internal/repository/update_bucket.go
Rama main · réplica de lectura del repositorio original.
package repository
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"xprem/internal/bucket"
"xprem/internal/crypto"
"xprem/internal/helpers"
"xprem/internal/types"
"fmt"
"sort"
"strconv"
"strings"
"time"
update2 "xprem/internal/update"
)
type BucketUpdateRepository struct {
updateStore *bucket.UpdateStore
}
func NewBucketUpdateRepository(updateStore *bucket.UpdateStore) *BucketUpdateRepository {
return &BucketUpdateRepository{
updateStore: updateStore,
}
}
// GetLatestUpdate returns the newest complete update for the platform, or nil
// when the branch has none yet.
func (s *BucketUpdateRepository) GetLatestUpdate(ctx context.Context, appId string, branchName string, runtimeVersion string, platform types.Platform) (*types.Update, error) {
updates, err := s.updateStore.List(ctx, appId, branchName, runtimeVersion)
if err != nil {
return nil, err
}
updates = sortNewestFirst(updates)
for i := range updates {
storedMetadata, metaErr := update2.RetrieveUpdateStoredMetadata(ctx, updates[i])
if metaErr != nil || storedMetadata == nil || storedMetadata.Platform != platform {
continue
}
valid, err := s.isUpdateValid(ctx, updates[i])
if err != nil {
return nil, err
}
if !valid {
continue
}
updates[i].UpdateUUID = storedMetadata.UpdateUUID
return &updates[i], nil
}
return nil, nil
}
func sortNewestFirst(updates []types.Update) []types.Update {
sort.Slice(updates, func(i, j int) bool {
idI, _ := strconv.ParseInt(updates[i].UpdateId, 10, 64)
idJ, _ := strconv.ParseInt(updates[j].UpdateId, 10, 64)
return idI > idJ
})
return updates
}
func (s *BucketUpdateRepository) GetUpdateType(ctx context.Context, update types.Update) (types.UpdateType, error) {
return s.updateType(ctx, update)
}
// updateType reports whether update is a rollback, based on the presence of
// the "rollback" marker file written by CreateRollback.
func (s *BucketUpdateRepository) updateType(ctx context.Context, update types.Update) (types.UpdateType, error) {
file, err := s.updateStore.GetFile(ctx, update, "rollback")
if err != nil {
return 0, fmt.Errorf("failed to read the rollback marker: %w", err)
}
if file == nil {
return types.NormalUpdate, nil
}
file.Reader.Close()
return types.Rollback, nil
}
func (s *BucketUpdateRepository) IsUpdateValid(ctx context.Context, update types.Update) (bool, error) {
return s.isUpdateValid(ctx, update)
}
// isUpdateValid reports whether the ".check" sentinel file is present, marking
// the update as fully uploaded.
func (s *BucketUpdateRepository) isUpdateValid(ctx context.Context, update types.Update) (bool, error) {
file, err := s.updateStore.GetFile(ctx, update, ".check")
if err != nil {
return false, fmt.Errorf("failed to read the .check marker: %w", err)
}
if file == nil {
return false, nil
}
file.Reader.Close()
return true, nil
}
func (s *BucketUpdateRepository) MarkUpdateAsChecked(ctx context.Context, update types.Update) error {
return s.updateStore.PutFile(ctx, update, ".check", strings.NewReader(".check"))
}
// updateMetadataReader marshals the update-metadata.json body. message is
// omitted when empty.
func updateMetadataReader(platform types.Platform, commitHash, message string) (*bytes.Reader, error) {
fileUpdateMetadata := map[string]string{
"platform": string(platform),
"commitHash": commitHash,
}
if message != "" {
fileUpdateMetadata["message"] = message
}
marshalledMetadata, err := json.Marshal(fileUpdateMetadata)
if err != nil {
return nil, fmt.Errorf("Error marshalling file update metadata: %w", err)
}
return bytes.NewReader(marshalledMetadata), nil
}
// publishGroup is ignored: stateless mode has no publish grouping.
func (s *BucketUpdateRepository) CreateUpdate(ctx context.Context, appId string, updateId int64, branchName string, runtimeVersion string, platform types.Platform, commitHash string, message string, publishGroup *string) (*types.Update, error) {
metadataReader, err := updateMetadataReader(platform, commitHash, message)
if err != nil {
return nil, err
}
createdAt := helpers.NormalizeTimestampToDuration(updateId)
err = s.updateStore.PutFile(ctx, types.Update{
AppId: appId,
Branch: branchName,
RuntimeVersion: runtimeVersion,
UpdateId: update2.ConvertUpdateTimestampToString(updateId),
CreatedAt: createdAt,
}, "update-metadata.json", metadataReader)
if err != nil {
return nil, fmt.Errorf("Error uploading file update metadata: %w", err)
}
return &types.Update{
UpdateId: strconv.FormatInt(updateId, 10),
Branch: branchName,
RuntimeVersion: runtimeVersion,
CreatedAt: createdAt,
AppId: appId,
}, nil
}
func (s *BucketUpdateRepository) GetUpdateDetails(ctx context.Context, appId string, branchName string, runtimeVersion string, updateId string) (types.UpdateDetails, error) {
update, err := s.GetUpdate(ctx, appId, branchName, runtimeVersion, updateId)
if err != nil {
return types.UpdateDetails{}, fmt.Errorf("failed to fetch update: %w", err)
}
// Rollback folders and phantom updates carry no metadata.json but must still render.
metadata, err := update2.GetMetadata(ctx, *update)
if err != nil && !errors.Is(err, update2.ErrUpdateMetadataMissing) {
return types.UpdateDetails{}, fmt.Errorf("failed to get update metadata: %w", err)
}
expoConfig, err := update2.GetExpoConfig(ctx, *update)
if err != nil {
return types.UpdateDetails{}, fmt.Errorf("failed to get expo config for update: %w", err)
}
numberUpdate, _ := strconv.ParseInt(update.UpdateId, 10, 64)
storedMetadata, _ := update2.RetrieveUpdateStoredMetadata(ctx, *update)
updateType, err := s.updateType(ctx, *update)
if err != nil {
return types.UpdateDetails{}, err
}
updateUUID := "Rollback to embedded"
if updateType != types.Rollback {
updateUUID = storedMetadata.UpdateUUID
if updateUUID == "" {
updateUUID = crypto.ConvertSHA256HashToUUID(metadata.ID)
}
}
return types.UpdateDetails{
UpdateUUID: updateUUID,
UpdateId: update.UpdateId,
CreatedAt: helpers.NormalizeTimestamp(numberUpdate).Format(time.RFC3339),
CommitHash: storedMetadata.CommitHash,
Platform: storedMetadata.Platform,
Message: storedMetadata.Message,
Type: updateType,
ExpoConfig: string(expoConfig),
}, nil
}
func (s *BucketUpdateRepository) GetUpdatesByRunTimeVersionAndBranchName(ctx context.Context, appId string, runtimeVersion string, branchName string, cursor *int64, limit int) (types.UpdatesPage, error) {
updates, err := s.updateStore.List(ctx, appId, branchName, runtimeVersion)
if err != nil {
return types.UpdatesPage{}, fmt.Errorf("failed to fetch updates: %w", err)
}
updates = sortNewestFirst(updates)
updatesResponse := make([]types.UpdateItem, 0, limit+1)
for _, update := range updates {
updateID, err := strconv.ParseInt(update.UpdateId, 10, 64)
if err != nil || (cursor != nil && updateID >= *cursor) {
continue
}
isValid, err := s.isUpdateValid(ctx, update)
if err != nil {
return types.UpdatesPage{}, err
}
if !isValid {
continue
}
numberUpdate := updateID
storedMetadata, _ := update2.RetrieveUpdateStoredMetadata(ctx, update)
updateType, err := s.updateType(ctx, update)
if err != nil {
return types.UpdatesPage{}, err
}
if updateType == types.Rollback {
updatesResponse = append(updatesResponse, types.UpdateItem{
UpdateUUID: "Rollback to embedded",
UpdateId: update.UpdateId,
CreatedAt: helpers.NormalizeTimestamp(numberUpdate).Format(time.RFC3339),
CommitHash: storedMetadata.CommitHash,
Platform: storedMetadata.Platform,
Message: storedMetadata.Message,
})
if len(updatesResponse) == limit+1 {
break
}
continue
}
metadata, err := update2.GetMetadata(ctx, update)
if err != nil && !errors.Is(err, update2.ErrUpdateMetadataMissing) {
continue
}
updateUUID := storedMetadata.UpdateUUID
if updateUUID == "" {
updateUUID = crypto.ConvertSHA256HashToUUID(metadata.ID)
}
updatesResponse = append(updatesResponse, types.UpdateItem{
UpdateUUID: updateUUID,
UpdateId: update.UpdateId,
CreatedAt: helpers.NormalizeTimestamp(numberUpdate).Format(time.RFC3339),
CommitHash: storedMetadata.CommitHash,
Platform: storedMetadata.Platform,
Message: storedMetadata.Message,
})
if len(updatesResponse) == limit+1 {
break
}
}
var nextCursor *string
if len(updatesResponse) > limit {
cursorValue := updatesResponse[limit-1].UpdateId
nextCursor = &cursorValue
updatesResponse = updatesResponse[:limit]
}
return types.UpdatesPage{Items: updatesResponse, NextCursor: nextCursor}, nil
}
// GetUpdate reconstructs an update handle from its id without touching the
// bucket; it does not check whether the update actually exists.
func (s *BucketUpdateRepository) GetUpdate(ctx context.Context, appId string, branchName string, runtimeVersion string, updateId string) (*types.Update, error) {
updateIdInt64, err := strconv.ParseInt(updateId, 10, 64)
if err != nil {
return nil, err
}
return &types.Update{
AppId: appId,
Branch: branchName,
RuntimeVersion: runtimeVersion,
UpdateId: updateId,
CreatedAt: helpers.NormalizeTimestampToDuration(updateIdInt64),
}, nil
}
func (s *BucketUpdateRepository) GetCheckedUpdate(ctx context.Context, appId string, branchName string, runtimeVersion string, updateId string) (*types.Update, error) {
update, err := s.GetUpdate(ctx, appId, branchName, runtimeVersion, updateId)
if err != nil {
return nil, err
}
valid, err := s.IsUpdateValid(ctx, *update)
if err != nil {
return nil, err
}
if !valid {
return nil, nil
}
return update, nil
}
func (s *BucketUpdateRepository) RetrieveUpdateStoredMetadata(ctx context.Context, update types.Update) (*types.UpdateStoredMetadata, error) {
return update2.RetrieveUpdateStoredMetadata(ctx, update)
}
// updateMetadataFile is the on-disk shape of update-metadata.json: the metadata
// every mode carries, plus the asset mapping only stateless keeps here. The
// embedded struct flattens, so the file's JSON is unchanged.
type updateMetadataFile struct {
types.UpdateStoredMetadata
AssetMapping *types.UpdateAssetMapping `json:"assetMapping,omitempty"`
}
func (s *BucketUpdateRepository) StoreUpdateUUIDInMetadata(ctx context.Context, update types.Update, updateUUID string) error {
return s.mutateUpdateMetadataFile(ctx, update, func(stored *updateMetadataFile) {
stored.UpdateUUID = updateUUID
})
}
func (s *BucketUpdateRepository) GetUpdateAssetMapping(ctx context.Context, update types.Update) (*types.UpdateAssetMapping, error) {
stored, err := s.readUpdateMetadataFile(ctx, update)
if err != nil || stored == nil {
return nil, err
}
return stored.AssetMapping, nil
}
func (s *BucketUpdateRepository) GetUpdateSourcemapHash(ctx context.Context, update types.Update) (*string, error) {
return nil, nil
}
func (s *BucketUpdateRepository) StoreUpdateSourcemapHash(ctx context.Context, update types.Update, hash string) error {
return ErrNotSupportedInStatelessMode
}
func (s *BucketUpdateRepository) StoreUpdateAssetMapping(ctx context.Context, update types.Update, mapping *types.UpdateAssetMapping) error {
return s.mutateUpdateMetadataFile(ctx, update, func(stored *updateMetadataFile) {
stored.AssetMapping = mapping
})
}
func (s *BucketUpdateRepository) readUpdateMetadataFile(ctx context.Context, update types.Update) (*updateMetadataFile, error) {
file, err := s.updateStore.GetFile(ctx, update, "update-metadata.json")
if err != nil {
return nil, err
}
if file == nil {
return nil, nil
}
defer file.Reader.Close()
return decodeUpdateMetadataFile(file.Reader)
}
// decodeUpdateMetadataFile reads the whole file before decoding, so an
// interrupted read is reported as such rather than as malformed JSON.
func decodeUpdateMetadataFile(reader io.Reader) (*updateMetadataFile, error) {
metadata, err := io.ReadAll(reader)
if err != nil {
return nil, err
}
var stored updateMetadataFile
if err := json.Unmarshal(metadata, &stored); err != nil {
return nil, err
}
return &stored, nil
}
func (s *BucketUpdateRepository) mutateUpdateMetadataFile(ctx context.Context, update types.Update, apply func(*updateMetadataFile)) error {
stored, err := s.readUpdateMetadataFile(ctx, update)
if err != nil {
return err
}
if stored == nil {
return fmt.Errorf("update-metadata.json missing for update %s", update.UpdateId)
}
apply(stored)
updated, err := json.Marshal(stored)
if err != nil {
return err
}
return s.updateStore.PutFile(ctx, update, "update-metadata.json", bytes.NewReader(updated))
}
// CreateRollback writes the metadata file and the "rollback" marker file;
// there is no bundle or asset to store for a rollback.
func (s *BucketUpdateRepository) CreateRollback(ctx context.Context, appId string, updateId int64, branchName string, runtimeVersion string, platform types.Platform, commitHash string, message string) (*types.Update, error) {
update := types.Update{
AppId: appId,
UpdateId: update2.ConvertUpdateTimestampToString(updateId),
Branch: branchName,
RuntimeVersion: runtimeVersion,
CreatedAt: helpers.NormalizeTimestampToDuration(updateId),
}
metadataReader, err := updateMetadataReader(platform, commitHash, message)
if err != nil {
return nil, err
}
err = s.updateStore.PutFile(ctx, update, "update-metadata.json", metadataReader)
if err != nil {
return nil, err
}
err = s.updateStore.PutFile(ctx, update, "rollback", strings.NewReader(""))
if err != nil {
return nil, err
}
return &update, nil
}
// GetLatestUpdateWithRollout wraps GetLatestUpdate with an empty rollout
// envelope, since stateless mode has no rollouts.
func (s *BucketUpdateRepository) GetLatestUpdateWithRollout(ctx context.Context, appId string, branchName string, runtimeVersion string, platform types.Platform) (*types.UpdateWithRollout, error) {
latest, err := s.GetLatestUpdate(ctx, appId, branchName, runtimeVersion, platform)
if err != nil {
return nil, err
}
if latest == nil {
return nil, nil
}
return &types.UpdateWithRollout{Update: *latest}, nil
}
// HasActiveRolloutUpdate is always false in stateless mode, since rollouts
// never exist here.
func (s *BucketUpdateRepository) HasActiveRolloutUpdate(ctx context.Context, appId string, branchName string, runtimeVersion string) (bool, error) {
return false, nil
}
func (s *BucketUpdateRepository) CreateUpdateWithRollout(ctx context.Context, appId string, updateId int64, branchName string, runtimeVersion string, platform types.Platform, commitHash string, message string, rolloutPercentage int, publishGroup *string) (*types.Update, error) {
return nil, ErrNotSupportedInStatelessMode
}
func (s *BucketUpdateRepository) ImportUpdate(ctx context.Context, params ImportUpdateParams) (bool, error) {
return false, ErrNotSupportedInStatelessMode
}
func (s *BucketUpdateRepository) UpdateExists(ctx context.Context, appId string, branchName string, updateId int64) (bool, error) {
return false, ErrNotSupportedInStatelessMode
}
func (s *BucketUpdateRepository) GetUpdatesByPublishGroup(ctx context.Context, appId string, branchName string, runtimeVersion string, publishGroup string) ([]types.PublishGroupMember, error) {
return nil, ErrNotSupportedInStatelessMode
}
func (s *BucketUpdateRepository) GetPublishGroupsPage(ctx context.Context, appId string, branchName string, runtimeVersion string, cursor *int64, limit int) (types.PublishGroupsPage, error) {
return types.PublishGroupsPage{}, ErrNotSupportedInStatelessMode
}
func (s *BucketUpdateRepository) GetUpdateFeed(ctx context.Context, appId string, query types.UpdateFeedQuery) ([]types.UpdateFeedItem, error) {
return nil, ErrNotSupportedInStatelessMode
}
// GetUpdateByUUID always returns (nil, nil) in stateless mode; there is no
// Expo-Requested-Update-ID resolution here.
func (s *BucketUpdateRepository) GetUpdateByUUID(ctx context.Context, appId string, updateUUID string) (*types.Update, error) {
return nil, nil
}