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 }