Ctrl K
ComunidadGuías…

internal/repository/update_bucket.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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
}