fix(releases): resolve the latest release from one snapshot and keep the index cheap to maintain
10 files · +242 -154
M internal/adapters/kvstore/releases.go +19 -9
| @@ -39,14 +39,22 @@ func relNameKey(a domain.ReleaseAsset) []byte { | |||
| 39 | 39 | func relLatestKey(repo domain.RepoName) []byte { return []byte("rellatest/" + repo.String()) } | |
| 40 | 40 | func relUseKey(repo domain.RepoName) []byte { return []byte("reluse/" + repo.String()) } | |
| 41 | 41 | ||
| 42 | - | // LatestReleaseID reads the latest-release index without scanning release records. | |
| 43 | - | func (c *Releases) LatestReleaseID(ctx context.Context, repo domain.RepoName) (int64, bool, error) { | |
| 44 | - | item, ok := c.db.Get(ctx, relLatestKey(repo)) | |
| 42 | + | // GetLatestRelease resolves the index and release from the same snapshot. | |
| 43 | + | func (c *Releases) GetLatestRelease(ctx context.Context, repo domain.RepoName) (domain.Release, error) { | |
| 44 | + | snap := c.db.Snapshot(ctx) | |
| 45 | + | item, ok := snap.Get(ctx, relLatestKey(repo)) | |
| 45 | 46 | if !ok { | |
| 46 | - | return 0, false, nil | |
| 47 | + | return domain.Release{}, domain.ErrNotFound | |
| 47 | 48 | } | |
| 48 | 49 | id, err := strconv.ParseInt(string(item.Value), 10, 64) | |
| 49 | - | return id, err == nil, err | |
| 50 | + | if err != nil { | |
| 51 | + | return domain.Release{}, err | |
| 52 | + | } | |
| 53 | + | item, ok = snap.Get(ctx, relKey(repo, id)) | |
| 54 | + | if !ok { | |
| 55 | + | return domain.Release{}, domain.ErrNotFound | |
| 56 | + | } | |
| 57 | + | return decodeRelease(item) | |
| 50 | 58 | } | |
| 51 | 59 | func encodeRelease(r domain.Release) []byte { | |
| 52 | 60 | return mustMarshal(&storagepb.Release{ | |
| @@ -296,10 +304,12 @@ func (c *Releases) ApplyReleases(ctx context.Context, w ports.ReleaseWrite) erro | |||
| 296 | 304 | b.RequireVersion(relUseKey(w.Repo), w.UsageVersion) | |
| 297 | 305 | } | |
| 298 | 306 | b.Put(relUseKey(w.Repo), mustMarshal(&storagepb.ReleaseUsage{Used: w.Used})) | |
| 299 | - | if w.LatestID > 0 { | |
| 300 | - | b.Put(relLatestKey(w.Repo), []byte(strconv.FormatInt(w.LatestID, 10))) | |
| 301 | - | } else { | |
| 302 | - | b.Delete(relLatestKey(w.Repo)) | |
| 307 | + | if w.SetLatest { | |
| 308 | + | if w.LatestID > 0 { | |
| 309 | + | b.Put(relLatestKey(w.Repo), []byte(strconv.FormatInt(w.LatestID, 10))) | |
| 310 | + | } else { | |
| 311 | + | b.Delete(relLatestKey(w.Repo)) | |
| 312 | + | } | |
| 303 | 313 | } | |
| 304 | 314 | snap := c.db.Snapshot(ctx) | |
| 305 | 315 | for _, r := range w.DeleteReleases { | |
| @@ -39,14 +39,22 @@ func relNameKey(a domain.ReleaseAsset) []byte { | |||||
| 39 | func relLatestKey(repo domain.RepoName) []byte { return []byte("rellatest/" + repo.String()) } | 39 | func relLatestKey(repo domain.RepoName) []byte { return []byte("rellatest/" + repo.String()) } | ||
| 40 | func relUseKey(repo domain.RepoName) []byte { return []byte("reluse/" + repo.String()) } | 40 | func relUseKey(repo domain.RepoName) []byte { return []byte("reluse/" + repo.String()) } | ||
| 41 | 41 | ||||
| 42 | - | // LatestReleaseID reads the latest-release index without scanning release records. | 42 | + | // GetLatestRelease resolves the index and release from the same snapshot. |
| 43 | - | func (c *Releases) LatestReleaseID(ctx context.Context, repo domain.RepoName) (int64, bool, error) { | 43 | + | func (c *Releases) GetLatestRelease(ctx context.Context, repo domain.RepoName) (domain.Release, error) { |
| 44 | - | item, ok := c.db.Get(ctx, relLatestKey(repo)) | 44 | + | snap := c.db.Snapshot(ctx) |
| 45 | + | item, ok := snap.Get(ctx, relLatestKey(repo)) | |||
| 45 | if !ok { | 46 | if !ok { | ||
| 46 | - | return 0, false, nil | 47 | + | return domain.Release{}, domain.ErrNotFound |
| 47 | } | 48 | } | ||
| 48 | id, err := strconv.ParseInt(string(item.Value), 10, 64) | 49 | id, err := strconv.ParseInt(string(item.Value), 10, 64) | ||
| 49 | - | return id, err == nil, err | 50 | + | if err != nil { |
| 51 | + | return domain.Release{}, err | |||
| 52 | + | } | |||
| 53 | + | item, ok = snap.Get(ctx, relKey(repo, id)) | |||
| 54 | + | if !ok { | |||
| 55 | + | return domain.Release{}, domain.ErrNotFound | |||
| 56 | + | } | |||
| 57 | + | return decodeRelease(item) | |||
| 50 | } | 58 | } | ||
| 51 | func encodeRelease(r domain.Release) []byte { | 59 | func encodeRelease(r domain.Release) []byte { | ||
| 52 | return mustMarshal(&storagepb.Release{ | 60 | return mustMarshal(&storagepb.Release{ | ||
| @@ -296,10 +304,12 @@ func (c *Releases) ApplyReleases(ctx context.Context, w ports.ReleaseWrite) erro | |||||
| 296 | b.RequireVersion(relUseKey(w.Repo), w.UsageVersion) | 304 | b.RequireVersion(relUseKey(w.Repo), w.UsageVersion) | ||
| 297 | } | 305 | } | ||
| 298 | b.Put(relUseKey(w.Repo), mustMarshal(&storagepb.ReleaseUsage{Used: w.Used})) | 306 | b.Put(relUseKey(w.Repo), mustMarshal(&storagepb.ReleaseUsage{Used: w.Used})) | ||
| 299 | - | if w.LatestID > 0 { | 307 | + | if w.SetLatest { |
| 300 | - | b.Put(relLatestKey(w.Repo), []byte(strconv.FormatInt(w.LatestID, 10))) | 308 | + | if w.LatestID > 0 { |
| 301 | - | } else { | 309 | + | b.Put(relLatestKey(w.Repo), []byte(strconv.FormatInt(w.LatestID, 10))) |
| 302 | - | b.Delete(relLatestKey(w.Repo)) | 310 | + | } else { |
| 311 | + | b.Delete(relLatestKey(w.Repo)) | |||
| 312 | + | } | |||
| 303 | } | 313 | } | ||
| 304 | snap := c.db.Snapshot(ctx) | 314 | snap := c.db.Snapshot(ctx) | ||
| 305 | for _, r := range w.DeleteReleases { | 315 | for _, r := range w.DeleteReleases { | ||
M internal/adapters/kvstore/releases_index.go +30 -10
| @@ -19,23 +19,43 @@ func (s *Store) buildReleaseLatestIndex(ctx context.Context) error { | |||
| 19 | 19 | if _, ok := s.db.Get(ctx, []byte(releaseLatestIndexMarker)); ok { | |
| 20 | 20 | return nil | |
| 21 | 21 | } | |
| 22 | - | releases := make(map[domain.RepoName][]domain.Release) | |
| 22 | + | var repo domain.RepoName | |
| 23 | + | var best domain.Release | |
| 24 | + | var eligible bool | |
| 25 | + | flush := func() error { | |
| 26 | + | var b kv.Batch | |
| 27 | + | if eligible { | |
| 28 | + | b.Put(relLatestKey(repo), []byte(strconv.FormatInt(best.ID, 10))) | |
| 29 | + | } else { | |
| 30 | + | b.Delete(relLatestKey(repo)) | |
| 31 | + | } | |
| 32 | + | if _, err := s.db.Apply(ctx, &b); err != nil { | |
| 33 | + | return fmt.Errorf("build latest release index: %w", err) | |
| 34 | + | } | |
| 35 | + | return nil | |
| 36 | + | } | |
| 37 | + | haveRepo := false | |
| 23 | 38 | for _, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte("rel/")) { | |
| 24 | 39 | rel, err := decodeRelease(item) | |
| 25 | 40 | if err != nil { | |
| 26 | 41 | return fmt.Errorf("build latest release index: %w", err) | |
| 27 | 42 | } | |
| 28 | - | releases[rel.Repo] = append(releases[rel.Repo], rel) | |
| 29 | - | } | |
| 30 | - | for repo, all := range releases { | |
| 31 | - | var b kv.Batch | |
| 32 | - | if latest, ok := domain.LatestRelease(all); ok { | |
| 33 | - | b.Put(relLatestKey(repo), []byte(strconv.FormatInt(latest.ID, 10))) | |
| 43 | + | if haveRepo && rel.Repo != repo { | |
| 44 | + | if err := flush(); err != nil { | |
| 45 | + | return err | |
| 46 | + | } | |
| 47 | + | best, eligible = domain.Release{}, false | |
| 48 | + | } | |
| 49 | + | repo, haveRepo = rel.Repo, true | |
| 50 | + | if eligible { | |
| 51 | + | best, eligible = domain.LatestRelease([]domain.Release{best, rel}) | |
| 34 | 52 | } else { | |
| 35 | - | b.Delete(relLatestKey(repo)) | |
| 53 | + | best, eligible = domain.LatestRelease([]domain.Release{rel}) | |
| 36 | 54 | } | |
| 37 | - | if _, err := s.db.Apply(ctx, &b); err != nil { | |
| 38 | - | return fmt.Errorf("build latest release index: %w", err) | |
| 55 | + | } | |
| 56 | + | if haveRepo { | |
| 57 | + | if err := flush(); err != nil { | |
| 58 | + | return err | |
| 39 | 59 | } | |
| 40 | 60 | } | |
| 41 | 61 | var b kv.Batch | |
| @@ -19,23 +19,43 @@ func (s *Store) buildReleaseLatestIndex(ctx context.Context) error { | |||||
| 19 | if _, ok := s.db.Get(ctx, []byte(releaseLatestIndexMarker)); ok { | 19 | if _, ok := s.db.Get(ctx, []byte(releaseLatestIndexMarker)); ok { | ||
| 20 | return nil | 20 | return nil | ||
| 21 | } | 21 | } | ||
| 22 | - | releases := make(map[domain.RepoName][]domain.Release) | 22 | + | var repo domain.RepoName |
| 23 | + | var best domain.Release | |||
| 24 | + | var eligible bool | |||
| 25 | + | flush := func() error { | |||
| 26 | + | var b kv.Batch | |||
| 27 | + | if eligible { | |||
| 28 | + | b.Put(relLatestKey(repo), []byte(strconv.FormatInt(best.ID, 10))) | |||
| 29 | + | } else { | |||
| 30 | + | b.Delete(relLatestKey(repo)) | |||
| 31 | + | } | |||
| 32 | + | if _, err := s.db.Apply(ctx, &b); err != nil { | |||
| 33 | + | return fmt.Errorf("build latest release index: %w", err) | |||
| 34 | + | } | |||
| 35 | + | return nil | |||
| 36 | + | } | |||
| 37 | + | haveRepo := false | |||
| 23 | for _, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte("rel/")) { | 38 | for _, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte("rel/")) { | ||
| 24 | rel, err := decodeRelease(item) | 39 | rel, err := decodeRelease(item) | ||
| 25 | if err != nil { | 40 | if err != nil { | ||
| 26 | return fmt.Errorf("build latest release index: %w", err) | 41 | return fmt.Errorf("build latest release index: %w", err) | ||
| 27 | } | 42 | } | ||
| 28 | - | releases[rel.Repo] = append(releases[rel.Repo], rel) | 43 | + | if haveRepo && rel.Repo != repo { |
| 29 | - | } | 44 | + | if err := flush(); err != nil { |
| 30 | - | for repo, all := range releases { | 45 | + | return err |
| 31 | - | var b kv.Batch | 46 | + | } |
| 32 | - | if latest, ok := domain.LatestRelease(all); ok { | 47 | + | best, eligible = domain.Release{}, false |
| 33 | - | b.Put(relLatestKey(repo), []byte(strconv.FormatInt(latest.ID, 10))) | 48 | + | } |
| 49 | + | repo, haveRepo = rel.Repo, true | |||
| 50 | + | if eligible { | |||
| 51 | + | best, eligible = domain.LatestRelease([]domain.Release{best, rel}) | |||
| 34 | } else { | 52 | } else { | ||
| 35 | - | b.Delete(relLatestKey(repo)) | 53 | + | best, eligible = domain.LatestRelease([]domain.Release{rel}) |
| 36 | } | 54 | } | ||
| 37 | - | if _, err := s.db.Apply(ctx, &b); err != nil { | 55 | + | } |
| 38 | - | return fmt.Errorf("build latest release index: %w", err) | 56 | + | if haveRepo { |
| 57 | + | if err := flush(); err != nil { | |||
| 58 | + | return err | |||
| 39 | } | 59 | } | ||
| 40 | } | 60 | } | ||
| 41 | var b kv.Batch | 61 | var b kv.Batch | ||
M internal/adapters/kvstore/releases_index_test.go +70 -27
| @@ -2,12 +2,14 @@ | |||
| 2 | 2 | package kvstore | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | + | "context" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 5 | 9 | kv "bnema.dev/ermite-kv" | |
| 6 | 10 | "bnema.dev/ermite/internal/domain" | |
| 7 | 11 | "bnema.dev/ermite/internal/ports" | |
| 8 | - | "context" | |
| 9 | 12 | "github.com/stretchr/testify/require" | |
| 10 | - | "testing" | |
| 11 | 13 | ) | |
| 12 | 14 | ||
| 13 | 15 | func TestReleaseLatestIndexBackfill(t *testing.T) { | |
| @@ -15,23 +17,27 @@ func TestReleaseLatestIndexBackfill(t *testing.T) { | |||
| 15 | 17 | dir := t.TempDir() | |
| 16 | 18 | s, err := Open(ctx, dir) | |
| 17 | 19 | require.NoError(t, err) | |
| 18 | - | repo, err := domain.ParseRepoName("backfill") | |
| 19 | - | require.NoError(t, err) | |
| 20 | - | require.NoError(t, s.Create(ctx, domain.Repo{Name: repo, Visibility: domain.VisibilityPublic})) | |
| 21 | - | rel := domain.Release{ID: 1, Repo: repo, Tag: "v1"} | |
| 22 | - | require.NoError(t, s.Releases().ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, PutReleases: []domain.Release{rel}, LatestID: 1})) | |
| 23 | - | id, ok, err := s.Releases().LatestReleaseID(ctx, repo) | |
| 24 | - | require.NoError(t, err) | |
| 25 | - | require.True(t, ok) | |
| 26 | - | require.EqualValues(t, 1, id) | |
| 27 | - | _, version, err := s.Releases().ReleaseUsage(ctx, repo) | |
| 28 | - | require.NoError(t, err) | |
| 29 | - | require.NoError(t, s.Releases().ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version})) | |
| 30 | - | _, ok, err = s.Releases().LatestReleaseID(ctx, repo) | |
| 31 | - | require.NoError(t, err) | |
| 32 | - | require.False(t, ok) | |
| 33 | - | // Simulate pre-index data, then exercise the actual startup hook. | |
| 20 | + | var repos []domain.RepoName | |
| 21 | + | for _, raw := range []string{"backfill", "empty", "newest"} { | |
| 22 | + | repo, err := domain.ParseRepoName(raw) | |
| 23 | + | require.NoError(t, err) | |
| 24 | + | repos = append(repos, repo) | |
| 25 | + | } | |
| 26 | + | for _, repo := range repos { | |
| 27 | + | require.NoError(t, s.Create(ctx, domain.Repo{Name: repo, Visibility: domain.VisibilityPublic})) | |
| 28 | + | } | |
| 34 | 29 | var b kv.Batch | |
| 30 | + | for i, repo := range repos { | |
| 31 | + | for j := 1; j <= 5; j++ { | |
| 32 | + | rel := domain.Release{ID: int64(i*10 + j), Repo: repo, Tag: string(rune('a' + j)), CreatedAt: time.Unix(int64(j), 0)} | |
| 33 | + | rel.Draft = j == 5 || i == 1 | |
| 34 | + | rel.Prerelease = j == 4 | |
| 35 | + | rel.MakeLatest = j == 1 && i == 0 | |
| 36 | + | b.Put(relKey(repo, rel.ID), encodeRelease(rel)) | |
| 37 | + | } | |
| 38 | + | // A stale key must also be removed for a repo with no eligible release. | |
| 39 | + | b.Put(relLatestKey(repo), []byte("999")) | |
| 40 | + | } | |
| 35 | 41 | b.Delete([]byte(releaseLatestIndexMarker)) | |
| 36 | 42 | _, err = s.db.Apply(ctx, &b) | |
| 37 | 43 | require.NoError(t, err) | |
| @@ -39,16 +45,53 @@ func TestReleaseLatestIndexBackfill(t *testing.T) { | |||
| 39 | 45 | s, err = Open(ctx, dir) | |
| 40 | 46 | require.NoError(t, err) | |
| 41 | 47 | defer func() { require.NoError(t, s.Close(context.Background())) }() | |
| 42 | - | id, ok, err = s.Releases().LatestReleaseID(ctx, repo) | |
| 48 | + | for i, want := range []int64{1, 0, 23} { | |
| 49 | + | got, err := s.Releases().GetLatestRelease(ctx, repos[i]) | |
| 50 | + | if want == 0 { | |
| 51 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 52 | + | } else { | |
| 53 | + | require.NoError(t, err) | |
| 54 | + | require.Equal(t, want, got.ID) | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | require.NoError(t, s.buildReleaseLatestIndex(ctx)) | |
| 58 | + | got, err := s.Releases().GetLatestRelease(ctx, repos[0]) | |
| 43 | 59 | require.NoError(t, err) | |
| 44 | - | require.True(t, ok) | |
| 45 | - | require.EqualValues(t, 1, id) | |
| 46 | - | require.NoError(t, s.Delete(ctx, repo)) | |
| 47 | - | _, ok, err = s.Releases().LatestReleaseID(ctx, repo) | |
| 60 | + | require.EqualValues(t, 1, got.ID) | |
| 61 | + | require.NoError(t, s.Delete(ctx, repos[0])) | |
| 62 | + | _, err = s.Releases().GetLatestRelease(ctx, repos[0]) | |
| 63 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func TestGetLatestReleaseAtomicUpdate(t *testing.T) { | |
| 67 | + | ctx := t.Context() | |
| 68 | + | s, err := Open(ctx, t.TempDir()) | |
| 48 | 69 | require.NoError(t, err) | |
| 49 | - | require.False(t, ok) | |
| 50 | - | require.NoError(t, s.buildReleaseLatestIndex(ctx)) | |
| 51 | - | _, ok, err = s.Releases().LatestReleaseID(ctx, repo) | |
| 70 | + | defer func() { require.NoError(t, s.Close(context.Background())) }() | |
| 71 | + | repo, err := domain.ParseRepoName("atomic") | |
| 72 | + | require.NoError(t, err) | |
| 73 | + | require.NoError(t, s.Create(ctx, domain.Repo{Name: repo, Visibility: domain.VisibilityPublic})) | |
| 74 | + | a := domain.Release{ID: 1, Repo: repo, Tag: "a"} | |
| 75 | + | b := domain.Release{ID: 2, Repo: repo, Tag: "b"} | |
| 76 | + | store := s.Releases() | |
| 77 | + | require.NoError(t, store.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, PutReleases: []domain.Release{a, b}, SetLatest: true, LatestID: a.ID})) | |
| 78 | + | a, err = store.GetLatestRelease(ctx, repo) | |
| 79 | + | require.NoError(t, err) | |
| 80 | + | require.EqualValues(t, 1, a.ID) | |
| 81 | + | _, version, err := store.ReleaseUsage(ctx, repo) | |
| 82 | + | require.NoError(t, err) | |
| 83 | + | a.Prerelease = true | |
| 84 | + | require.NoError(t, store.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version, PutReleases: []domain.Release{a}, SetLatest: true, LatestID: b.ID})) | |
| 85 | + | got, err := store.GetLatestRelease(ctx, repo) | |
| 52 | 86 | require.NoError(t, err) | |
| 53 | - | require.False(t, ok) | |
| 87 | + | require.EqualValues(t, 2, got.ID) | |
| 88 | + | // An asset-only/usage write leaves the index untouched, including its version. | |
| 89 | + | before, ok := s.db.Get(ctx, relLatestKey(repo)) | |
| 90 | + | require.True(t, ok) | |
| 91 | + | _, version, err = store.ReleaseUsage(ctx, repo) | |
| 92 | + | require.NoError(t, err) | |
| 93 | + | require.NoError(t, store.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version, Used: 10})) | |
| 94 | + | after, ok := s.db.Get(ctx, relLatestKey(repo)) | |
| 95 | + | require.True(t, ok) | |
| 96 | + | require.Equal(t, before, after) | |
| 54 | 97 | } | |
| @@ -2,12 +2,14 @@ | |||||
| 2 | package kvstore | 2 | package kvstore | ||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | + | "context" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 5 | kv "bnema.dev/ermite-kv" | 9 | kv "bnema.dev/ermite-kv" | ||
| 6 | "bnema.dev/ermite/internal/domain" | 10 | "bnema.dev/ermite/internal/domain" | ||
| 7 | "bnema.dev/ermite/internal/ports" | 11 | "bnema.dev/ermite/internal/ports" | ||
| 8 | - | "context" | |||
| 9 | "github.com/stretchr/testify/require" | 12 | "github.com/stretchr/testify/require" | ||
| 10 | - | "testing" | |||
| 11 | ) | 13 | ) | ||
| 12 | 14 | ||||
| 13 | func TestReleaseLatestIndexBackfill(t *testing.T) { | 15 | func TestReleaseLatestIndexBackfill(t *testing.T) { | ||
| @@ -15,23 +17,27 @@ func TestReleaseLatestIndexBackfill(t *testing.T) { | |||||
| 15 | dir := t.TempDir() | 17 | dir := t.TempDir() | ||
| 16 | s, err := Open(ctx, dir) | 18 | s, err := Open(ctx, dir) | ||
| 17 | require.NoError(t, err) | 19 | require.NoError(t, err) | ||
| 18 | - | repo, err := domain.ParseRepoName("backfill") | 20 | + | var repos []domain.RepoName |
| 19 | - | require.NoError(t, err) | 21 | + | for _, raw := range []string{"backfill", "empty", "newest"} { |
| 20 | - | require.NoError(t, s.Create(ctx, domain.Repo{Name: repo, Visibility: domain.VisibilityPublic})) | 22 | + | repo, err := domain.ParseRepoName(raw) |
| 21 | - | rel := domain.Release{ID: 1, Repo: repo, Tag: "v1"} | 23 | + | require.NoError(t, err) |
| 22 | - | require.NoError(t, s.Releases().ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, PutReleases: []domain.Release{rel}, LatestID: 1})) | 24 | + | repos = append(repos, repo) |
| 23 | - | id, ok, err := s.Releases().LatestReleaseID(ctx, repo) | 25 | + | } |
| 24 | - | require.NoError(t, err) | 26 | + | for _, repo := range repos { |
| 25 | - | require.True(t, ok) | 27 | + | require.NoError(t, s.Create(ctx, domain.Repo{Name: repo, Visibility: domain.VisibilityPublic})) |
| 26 | - | require.EqualValues(t, 1, id) | 28 | + | } |
| 27 | - | _, version, err := s.Releases().ReleaseUsage(ctx, repo) | |||
| 28 | - | require.NoError(t, err) | |||
| 29 | - | require.NoError(t, s.Releases().ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version})) | |||
| 30 | - | _, ok, err = s.Releases().LatestReleaseID(ctx, repo) | |||
| 31 | - | require.NoError(t, err) | |||
| 32 | - | require.False(t, ok) | |||
| 33 | - | // Simulate pre-index data, then exercise the actual startup hook. | |||
| 34 | var b kv.Batch | 29 | var b kv.Batch | ||
| 30 | + | for i, repo := range repos { | |||
| 31 | + | for j := 1; j <= 5; j++ { | |||
| 32 | + | rel := domain.Release{ID: int64(i*10 + j), Repo: repo, Tag: string(rune('a' + j)), CreatedAt: time.Unix(int64(j), 0)} | |||
| 33 | + | rel.Draft = j == 5 || i == 1 | |||
| 34 | + | rel.Prerelease = j == 4 | |||
| 35 | + | rel.MakeLatest = j == 1 && i == 0 | |||
| 36 | + | b.Put(relKey(repo, rel.ID), encodeRelease(rel)) | |||
| 37 | + | } | |||
| 38 | + | // A stale key must also be removed for a repo with no eligible release. | |||
| 39 | + | b.Put(relLatestKey(repo), []byte("999")) | |||
| 40 | + | } | |||
| 35 | b.Delete([]byte(releaseLatestIndexMarker)) | 41 | b.Delete([]byte(releaseLatestIndexMarker)) | ||
| 36 | _, err = s.db.Apply(ctx, &b) | 42 | _, err = s.db.Apply(ctx, &b) | ||
| 37 | require.NoError(t, err) | 43 | require.NoError(t, err) | ||
| @@ -39,16 +45,53 @@ func TestReleaseLatestIndexBackfill(t *testing.T) { | |||||
| 39 | s, err = Open(ctx, dir) | 45 | s, err = Open(ctx, dir) | ||
| 40 | require.NoError(t, err) | 46 | require.NoError(t, err) | ||
| 41 | defer func() { require.NoError(t, s.Close(context.Background())) }() | 47 | defer func() { require.NoError(t, s.Close(context.Background())) }() | ||
| 42 | - | id, ok, err = s.Releases().LatestReleaseID(ctx, repo) | 48 | + | for i, want := range []int64{1, 0, 23} { |
| 49 | + | got, err := s.Releases().GetLatestRelease(ctx, repos[i]) | |||
| 50 | + | if want == 0 { | |||
| 51 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |||
| 52 | + | } else { | |||
| 53 | + | require.NoError(t, err) | |||
| 54 | + | require.Equal(t, want, got.ID) | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | require.NoError(t, s.buildReleaseLatestIndex(ctx)) | |||
| 58 | + | got, err := s.Releases().GetLatestRelease(ctx, repos[0]) | |||
| 43 | require.NoError(t, err) | 59 | require.NoError(t, err) | ||
| 44 | - | require.True(t, ok) | 60 | + | require.EqualValues(t, 1, got.ID) |
| 45 | - | require.EqualValues(t, 1, id) | 61 | + | require.NoError(t, s.Delete(ctx, repos[0])) |
| 46 | - | require.NoError(t, s.Delete(ctx, repo)) | 62 | + | _, err = s.Releases().GetLatestRelease(ctx, repos[0]) |
| 47 | - | _, ok, err = s.Releases().LatestReleaseID(ctx, repo) | 63 | + | require.ErrorIs(t, err, domain.ErrNotFound) |
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func TestGetLatestReleaseAtomicUpdate(t *testing.T) { | |||
| 67 | + | ctx := t.Context() | |||
| 68 | + | s, err := Open(ctx, t.TempDir()) | |||
| 48 | require.NoError(t, err) | 69 | require.NoError(t, err) | ||
| 49 | - | require.False(t, ok) | 70 | + | defer func() { require.NoError(t, s.Close(context.Background())) }() |
| 50 | - | require.NoError(t, s.buildReleaseLatestIndex(ctx)) | 71 | + | repo, err := domain.ParseRepoName("atomic") |
| 51 | - | _, ok, err = s.Releases().LatestReleaseID(ctx, repo) | 72 | + | require.NoError(t, err) |
| 73 | + | require.NoError(t, s.Create(ctx, domain.Repo{Name: repo, Visibility: domain.VisibilityPublic})) | |||
| 74 | + | a := domain.Release{ID: 1, Repo: repo, Tag: "a"} | |||
| 75 | + | b := domain.Release{ID: 2, Repo: repo, Tag: "b"} | |||
| 76 | + | store := s.Releases() | |||
| 77 | + | require.NoError(t, store.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, PutReleases: []domain.Release{a, b}, SetLatest: true, LatestID: a.ID})) | |||
| 78 | + | a, err = store.GetLatestRelease(ctx, repo) | |||
| 79 | + | require.NoError(t, err) | |||
| 80 | + | require.EqualValues(t, 1, a.ID) | |||
| 81 | + | _, version, err := store.ReleaseUsage(ctx, repo) | |||
| 82 | + | require.NoError(t, err) | |||
| 83 | + | a.Prerelease = true | |||
| 84 | + | require.NoError(t, store.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version, PutReleases: []domain.Release{a}, SetLatest: true, LatestID: b.ID})) | |||
| 85 | + | got, err := store.GetLatestRelease(ctx, repo) | |||
| 52 | require.NoError(t, err) | 86 | require.NoError(t, err) | ||
| 53 | - | require.False(t, ok) | 87 | + | require.EqualValues(t, 2, got.ID) |
| 88 | + | // An asset-only/usage write leaves the index untouched, including its version. | |||
| 89 | + | before, ok := s.db.Get(ctx, relLatestKey(repo)) | |||
| 90 | + | require.True(t, ok) | |||
| 91 | + | _, version, err = store.ReleaseUsage(ctx, repo) | |||
| 92 | + | require.NoError(t, err) | |||
| 93 | + | require.NoError(t, store.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version, Used: 10})) | |||
| 94 | + | after, ok := s.db.Get(ctx, relLatestKey(repo)) | |||
| 95 | + | require.True(t, ok) | |||
| 96 | + | require.Equal(t, before, after) | |||
| 54 | } | 97 | } | ||
M internal/app/web_issue_test.go +1 -1
| @@ -28,7 +28,7 @@ func TestWebIssuePagesEndToEnd(t *testing.T) { | |||
| 28 | 28 | // Off by default: no page, no tab, for the owner as well. | |
| 29 | 29 | assert.Equal(t, 404, b.get("/proj/issues", as(token)).status) | |
| 30 | 30 | assert.Equal(t, 404, b.get("/proj/issues/new", as(token)).status) | |
| 31 | - | assert.NotContains(t, b.get("/proj", as(token)).body, `data-key="i"`) | |
| 31 | + | assert.NotContains(t, b.get("/proj", as(token)).body, `data-key="g i"`) | |
| 32 | 32 | ||
| 33 | 33 | mode := domain.IssuesOwner | |
| 34 | 34 | _, err := r.forge.Repos.Update(r.ctx, domain.Root(), r.name("proj"), ports.UpdateRepoParams{Issues: &mode}) | |
| @@ -28,7 +28,7 @@ func TestWebIssuePagesEndToEnd(t *testing.T) { | |||||
| 28 | // Off by default: no page, no tab, for the owner as well. | 28 | // Off by default: no page, no tab, for the owner as well. | ||
| 29 | assert.Equal(t, 404, b.get("/proj/issues", as(token)).status) | 29 | assert.Equal(t, 404, b.get("/proj/issues", as(token)).status) | ||
| 30 | assert.Equal(t, 404, b.get("/proj/issues/new", as(token)).status) | 30 | assert.Equal(t, 404, b.get("/proj/issues/new", as(token)).status) | ||
| 31 | - | assert.NotContains(t, b.get("/proj", as(token)).body, `data-key="i"`) | 31 | + | assert.NotContains(t, b.get("/proj", as(token)).body, `data-key="g i"`) |
| 32 | 32 | ||||
| 33 | mode := domain.IssuesOwner | 33 | mode := domain.IssuesOwner | ||
| 34 | _, err := r.forge.Repos.Update(r.ctx, domain.Root(), r.name("proj"), ports.UpdateRepoParams{Issues: &mode}) | 34 | _, err := r.forge.Repos.Update(r.ctx, domain.Root(), r.name("proj"), ports.UpdateRepoParams{Issues: &mode}) | ||
M internal/app/web_mr_test.go +1 -1
| @@ -93,7 +93,7 @@ func TestWebMergeRequestPagesRender(t *testing.T) { | |||
| 93 | 93 | require.Equal(t, 200, list.status) | |
| 94 | 94 | assert.Contains(t, list.body, "no open merge requests") | |
| 95 | 95 | assert.Contains(t, list.body, `href="/proj/compare/feat/search"`) | |
| 96 | - | assert.Contains(t, list.body, `data-key="m"`, "the section of the nav") | |
| 96 | + | assert.Contains(t, list.body, `data-key="g m"`, "the section of the nav") | |
| 97 | 97 | assert.NotContains(t, strings.ToLower(list.body), "proposal") | |
| 98 | 98 | ||
| 99 | 99 | // Open one from the compare page. | |
| @@ -93,7 +93,7 @@ func TestWebMergeRequestPagesRender(t *testing.T) { | |||||
| 93 | require.Equal(t, 200, list.status) | 93 | require.Equal(t, 200, list.status) | ||
| 94 | assert.Contains(t, list.body, "no open merge requests") | 94 | assert.Contains(t, list.body, "no open merge requests") | ||
| 95 | assert.Contains(t, list.body, `href="/proj/compare/feat/search"`) | 95 | assert.Contains(t, list.body, `href="/proj/compare/feat/search"`) | ||
| 96 | - | assert.Contains(t, list.body, `data-key="m"`, "the section of the nav") | 96 | + | assert.Contains(t, list.body, `data-key="g m"`, "the section of the nav") |
| 97 | assert.NotContains(t, strings.ToLower(list.body), "proposal") | 97 | assert.NotContains(t, strings.ToLower(list.body), "proposal") | ||
| 98 | 98 | ||||
| 99 | // Open one from the compare page. | 99 | // Open one from the compare page. | ||
M internal/ports/mocks/ReleaseStore.go +66 -72
| @@ -311,6 +311,72 @@ func (_c *ReleaseStore_GetAssetByName_Call) RunAndReturn(run func(ctx context.Co | |||
| 311 | 311 | return _c | |
| 312 | 312 | } | |
| 313 | 313 | ||
| 314 | + | // GetLatestRelease provides a mock function for the type ReleaseStore | |
| 315 | + | func (_mock *ReleaseStore) GetLatestRelease(ctx context.Context, repo domain.RepoName) (domain.Release, error) { | |
| 316 | + | ret := _mock.Called(ctx, repo) | |
| 317 | + | ||
| 318 | + | if len(ret) == 0 { | |
| 319 | + | panic("no return value specified for GetLatestRelease") | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | var r0 domain.Release | |
| 323 | + | var r1 error | |
| 324 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) (domain.Release, error)); ok { | |
| 325 | + | return returnFunc(ctx, repo) | |
| 326 | + | } | |
| 327 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) domain.Release); ok { | |
| 328 | + | r0 = returnFunc(ctx, repo) | |
| 329 | + | } else { | |
| 330 | + | r0 = ret.Get(0).(domain.Release) | |
| 331 | + | } | |
| 332 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName) error); ok { | |
| 333 | + | r1 = returnFunc(ctx, repo) | |
| 334 | + | } else { | |
| 335 | + | r1 = ret.Error(1) | |
| 336 | + | } | |
| 337 | + | return r0, r1 | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | // ReleaseStore_GetLatestRelease_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetLatestRelease' | |
| 341 | + | type ReleaseStore_GetLatestRelease_Call struct { | |
| 342 | + | *mock.Call | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | // GetLatestRelease is a helper method to define mock.On call | |
| 346 | + | // - ctx context.Context | |
| 347 | + | // - repo domain.RepoName | |
| 348 | + | func (_e *ReleaseStore_Expecter) GetLatestRelease(ctx any, repo any) *ReleaseStore_GetLatestRelease_Call { | |
| 349 | + | return &ReleaseStore_GetLatestRelease_Call{Call: _e.mock.On("GetLatestRelease", ctx, repo)} | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | func (_c *ReleaseStore_GetLatestRelease_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *ReleaseStore_GetLatestRelease_Call { | |
| 353 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 354 | + | var arg0 context.Context | |
| 355 | + | if args[0] != nil { | |
| 356 | + | arg0 = args[0].(context.Context) | |
| 357 | + | } | |
| 358 | + | var arg1 domain.RepoName | |
| 359 | + | if args[1] != nil { | |
| 360 | + | arg1 = args[1].(domain.RepoName) | |
| 361 | + | } | |
| 362 | + | run( | |
| 363 | + | arg0, | |
| 364 | + | arg1, | |
| 365 | + | ) | |
| 366 | + | }) | |
| 367 | + | return _c | |
| 368 | + | } | |
| 369 | + | ||
| 370 | + | func (_c *ReleaseStore_GetLatestRelease_Call) Return(release domain.Release, err error) *ReleaseStore_GetLatestRelease_Call { | |
| 371 | + | _c.Call.Return(release, err) | |
| 372 | + | return _c | |
| 373 | + | } | |
| 374 | + | ||
| 375 | + | func (_c *ReleaseStore_GetLatestRelease_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName) (domain.Release, error)) *ReleaseStore_GetLatestRelease_Call { | |
| 376 | + | _c.Call.Return(run) | |
| 377 | + | return _c | |
| 378 | + | } | |
| 379 | + | ||
| 314 | 380 | // GetRelease provides a mock function for the type ReleaseStore | |
| 315 | 381 | func (_mock *ReleaseStore) GetRelease(ctx context.Context, repo domain.RepoName, id int64) (domain.Release, error) { | |
| 316 | 382 | ret := _mock.Called(ctx, repo, id) | |
| @@ -455,78 +521,6 @@ func (_c *ReleaseStore_GetReleaseByTag_Call) RunAndReturn(run func(ctx context.C | |||
| 455 | 521 | return _c | |
| 456 | 522 | } | |
| 457 | 523 | ||
| 458 | - | // LatestReleaseID provides a mock function for the type ReleaseStore | |
| 459 | - | func (_mock *ReleaseStore) LatestReleaseID(ctx context.Context, repo domain.RepoName) (int64, bool, error) { | |
| 460 | - | ret := _mock.Called(ctx, repo) | |
| 461 | - | ||
| 462 | - | if len(ret) == 0 { | |
| 463 | - | panic("no return value specified for LatestReleaseID") | |
| 464 | - | } | |
| 465 | - | ||
| 466 | - | var r0 int64 | |
| 467 | - | var r1 bool | |
| 468 | - | var r2 error | |
| 469 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) (int64, bool, error)); ok { | |
| 470 | - | return returnFunc(ctx, repo) | |
| 471 | - | } | |
| 472 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) int64); ok { | |
| 473 | - | r0 = returnFunc(ctx, repo) | |
| 474 | - | } else { | |
| 475 | - | r0 = ret.Get(0).(int64) | |
| 476 | - | } | |
| 477 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName) bool); ok { | |
| 478 | - | r1 = returnFunc(ctx, repo) | |
| 479 | - | } else { | |
| 480 | - | r1 = ret.Get(1).(bool) | |
| 481 | - | } | |
| 482 | - | if returnFunc, ok := ret.Get(2).(func(context.Context, domain.RepoName) error); ok { | |
| 483 | - | r2 = returnFunc(ctx, repo) | |
| 484 | - | } else { | |
| 485 | - | r2 = ret.Error(2) | |
| 486 | - | } | |
| 487 | - | return r0, r1, r2 | |
| 488 | - | } | |
| 489 | - | ||
| 490 | - | // ReleaseStore_LatestReleaseID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LatestReleaseID' | |
| 491 | - | type ReleaseStore_LatestReleaseID_Call struct { | |
| 492 | - | *mock.Call | |
| 493 | - | } | |
| 494 | - | ||
| 495 | - | // LatestReleaseID is a helper method to define mock.On call | |
| 496 | - | // - ctx context.Context | |
| 497 | - | // - repo domain.RepoName | |
| 498 | - | func (_e *ReleaseStore_Expecter) LatestReleaseID(ctx any, repo any) *ReleaseStore_LatestReleaseID_Call { | |
| 499 | - | return &ReleaseStore_LatestReleaseID_Call{Call: _e.mock.On("LatestReleaseID", ctx, repo)} | |
| 500 | - | } | |
| 501 | - | ||
| 502 | - | func (_c *ReleaseStore_LatestReleaseID_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *ReleaseStore_LatestReleaseID_Call { | |
| 503 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 504 | - | var arg0 context.Context | |
| 505 | - | if args[0] != nil { | |
| 506 | - | arg0 = args[0].(context.Context) | |
| 507 | - | } | |
| 508 | - | var arg1 domain.RepoName | |
| 509 | - | if args[1] != nil { | |
| 510 | - | arg1 = args[1].(domain.RepoName) | |
| 511 | - | } | |
| 512 | - | run( | |
| 513 | - | arg0, | |
| 514 | - | arg1, | |
| 515 | - | ) | |
| 516 | - | }) | |
| 517 | - | return _c | |
| 518 | - | } | |
| 519 | - | ||
| 520 | - | func (_c *ReleaseStore_LatestReleaseID_Call) Return(id int64, ok bool, err error) *ReleaseStore_LatestReleaseID_Call { | |
| 521 | - | _c.Call.Return(id, ok, err) | |
| 522 | - | return _c | |
| 523 | - | } | |
| 524 | - | ||
| 525 | - | func (_c *ReleaseStore_LatestReleaseID_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName) (int64, bool, error)) *ReleaseStore_LatestReleaseID_Call { | |
| 526 | - | _c.Call.Return(run) | |
| 527 | - | return _c | |
| 528 | - | } | |
| 529 | - | ||
| 530 | 524 | // ListAssets provides a mock function for the type ReleaseStore | |
| 531 | 525 | func (_mock *ReleaseStore) ListAssets(ctx context.Context, repo domain.RepoName, releaseID int64) ([]domain.ReleaseAsset, error) { | |
| 532 | 526 | ret := _mock.Called(ctx, repo, releaseID) | |
| @@ -311,6 +311,72 @@ func (_c *ReleaseStore_GetAssetByName_Call) RunAndReturn(run func(ctx context.Co | |||||
| 311 | return _c | 311 | return _c | ||
| 312 | } | 312 | } | ||
| 313 | 313 | ||||
| 314 | + | // GetLatestRelease provides a mock function for the type ReleaseStore | |||
| 315 | + | func (_mock *ReleaseStore) GetLatestRelease(ctx context.Context, repo domain.RepoName) (domain.Release, error) { | |||
| 316 | + | ret := _mock.Called(ctx, repo) | |||
| 317 | + | ||||
| 318 | + | if len(ret) == 0 { | |||
| 319 | + | panic("no return value specified for GetLatestRelease") | |||
| 320 | + | } | |||
| 321 | + | ||||
| 322 | + | var r0 domain.Release | |||
| 323 | + | var r1 error | |||
| 324 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) (domain.Release, error)); ok { | |||
| 325 | + | return returnFunc(ctx, repo) | |||
| 326 | + | } | |||
| 327 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) domain.Release); ok { | |||
| 328 | + | r0 = returnFunc(ctx, repo) | |||
| 329 | + | } else { | |||
| 330 | + | r0 = ret.Get(0).(domain.Release) | |||
| 331 | + | } | |||
| 332 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName) error); ok { | |||
| 333 | + | r1 = returnFunc(ctx, repo) | |||
| 334 | + | } else { | |||
| 335 | + | r1 = ret.Error(1) | |||
| 336 | + | } | |||
| 337 | + | return r0, r1 | |||
| 338 | + | } | |||
| 339 | + | ||||
| 340 | + | // ReleaseStore_GetLatestRelease_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetLatestRelease' | |||
| 341 | + | type ReleaseStore_GetLatestRelease_Call struct { | |||
| 342 | + | *mock.Call | |||
| 343 | + | } | |||
| 344 | + | ||||
| 345 | + | // GetLatestRelease is a helper method to define mock.On call | |||
| 346 | + | // - ctx context.Context | |||
| 347 | + | // - repo domain.RepoName | |||
| 348 | + | func (_e *ReleaseStore_Expecter) GetLatestRelease(ctx any, repo any) *ReleaseStore_GetLatestRelease_Call { | |||
| 349 | + | return &ReleaseStore_GetLatestRelease_Call{Call: _e.mock.On("GetLatestRelease", ctx, repo)} | |||
| 350 | + | } | |||
| 351 | + | ||||
| 352 | + | func (_c *ReleaseStore_GetLatestRelease_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *ReleaseStore_GetLatestRelease_Call { | |||
| 353 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 354 | + | var arg0 context.Context | |||
| 355 | + | if args[0] != nil { | |||
| 356 | + | arg0 = args[0].(context.Context) | |||
| 357 | + | } | |||
| 358 | + | var arg1 domain.RepoName | |||
| 359 | + | if args[1] != nil { | |||
| 360 | + | arg1 = args[1].(domain.RepoName) | |||
| 361 | + | } | |||
| 362 | + | run( | |||
| 363 | + | arg0, | |||
| 364 | + | arg1, | |||
| 365 | + | ) | |||
| 366 | + | }) | |||
| 367 | + | return _c | |||
| 368 | + | } | |||
| 369 | + | ||||
| 370 | + | func (_c *ReleaseStore_GetLatestRelease_Call) Return(release domain.Release, err error) *ReleaseStore_GetLatestRelease_Call { | |||
| 371 | + | _c.Call.Return(release, err) | |||
| 372 | + | return _c | |||
| 373 | + | } | |||
| 374 | + | ||||
| 375 | + | func (_c *ReleaseStore_GetLatestRelease_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName) (domain.Release, error)) *ReleaseStore_GetLatestRelease_Call { | |||
| 376 | + | _c.Call.Return(run) | |||
| 377 | + | return _c | |||
| 378 | + | } | |||
| 379 | + | ||||
| 314 | // GetRelease provides a mock function for the type ReleaseStore | 380 | // GetRelease provides a mock function for the type ReleaseStore | ||
| 315 | func (_mock *ReleaseStore) GetRelease(ctx context.Context, repo domain.RepoName, id int64) (domain.Release, error) { | 381 | func (_mock *ReleaseStore) GetRelease(ctx context.Context, repo domain.RepoName, id int64) (domain.Release, error) { | ||
| 316 | ret := _mock.Called(ctx, repo, id) | 382 | ret := _mock.Called(ctx, repo, id) | ||
| @@ -455,78 +521,6 @@ func (_c *ReleaseStore_GetReleaseByTag_Call) RunAndReturn(run func(ctx context.C | |||||
| 455 | return _c | 521 | return _c | ||
| 456 | } | 522 | } | ||
| 457 | 523 | ||||
| 458 | - | // LatestReleaseID provides a mock function for the type ReleaseStore | |||
| 459 | - | func (_mock *ReleaseStore) LatestReleaseID(ctx context.Context, repo domain.RepoName) (int64, bool, error) { | |||
| 460 | - | ret := _mock.Called(ctx, repo) | |||
| 461 | - | ||||
| 462 | - | if len(ret) == 0 { | |||
| 463 | - | panic("no return value specified for LatestReleaseID") | |||
| 464 | - | } | |||
| 465 | - | ||||
| 466 | - | var r0 int64 | |||
| 467 | - | var r1 bool | |||
| 468 | - | var r2 error | |||
| 469 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) (int64, bool, error)); ok { | |||
| 470 | - | return returnFunc(ctx, repo) | |||
| 471 | - | } | |||
| 472 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) int64); ok { | |||
| 473 | - | r0 = returnFunc(ctx, repo) | |||
| 474 | - | } else { | |||
| 475 | - | r0 = ret.Get(0).(int64) | |||
| 476 | - | } | |||
| 477 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName) bool); ok { | |||
| 478 | - | r1 = returnFunc(ctx, repo) | |||
| 479 | - | } else { | |||
| 480 | - | r1 = ret.Get(1).(bool) | |||
| 481 | - | } | |||
| 482 | - | if returnFunc, ok := ret.Get(2).(func(context.Context, domain.RepoName) error); ok { | |||
| 483 | - | r2 = returnFunc(ctx, repo) | |||
| 484 | - | } else { | |||
| 485 | - | r2 = ret.Error(2) | |||
| 486 | - | } | |||
| 487 | - | return r0, r1, r2 | |||
| 488 | - | } | |||
| 489 | - | ||||
| 490 | - | // ReleaseStore_LatestReleaseID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LatestReleaseID' | |||
| 491 | - | type ReleaseStore_LatestReleaseID_Call struct { | |||
| 492 | - | *mock.Call | |||
| 493 | - | } | |||
| 494 | - | ||||
| 495 | - | // LatestReleaseID is a helper method to define mock.On call | |||
| 496 | - | // - ctx context.Context | |||
| 497 | - | // - repo domain.RepoName | |||
| 498 | - | func (_e *ReleaseStore_Expecter) LatestReleaseID(ctx any, repo any) *ReleaseStore_LatestReleaseID_Call { | |||
| 499 | - | return &ReleaseStore_LatestReleaseID_Call{Call: _e.mock.On("LatestReleaseID", ctx, repo)} | |||
| 500 | - | } | |||
| 501 | - | ||||
| 502 | - | func (_c *ReleaseStore_LatestReleaseID_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *ReleaseStore_LatestReleaseID_Call { | |||
| 503 | - | _c.Call.Run(func(args mock.Arguments) { | |||
| 504 | - | var arg0 context.Context | |||
| 505 | - | if args[0] != nil { | |||
| 506 | - | arg0 = args[0].(context.Context) | |||
| 507 | - | } | |||
| 508 | - | var arg1 domain.RepoName | |||
| 509 | - | if args[1] != nil { | |||
| 510 | - | arg1 = args[1].(domain.RepoName) | |||
| 511 | - | } | |||
| 512 | - | run( | |||
| 513 | - | arg0, | |||
| 514 | - | arg1, | |||
| 515 | - | ) | |||
| 516 | - | }) | |||
| 517 | - | return _c | |||
| 518 | - | } | |||
| 519 | - | ||||
| 520 | - | func (_c *ReleaseStore_LatestReleaseID_Call) Return(id int64, ok bool, err error) *ReleaseStore_LatestReleaseID_Call { | |||
| 521 | - | _c.Call.Return(id, ok, err) | |||
| 522 | - | return _c | |||
| 523 | - | } | |||
| 524 | - | ||||
| 525 | - | func (_c *ReleaseStore_LatestReleaseID_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName) (int64, bool, error)) *ReleaseStore_LatestReleaseID_Call { | |||
| 526 | - | _c.Call.Return(run) | |||
| 527 | - | return _c | |||
| 528 | - | } | |||
| 529 | - | ||||
| 530 | // ListAssets provides a mock function for the type ReleaseStore | 524 | // ListAssets provides a mock function for the type ReleaseStore | ||
| 531 | func (_mock *ReleaseStore) ListAssets(ctx context.Context, repo domain.RepoName, releaseID int64) ([]domain.ReleaseAsset, error) { | 525 | func (_mock *ReleaseStore) ListAssets(ctx context.Context, repo domain.RepoName, releaseID int64) ([]domain.ReleaseAsset, error) { | ||
| 532 | ret := _mock.Called(ctx, repo, releaseID) | 526 | ret := _mock.Called(ctx, repo, releaseID) | ||
M internal/ports/release.go +3 -2
| @@ -13,7 +13,8 @@ import ( | |||
| 13 | 13 | // Used includes both uploading and uploaded assets. | |
| 14 | 14 | type ReleaseWrite struct { | |
| 15 | 15 | Repo domain.RepoName | |
| 16 | - | LatestID int64 // zero means no eligible release | |
| 16 | + | SetLatest bool // false leaves the latest index unchanged | |
| 17 | + | LatestID int64 // zero means no eligible release when SetLatest | |
| 17 | 18 | Used int64 | |
| 18 | 19 | UsageVersion uint64 | |
| 19 | 20 | PutReleases, DeleteReleases []domain.Release | |
| @@ -22,7 +23,7 @@ type ReleaseWrite struct { | |||
| 22 | 23 | ||
| 23 | 24 | // ReleaseStore keeps metadata, never contents. *kvstore.Releases implements it. | |
| 24 | 25 | type ReleaseStore interface { | |
| 25 | - | LatestReleaseID(ctx context.Context, repo domain.RepoName) (id int64, ok bool, err error) | |
| 26 | + | GetLatestRelease(ctx context.Context, repo domain.RepoName) (domain.Release, error) | |
| 26 | 27 | NextReleaseID(ctx context.Context) (int64, error) | |
| 27 | 28 | ReleaseUsage(ctx context.Context, repo domain.RepoName) (used int64, version uint64, err error) | |
| 28 | 29 | GetRelease(ctx context.Context, repo domain.RepoName, id int64) (domain.Release, error) | |
| @@ -13,7 +13,8 @@ import ( | |||||
| 13 | // Used includes both uploading and uploaded assets. | 13 | // Used includes both uploading and uploaded assets. | ||
| 14 | type ReleaseWrite struct { | 14 | type ReleaseWrite struct { | ||
| 15 | Repo domain.RepoName | 15 | Repo domain.RepoName | ||
| 16 | - | LatestID int64 // zero means no eligible release | 16 | + | SetLatest bool // false leaves the latest index unchanged |
| 17 | + | LatestID int64 // zero means no eligible release when SetLatest | |||
| 17 | Used int64 | 18 | Used int64 | ||
| 18 | UsageVersion uint64 | 19 | UsageVersion uint64 | ||
| 19 | PutReleases, DeleteReleases []domain.Release | 20 | PutReleases, DeleteReleases []domain.Release | ||
| @@ -22,7 +23,7 @@ type ReleaseWrite struct { | |||||
| 22 | 23 | ||||
| 23 | // ReleaseStore keeps metadata, never contents. *kvstore.Releases implements it. | 24 | // ReleaseStore keeps metadata, never contents. *kvstore.Releases implements it. | ||
| 24 | type ReleaseStore interface { | 25 | type ReleaseStore interface { | ||
| 25 | - | LatestReleaseID(ctx context.Context, repo domain.RepoName) (id int64, ok bool, err error) | 26 | + | GetLatestRelease(ctx context.Context, repo domain.RepoName) (domain.Release, error) |
| 26 | NextReleaseID(ctx context.Context) (int64, error) | 27 | NextReleaseID(ctx context.Context) (int64, error) | ||
| 27 | ReleaseUsage(ctx context.Context, repo domain.RepoName) (used int64, version uint64, err error) | 28 | ReleaseUsage(ctx context.Context, repo domain.RepoName) (used int64, version uint64, err error) | ||
| 28 | GetRelease(ctx context.Context, repo domain.RepoName, id int64) (domain.Release, error) | 29 | GetRelease(ctx context.Context, repo domain.RepoName, id int64) (domain.Release, error) | ||
M internal/usecase/releases.go +24 -30
| @@ -92,26 +92,29 @@ func (r *Releases) updateReleases(ctx context.Context, repo domain.RepoName, fn | |||
| 92 | 92 | if err = fn(&w); err != nil { | |
| 93 | 93 | return err | |
| 94 | 94 | } | |
| 95 | - | all, err := r.store.ListReleases(ctx, repo) | |
| 96 | - | if err != nil { | |
| 97 | - | return err | |
| 98 | - | } | |
| 99 | - | byID := make(map[int64]domain.Release, len(all)+len(w.PutReleases)) | |
| 100 | - | for _, rel := range all { | |
| 101 | - | byID[rel.ID] = rel | |
| 102 | - | } | |
| 103 | - | for _, rel := range w.DeleteReleases { | |
| 104 | - | delete(byID, rel.ID) | |
| 105 | - | } | |
| 106 | - | for _, rel := range w.PutReleases { | |
| 107 | - | byID[rel.ID] = rel | |
| 108 | - | } | |
| 109 | - | all = all[:0] | |
| 110 | - | for _, rel := range byID { | |
| 111 | - | all = append(all, rel) | |
| 112 | - | } | |
| 113 | - | if latest, ok := domain.LatestRelease(all); ok { | |
| 114 | - | w.LatestID = latest.ID | |
| 95 | + | if len(w.PutReleases) > 0 || len(w.DeleteReleases) > 0 { | |
| 96 | + | w.SetLatest = true | |
| 97 | + | all, err := r.store.ListReleases(ctx, repo) | |
| 98 | + | if err != nil { | |
| 99 | + | return err | |
| 100 | + | } | |
| 101 | + | byID := make(map[int64]domain.Release, len(all)+len(w.PutReleases)) | |
| 102 | + | for _, rel := range all { | |
| 103 | + | byID[rel.ID] = rel | |
| 104 | + | } | |
| 105 | + | for _, rel := range w.DeleteReleases { | |
| 106 | + | delete(byID, rel.ID) | |
| 107 | + | } | |
| 108 | + | for _, rel := range w.PutReleases { | |
| 109 | + | byID[rel.ID] = rel | |
| 110 | + | } | |
| 111 | + | all = all[:0] | |
| 112 | + | for _, rel := range byID { | |
| 113 | + | all = append(all, rel) | |
| 114 | + | } | |
| 115 | + | if latest, ok := domain.LatestRelease(all); ok { | |
| 116 | + | w.LatestID = latest.ID | |
| 117 | + | } | |
| 115 | 118 | } | |
| 116 | 119 | err = r.store.ApplyReleases(ctx, w) | |
| 117 | 120 | if errors.Is(err, domain.ErrConflict) { | |
| @@ -266,16 +269,7 @@ func (r *Releases) GetRelease(ctx context.Context, p domain.Principal, name doma | |||
| 266 | 269 | case ref.Tag != "": | |
| 267 | 270 | rel, err = r.store.GetReleaseByTag(ctx, name, ref.Tag) | |
| 268 | 271 | default: | |
| 269 | - | var id int64 | |
| 270 | - | var ok bool | |
| 271 | - | id, ok, err = r.store.LatestReleaseID(ctx, name) | |
| 272 | - | if err == nil { | |
| 273 | - | if !ok { | |
| 274 | - | err = domain.ErrNotFound | |
| 275 | - | } else { | |
| 276 | - | rel, err = r.store.GetRelease(ctx, name, id) | |
| 277 | - | } | |
| 278 | - | } | |
| 272 | + | rel, err = r.store.GetLatestRelease(ctx, name) | |
| 279 | 273 | } | |
| 280 | 274 | if err == nil && rel.Draft && !releaseWriter(p, name) { | |
| 281 | 275 | err = domain.ErrNotFound | |
| @@ -92,26 +92,29 @@ func (r *Releases) updateReleases(ctx context.Context, repo domain.RepoName, fn | |||||
| 92 | if err = fn(&w); err != nil { | 92 | if err = fn(&w); err != nil { | ||
| 93 | return err | 93 | return err | ||
| 94 | } | 94 | } | ||
| 95 | - | all, err := r.store.ListReleases(ctx, repo) | 95 | + | if len(w.PutReleases) > 0 || len(w.DeleteReleases) > 0 { |
| 96 | - | if err != nil { | 96 | + | w.SetLatest = true |
| 97 | - | return err | 97 | + | all, err := r.store.ListReleases(ctx, repo) |
| 98 | - | } | 98 | + | if err != nil { |
| 99 | - | byID := make(map[int64]domain.Release, len(all)+len(w.PutReleases)) | 99 | + | return err |
| 100 | - | for _, rel := range all { | 100 | + | } |
| 101 | - | byID[rel.ID] = rel | 101 | + | byID := make(map[int64]domain.Release, len(all)+len(w.PutReleases)) |
| 102 | - | } | 102 | + | for _, rel := range all { |
| 103 | - | for _, rel := range w.DeleteReleases { | 103 | + | byID[rel.ID] = rel |
| 104 | - | delete(byID, rel.ID) | 104 | + | } |
| 105 | - | } | 105 | + | for _, rel := range w.DeleteReleases { |
| 106 | - | for _, rel := range w.PutReleases { | 106 | + | delete(byID, rel.ID) |
| 107 | - | byID[rel.ID] = rel | 107 | + | } |
| 108 | - | } | 108 | + | for _, rel := range w.PutReleases { |
| 109 | - | all = all[:0] | 109 | + | byID[rel.ID] = rel |
| 110 | - | for _, rel := range byID { | 110 | + | } |
| 111 | - | all = append(all, rel) | 111 | + | all = all[:0] |
| 112 | - | } | 112 | + | for _, rel := range byID { |
| 113 | - | if latest, ok := domain.LatestRelease(all); ok { | 113 | + | all = append(all, rel) |
| 114 | - | w.LatestID = latest.ID | 114 | + | } |
| 115 | + | if latest, ok := domain.LatestRelease(all); ok { | |||
| 116 | + | w.LatestID = latest.ID | |||
| 117 | + | } | |||
| 115 | } | 118 | } | ||
| 116 | err = r.store.ApplyReleases(ctx, w) | 119 | err = r.store.ApplyReleases(ctx, w) | ||
| 117 | if errors.Is(err, domain.ErrConflict) { | 120 | if errors.Is(err, domain.ErrConflict) { | ||
| @@ -266,16 +269,7 @@ func (r *Releases) GetRelease(ctx context.Context, p domain.Principal, name doma | |||||
| 266 | case ref.Tag != "": | 269 | case ref.Tag != "": | ||
| 267 | rel, err = r.store.GetReleaseByTag(ctx, name, ref.Tag) | 270 | rel, err = r.store.GetReleaseByTag(ctx, name, ref.Tag) | ||
| 268 | default: | 271 | default: | ||
| 269 | - | var id int64 | 272 | + | rel, err = r.store.GetLatestRelease(ctx, name) |
| 270 | - | var ok bool | |||
| 271 | - | id, ok, err = r.store.LatestReleaseID(ctx, name) | |||
| 272 | - | if err == nil { | |||
| 273 | - | if !ok { | |||
| 274 | - | err = domain.ErrNotFound | |||
| 275 | - | } else { | |||
| 276 | - | rel, err = r.store.GetRelease(ctx, name, id) | |||
| 277 | - | } | |||
| 278 | - | } | |||
| 279 | } | 273 | } | ||
| 280 | if err == nil && rel.Draft && !releaseWriter(p, name) { | 274 | if err == nil && rel.Draft && !releaseWriter(p, name) { | ||
| 281 | err = domain.ErrNotFound | 275 | err = domain.ErrNotFound | ||
M internal/usecase/releases_integration_test.go +1 -2
| @@ -387,8 +387,7 @@ func TestReleaseLatestUsesIndex(t *testing.T) { | |||
| 387 | 387 | store := mocks.NewReleaseStore(t) | |
| 388 | 388 | meta := mocks.NewRepoMetaStore(t) | |
| 389 | 389 | meta.EXPECT().Get(ctx, repo).Return(domain.Repo{Name: repo, Visibility: domain.VisibilityPublic}, nil) | |
| 390 | - | store.EXPECT().LatestReleaseID(ctx, repo).Return(int64(42), true, nil).Once() | |
| 391 | - | store.EXPECT().GetRelease(ctx, repo, int64(42)).Return(domain.Release{ID: 42, Repo: repo}, nil).Once() | |
| 390 | + | store.EXPECT().GetLatestRelease(ctx, repo).Return(domain.Release{ID: 42, Repo: repo}, nil).Once() | |
| 392 | 391 | svc := usecase.NewReleases(store, nil, meta, nil, nil, nil, nil, nil, ports.ReleaseLimits{}) | |
| 393 | 392 | got, err := svc.GetRelease(ctx, domain.Anonymous(), repo, ports.ReleaseRef{Latest: true}) | |
| 394 | 393 | require.NoError(t, err) | |
| @@ -387,8 +387,7 @@ func TestReleaseLatestUsesIndex(t *testing.T) { | |||||
| 387 | store := mocks.NewReleaseStore(t) | 387 | store := mocks.NewReleaseStore(t) | ||
| 388 | meta := mocks.NewRepoMetaStore(t) | 388 | meta := mocks.NewRepoMetaStore(t) | ||
| 389 | meta.EXPECT().Get(ctx, repo).Return(domain.Repo{Name: repo, Visibility: domain.VisibilityPublic}, nil) | 389 | meta.EXPECT().Get(ctx, repo).Return(domain.Repo{Name: repo, Visibility: domain.VisibilityPublic}, nil) | ||
| 390 | - | store.EXPECT().LatestReleaseID(ctx, repo).Return(int64(42), true, nil).Once() | 390 | + | store.EXPECT().GetLatestRelease(ctx, repo).Return(domain.Release{ID: 42, Repo: repo}, nil).Once() |
| 391 | - | store.EXPECT().GetRelease(ctx, repo, int64(42)).Return(domain.Release{ID: 42, Repo: repo}, nil).Once() | |||
| 392 | svc := usecase.NewReleases(store, nil, meta, nil, nil, nil, nil, nil, ports.ReleaseLimits{}) | 391 | svc := usecase.NewReleases(store, nil, meta, nil, nil, nil, nil, nil, ports.ReleaseLimits{}) | ||
| 393 | got, err := svc.GetRelease(ctx, domain.Anonymous(), repo, ports.ReleaseRef{Latest: true}) | 392 | got, err := svc.GetRelease(ctx, domain.Anonymous(), repo, ports.ReleaseRef{Latest: true}) | ||
| 394 | require.NoError(t, err) | 393 | require.NoError(t, err) | ||
A internal/usecase/releases_write_test.go +27 -0
| @@ -0,0 +1,27 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | package usecase | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "bnema.dev/ermite/internal/domain" | |
| 8 | + | "bnema.dev/ermite/internal/ports" | |
| 9 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // No ListReleases expectation: asset-only changes must not scan releases. | |
| 14 | + | func TestAssetOnlyWriteDoesNotListReleases(t *testing.T) { | |
| 15 | + | ctx := t.Context() | |
| 16 | + | repo, err := domain.ParseRepoName("assets") | |
| 17 | + | require.NoError(t, err) | |
| 18 | + | store := mocks.NewReleaseStore(t) | |
| 19 | + | asset := domain.ReleaseAsset{ID: 1, Repo: repo} | |
| 20 | + | store.EXPECT().ReleaseUsage(ctx, repo).Return(int64(0), uint64(2), nil).Once() | |
| 21 | + | store.EXPECT().ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: 2, PutAssets: []domain.ReleaseAsset{asset}}).Return(nil).Once() | |
| 22 | + | svc := &Releases{store: store} | |
| 23 | + | require.NoError(t, svc.updateReleases(ctx, repo, func(w *ports.ReleaseWrite) error { | |
| 24 | + | w.PutAssets = append(w.PutAssets, asset) | |
| 25 | + | return nil | |
| 26 | + | })) | |
| 27 | + | } | |
| @@ -0,0 +1,27 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | package usecase | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "bnema.dev/ermite/internal/domain" | |||
| 8 | + | "bnema.dev/ermite/internal/ports" | |||
| 9 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // No ListReleases expectation: asset-only changes must not scan releases. | |||
| 14 | + | func TestAssetOnlyWriteDoesNotListReleases(t *testing.T) { | |||
| 15 | + | ctx := t.Context() | |||
| 16 | + | repo, err := domain.ParseRepoName("assets") | |||
| 17 | + | require.NoError(t, err) | |||
| 18 | + | store := mocks.NewReleaseStore(t) | |||
| 19 | + | asset := domain.ReleaseAsset{ID: 1, Repo: repo} | |||
| 20 | + | store.EXPECT().ReleaseUsage(ctx, repo).Return(int64(0), uint64(2), nil).Once() | |||
| 21 | + | store.EXPECT().ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: 2, PutAssets: []domain.ReleaseAsset{asset}}).Return(nil).Once() | |||
| 22 | + | svc := &Releases{store: store} | |||
| 23 | + | require.NoError(t, svc.updateReleases(ctx, repo, func(w *ports.ReleaseWrite) error { | |||
| 24 | + | w.PutAssets = append(w.PutAssets, asset) | |||
| 25 | + | return nil | |||
| 26 | + | })) | |||
| 27 | + | } | |||