From dfd2e5a1e57ea5c16923e142f7652d0e2f67df56 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 01:04:18 +0000 Subject: [PATCH] refactor: consolidate legacy config migration into migration.go Move fromLegacyConfig out of model.go and merge it with LoadLegacyConfig (formerly legacy_config.go) into a single migration.go file, so the one-time legacy config migration is kept separate from the app config model and its business logic. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YZ6SoJpmnLggZqactXxrak --- backend/internal/appconfig/legacy_config.go | 47 ---------- backend/internal/appconfig/migration.go | 97 +++++++++++++++++++++ backend/internal/appconfig/model.go | 44 ---------- 3 files changed, 97 insertions(+), 91 deletions(-) delete mode 100644 backend/internal/appconfig/legacy_config.go create mode 100644 backend/internal/appconfig/migration.go diff --git a/backend/internal/appconfig/legacy_config.go b/backend/internal/appconfig/legacy_config.go deleted file mode 100644 index db5b26ba..00000000 --- a/backend/internal/appconfig/legacy_config.go +++ /dev/null @@ -1,47 +0,0 @@ -package appconfig - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "time" - - "gorm.io/gorm" - - "github.com/pocket-id/pocket-id/backend/internal/model" -) - -// loadLegacyConfig loads the legacy config from the database -// This was migrated to the "config_migrated" key in the kv table -func LoadLegacyConfig(ctx context.Context, db *gorm.DB) (map[string]string, error) { - // Retrieve the migrated config from the kv table - row := model.KV{ - Key: "config_migrated", - } - ctx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - err := db.WithContext(ctx).First(&row).Error - switch { - case errors.Is(err, gorm.ErrRecordNotFound): - // There's no migrated config in the database, nothing to do - return nil, nil - case err != nil: - return nil, fmt.Errorf("failed to load migrated config from the database: %w", err) - case row.Value == nil || len(*row.Value) == 0: - // Also no migrated config, nothing to do - return nil, nil - } - - // The value is a JSON-encoded dictionary - res := map[string]string{} - err = json.Unmarshal([]byte(*row.Value), &res) - if err != nil { - return nil, fmt.Errorf("error parsing migrated config: %w", err) - } - - if len(res) == 0 { - return nil, nil - } - return res, nil -} diff --git a/backend/internal/appconfig/migration.go b/backend/internal/appconfig/migration.go new file mode 100644 index 00000000..0b2086ae --- /dev/null +++ b/backend/internal/appconfig/migration.go @@ -0,0 +1,97 @@ +package appconfig + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "reflect" + "strconv" + "strings" + "time" + + "github.com/italypaleale/go-kit/utils" + "gorm.io/gorm" + + "github.com/pocket-id/pocket-id/backend/internal/model" +) + +// This file holds the one-time migration of the legacy (pre-actor) app config +// The legacy config was stored in the "config_migrated" key of the kv table, and it's loaded here to bootstrap the AppConfig actor on first startup + +// LoadLegacyConfig loads the legacy config from the database +// This was migrated to the "config_migrated" key in the kv table +func LoadLegacyConfig(ctx context.Context, db *gorm.DB) (map[string]string, error) { + // Retrieve the migrated config from the kv table + row := model.KV{ + Key: "config_migrated", + } + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + err := db.WithContext(ctx).First(&row).Error + switch { + case errors.Is(err, gorm.ErrRecordNotFound): + // There's no migrated config in the database, nothing to do + return nil, nil + case err != nil: + return nil, fmt.Errorf("failed to load migrated config from the database: %w", err) + case row.Value == nil || len(*row.Value) == 0: + // Also no migrated config, nothing to do + return nil, nil + } + + // The value is a JSON-encoded dictionary + res := map[string]string{} + err = json.Unmarshal([]byte(*row.Value), &res) + if err != nil { + return nil, fmt.Errorf("error parsing migrated config: %w", err) + } + + if len(res) == 0 { + return nil, nil + } + return res, nil +} + +// fromLegacyConfig builds an appConfigModel from a legacy config map +// The map's keys correspond to the "json" tags on appConfigModel, and all values are strings that are cast to each field's type +// Keys that are missing (or have an empty value) retain the default value +func fromLegacyConfig(legacyCfg map[string]string) (*AppConfigModel, error) { + // Start from the default configuration, then override with the values from the legacy config + dest := getDefaultConfig() + + rt := reflect.ValueOf(dest).Elem().Type() + rv := reflect.ValueOf(dest).Elem() + for i := range rt.NumField() { + field := rt.Field(i) + + // Get the value of the json tag, taking only what's before the comma + key, _, _ := strings.Cut(field.Tag.Get("json"), ",") + + // Look up the value in the legacy config + // If the key is missing or the value is empty, we keep the default value + value, ok := legacyCfg[key] + if !ok || value == "" { + continue + } + + // Cast the string value to the field's type + fv := rv.Field(i) + switch fv.Kind() { //nolint:exhaustive + case reflect.String: + fv.SetString(value) + case reflect.Bool: + fv.SetBool(utils.IsTruthy(value)) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + n, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return nil, fmt.Errorf("failed to parse integer value for key '%s': %w", key, err) + } + fv.SetInt(n) + default: + return nil, fmt.Errorf("unsupported field type '%s' for key '%s'", fv.Kind(), key) + } + } + + return dest, nil +} diff --git a/backend/internal/appconfig/model.go b/backend/internal/appconfig/model.go index 116edec6..47436522 100644 --- a/backend/internal/appconfig/model.go +++ b/backend/internal/appconfig/model.go @@ -2,7 +2,6 @@ package appconfig import ( "errors" - "fmt" "reflect" "strconv" "strings" @@ -149,49 +148,6 @@ func getDefaultConfig() *AppConfigModel { } } -// fromLegacyConfig builds an appConfigModel from a legacy config map -// The map's keys correspond to the "json" tags on appConfigModel, and all values are strings that are cast to each field's type -// Keys that are missing (or have an empty value) retain the default value -func fromLegacyConfig(legacyCfg map[string]string) (*AppConfigModel, error) { - // Start from the default configuration, then override with the values from the legacy config - dest := getDefaultConfig() - - rt := reflect.ValueOf(dest).Elem().Type() - rv := reflect.ValueOf(dest).Elem() - for i := range rt.NumField() { - field := rt.Field(i) - - // Get the value of the json tag, taking only what's before the comma - key, _, _ := strings.Cut(field.Tag.Get("json"), ",") - - // Look up the value in the legacy config - // If the key is missing or the value is empty, we keep the default value - value, ok := legacyCfg[key] - if !ok || value == "" { - continue - } - - // Cast the string value to the field's type - fv := rv.Field(i) - switch fv.Kind() { //nolint:exhaustive - case reflect.String: - fv.SetString(value) - case reflect.Bool: - fv.SetBool(utils.IsTruthy(value)) - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - n, err := strconv.ParseInt(value, 10, 64) - if err != nil { - return nil, fmt.Errorf("failed to parse integer value for key '%s': %w", key, err) - } - fv.SetInt(n) - default: - return nil, fmt.Errorf("unsupported field type '%s' for key '%s'", fv.Kind(), key) - } - } - - return dest, nil -} - // Replace updates every configuration property with the values from the input DTO // An empty string value resets the corresponding property to its default value func (m *AppConfigModel) Replace(input dto.AppConfigUpdateDto) {