1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package kvstore
4
5import (
6 "context"
7 "errors"
8 "fmt"
9 "time"
10 "unicode/utf8"
11
12 kv "bnema.dev/ermite-kv"
13 "google.golang.org/protobuf/proto"
14 "google.golang.org/protobuf/types/known/timestamppb"
15
16 storagepb "bnema.dev/ermite/gen/ermite/storage"
17 "bnema.dev/ermite/internal/domain"
18)
19
20const repoPrefix = "repo/"
21
22func repoKey(name domain.RepoName) []byte { return []byte(repoPrefix + name.String()) }
23
24// Create stores repo with an "absent" precondition: a repository with that
25// name, or a concurrent Create that commits first, yields domain.ErrAlreadyExists.
26func (s *Store) Create(ctx context.Context, repo domain.Repo) error {
27 val, err := encodeRepo(repo)
28 if err != nil {
29 return err
30 }
31 key := repoKey(repo.Name)
32 var b kv.Batch
33 b.RequireAbsent(key)
34 b.Put(key, val)
35 if _, err := s.db.Apply(ctx, &b); err != nil {
36 if errors.Is(err, kv.ErrConflict) {
37 return domain.ErrAlreadyExists
38 }
39 return fmt.Errorf("create repository %q: %w", repo.Name, err)
40 }
41 return nil
42}
43
44// Get returns the repository or domain.ErrNotFound.
45func (s *Store) Get(ctx context.Context, name domain.RepoName) (domain.Repo, error) {
46 item, ok := s.db.Get(ctx, repoKey(name))
47 if !ok {
48 return domain.Repo{}, domain.ErrNotFound
49 }
50 return decodeRepo(item.Value)
51}
52
53// List returns every repository ordered by name.
54func (s *Store) List(ctx context.Context) ([]domain.Repo, error) {
55 var repos []domain.Repo
56 for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(repoPrefix)) {
57 repo, err := decodeRepo(item.Value)
58 if err != nil {
59 return nil, fmt.Errorf("decode %s: %w", key, err)
60 }
61 repos = append(repos, repo)
62 }
63 return repos, nil
64}
65
66// Update replaces the stored repository. It returns domain.ErrNotFound if
67// there is none and domain.ErrConflict if another write changed it since it
68// was read.
69//
70//hexcheck:ignore suspicious-business-logic-in-adapter port method name only
71func (s *Store) Update(ctx context.Context, repo domain.Repo) error {
72 val, err := encodeRepo(repo)
73 if err != nil {
74 return err
75 }
76 key := repoKey(repo.Name)
77 item, ok := s.db.Get(ctx, key)
78 if !ok {
79 return domain.ErrNotFound
80 }
81 var b kv.Batch
82 b.RequireVersion(key, item.Version)
83 b.Put(key, val)
84 if _, err := s.db.Apply(ctx, &b); err != nil {
85 if errors.Is(err, kv.ErrConflict) {
86 return domain.ErrConflict
87 }
88 return fmt.Errorf("update repository %q: %w", repo.Name, err)
89 }
90 return nil
91}
92
93// Delete removes the repository; a missing one is not an error.
94func (s *Store) Delete(ctx context.Context, name domain.RepoName) error {
95 var b kv.Batch
96 b.Delete(repoKey(name))
97 if _, err := s.db.Apply(ctx, &b); err != nil {
98 return fmt.Errorf("delete repository %q: %w", name, err)
99 }
100 return nil
101}
102
103func encodeRepo(r domain.Repo) ([]byte, error) {
104 if r.Name.IsZero() {
105 return nil, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
106 }
107 if !utf8.ValidString(r.Description) {
108 return nil, errors.New("repository description is not valid UTF-8")
109 }
110 vis, err := encodeVisibility(r.Visibility)
111 if err != nil {
112 return nil, err
113 }
114 pb := &storagepb.Repo{
115 Name: r.Name.String(),
116 Description: r.Description,
117 Visibility: vis,
118 CreatedAt: encodeTime(r.CreatedAt),
119 PushedAt: encodeTime(r.PushedAt),
120 }
121 return mustMarshal(pb), nil
122}
123
124func decodeRepo(val []byte) (domain.Repo, error) {
125 var pb storagepb.Repo
126 if err := proto.Unmarshal(val, &pb); err != nil {
127 return domain.Repo{}, fmt.Errorf("decode repository: %w", err)
128 }
129 name, err := domain.ParseRepoName(pb.GetName())
130 if err != nil {
131 return domain.Repo{}, fmt.Errorf("decode repository: %w", err)
132 }
133 return domain.Repo{
134 Name: name,
135 Description: pb.GetDescription(),
136 Visibility: decodeVisibility(pb.GetVisibility()),
137 CreatedAt: decodeTime(pb.GetCreatedAt()),
138 PushedAt: decodeTime(pb.GetPushedAt()),
139 }, nil
140}
141
142func encodeVisibility(v domain.Visibility) (storagepb.Visibility, error) {
143 switch v {
144 case domain.VisibilityPublic:
145 return storagepb.Visibility_VISIBILITY_PUBLIC, nil
146 case domain.VisibilityPrivate:
147 return storagepb.Visibility_VISIBILITY_PRIVATE, nil
148 }
149 return storagepb.Visibility_VISIBILITY_UNSPECIFIED, fmt.Errorf("%w: %q", domain.ErrInvalidVisibility, v)
150}
151
152// decodeVisibility maps anything but public to private, including the
153// unspecified value and values from a newer schema: the safe side.
154func decodeVisibility(v storagepb.Visibility) domain.Visibility {
155 if v == storagepb.Visibility_VISIBILITY_PUBLIC {
156 return domain.VisibilityPublic
157 }
158 return domain.VisibilityPrivate
159}
160
161func encodeTime(t time.Time) *timestamppb.Timestamp {
162 if t.IsZero() {
163 return nil
164 }
165 return timestamppb.New(t)
166}
167
168func decodeTime(ts *timestamppb.Timestamp) time.Time {
169 if ts == nil {
170 return time.Time{}
171 }
172 return ts.AsTime()
173}