Implement local DB migrations and archived configuration lifecycle

This commit is contained in:
Mikhail Chusavitin
2026-02-04 18:52:56 +03:00
parent f4f92dea66
commit 41c0a47f54
15 changed files with 2072 additions and 172 deletions

View File

@@ -2,12 +2,23 @@ package services
import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var (
ErrConfigVersionNotFound = errors.New("configuration version not found")
ErrInvalidVersionNumber = errors.New("invalid version number")
ErrVersionConflict = errors.New("configuration version conflict")
)
// LocalConfigurationService handles configurations in local-first mode
@@ -64,18 +75,8 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
// Convert to local model
localCfg := localdb.ConfigurationToLocal(cfg)
// Save to local SQLite
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
return nil, err
}
// Add to pending sync queue
payload, err := json.Marshal(cfg)
if err != nil {
return nil, err
}
if err := s.localDB.AddPendingChange("configuration", cfg.UUID, "create", string(payload)); err != nil {
return nil, err
if err := s.createWithVersion(localCfg, ownerUsername); err != nil {
return nil, fmt.Errorf("create configuration with version: %w", err)
}
// Record usage stats
@@ -90,6 +91,9 @@ func (s *LocalConfigurationService) GetByUUID(uuid string, ownerUsername string)
if err != nil {
return nil, ErrConfigNotFound
}
if !localCfg.IsActive {
return nil, ErrConfigNotFound
}
// Convert to models.Configuration
cfg := localdb.LocalToConfiguration(localCfg)
@@ -136,19 +140,9 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
localCfg.UpdatedAt = time.Now()
localCfg.SyncStatus = "pending"
// Save to local SQLite
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
return nil, err
}
// Add to pending sync queue
cfg := localdb.LocalToConfiguration(localCfg)
payload, err := json.Marshal(cfg)
cfg, err := s.saveWithVersionAndPending(localCfg, "update", ownerUsername)
if err != nil {
return nil, err
}
if err := s.localDB.AddPendingChange("configuration", uuid, "update", string(payload)); err != nil {
return nil, err
return nil, fmt.Errorf("update configuration with version: %w", err)
}
return cfg, nil
@@ -165,17 +159,30 @@ func (s *LocalConfigurationService) Delete(uuid string, ownerUsername string) er
return ErrConfigForbidden
}
// Delete from local SQLite
if err := s.localDB.DeleteConfiguration(uuid); err != nil {
return err
localCfg.IsActive = false
localCfg.UpdatedAt = time.Now()
localCfg.SyncStatus = "pending"
_, err = s.saveWithVersionAndPending(localCfg, "deactivate", ownerUsername)
return err
}
// Reactivate restores an archived configuration and creates a new version.
func (s *LocalConfigurationService) Reactivate(uuid string, ownerUsername string) (*models.Configuration, error) {
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
if !s.isOwner(localCfg, ownerUsername) {
return nil, ErrConfigForbidden
}
if localCfg.IsActive {
return localdb.LocalToConfiguration(localCfg), nil
}
// Add to pending sync queue
if err := s.localDB.AddPendingChange("configuration", uuid, "delete", ""); err != nil {
return err
}
return nil
localCfg.IsActive = true
localCfg.UpdatedAt = time.Now()
localCfg.SyncStatus = "pending"
return s.saveWithVersionAndPending(localCfg, "reactivate", ownerUsername)
}
// Rename renames a configuration
@@ -193,20 +200,10 @@ func (s *LocalConfigurationService) Rename(uuid string, ownerUsername string, ne
localCfg.UpdatedAt = time.Now()
localCfg.SyncStatus = "pending"
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
return nil, err
}
// Add to pending sync queue
cfg := localdb.LocalToConfiguration(localCfg)
payload, err := json.Marshal(cfg)
cfg, err := s.saveWithVersionAndPending(localCfg, "update", ownerUsername)
if err != nil {
return nil, err
return nil, fmt.Errorf("rename configuration with version: %w", err)
}
if err := s.localDB.AddPendingChange("configuration", uuid, "update", string(payload)); err != nil {
return nil, err
}
return cfg, nil
}
@@ -236,17 +233,8 @@ func (s *LocalConfigurationService) Clone(configUUID string, ownerUsername strin
}
localCfg := localdb.ConfigurationToLocal(clone)
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
return nil, err
}
// Add to pending sync queue
payload, err := json.Marshal(clone)
if err != nil {
return nil, err
}
if err := s.localDB.AddPendingChange("configuration", clone.UUID, "create", string(payload)); err != nil {
return nil, err
if err := s.createWithVersion(localCfg, ownerUsername); err != nil {
return nil, fmt.Errorf("clone configuration with version: %w", err)
}
return clone, nil
@@ -254,6 +242,10 @@ func (s *LocalConfigurationService) Clone(configUUID string, ownerUsername strin
// ListByUser returns all configurations for a user from local SQLite
func (s *LocalConfigurationService) ListByUser(ownerUsername string, page, perPage int) ([]models.Configuration, int64, error) {
return s.listByUserWithStatus(ownerUsername, page, perPage, "active")
}
func (s *LocalConfigurationService) listByUserWithStatus(ownerUsername string, page, perPage int, status string) ([]models.Configuration, int64, error) {
// Get all local configurations
localConfigs, err := s.localDB.GetConfigurations()
if err != nil {
@@ -263,6 +255,9 @@ func (s *LocalConfigurationService) ListByUser(ownerUsername string, page, perPa
// Filter by user
var userConfigs []models.Configuration
for _, lc := range localConfigs {
if !matchesConfigStatus(lc.IsActive, status) {
continue
}
if (lc.OriginalUsername == ownerUsername) || lc.IsTemplate {
userConfigs = append(userConfigs, *localdb.LocalToConfiguration(&lc))
}
@@ -340,21 +335,10 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
localCfg.UpdatedAt = now
localCfg.SyncStatus = "pending"
// Save to local SQLite
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
return nil, err
}
// Add to pending sync queue
cfg := localdb.LocalToConfiguration(localCfg)
payload, err := json.Marshal(cfg)
cfg, err := s.saveWithVersionAndPending(localCfg, "update", ownerUsername)
if err != nil {
return nil, err
return nil, fmt.Errorf("refresh prices with version: %w", err)
}
if err := s.localDB.AddPendingChange("configuration", uuid, "update", string(payload)); err != nil {
return nil, err
}
return cfg, nil
}
@@ -364,6 +348,9 @@ func (s *LocalConfigurationService) GetByUUIDNoAuth(uuid string) (*models.Config
if err != nil {
return nil, ErrConfigNotFound
}
if !localCfg.IsActive {
return nil, ErrConfigNotFound
}
return localdb.LocalToConfiguration(localCfg), nil
}
@@ -396,28 +383,40 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
localCfg.UpdatedAt = time.Now()
localCfg.SyncStatus = "pending"
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
return nil, err
}
cfg := localdb.LocalToConfiguration(localCfg)
payload, err := json.Marshal(cfg)
cfg, err := s.saveWithVersionAndPending(localCfg, "update", "")
if err != nil {
return nil, err
return nil, fmt.Errorf("update configuration without auth with version: %w", err)
}
if err := s.localDB.AddPendingChange("configuration", uuid, "update", string(payload)); err != nil {
return nil, err
}
return cfg, nil
}
// DeleteNoAuth deletes configuration without ownership check
func (s *LocalConfigurationService) DeleteNoAuth(uuid string) error {
if err := s.localDB.DeleteConfiguration(uuid); err != nil {
return err
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
if err != nil {
return ErrConfigNotFound
}
return s.localDB.AddPendingChange("configuration", uuid, "delete", "")
localCfg.IsActive = false
localCfg.UpdatedAt = time.Now()
localCfg.SyncStatus = "pending"
_, err = s.saveWithVersionAndPending(localCfg, "deactivate", "")
return err
}
// ReactivateNoAuth restores an archived configuration without ownership check.
func (s *LocalConfigurationService) ReactivateNoAuth(uuid string) (*models.Configuration, error) {
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
if localCfg.IsActive {
return localdb.LocalToConfiguration(localCfg), nil
}
localCfg.IsActive = true
localCfg.UpdatedAt = time.Now()
localCfg.SyncStatus = "pending"
return s.saveWithVersionAndPending(localCfg, "reactivate", "")
}
// RenameNoAuth renames configuration without ownership check
@@ -431,19 +430,10 @@ func (s *LocalConfigurationService) RenameNoAuth(uuid string, newName string) (*
localCfg.UpdatedAt = time.Now()
localCfg.SyncStatus = "pending"
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
return nil, err
}
cfg := localdb.LocalToConfiguration(localCfg)
payload, err := json.Marshal(cfg)
cfg, err := s.saveWithVersionAndPending(localCfg, "update", "")
if err != nil {
return nil, err
return nil, fmt.Errorf("rename configuration without auth with version: %w", err)
}
if err := s.localDB.AddPendingChange("configuration", uuid, "update", string(payload)); err != nil {
return nil, err
}
return cfg, nil
}
@@ -473,16 +463,8 @@ func (s *LocalConfigurationService) CloneNoAuth(configUUID string, newName strin
}
localCfg := localdb.ConfigurationToLocal(clone)
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
return nil, err
}
payload, err := json.Marshal(clone)
if err != nil {
return nil, err
}
if err := s.localDB.AddPendingChange("configuration", clone.UUID, "create", string(payload)); err != nil {
return nil, err
if err := s.createWithVersion(localCfg, ownerUsername); err != nil {
return nil, fmt.Errorf("clone configuration without auth with version: %w", err)
}
return clone, nil
@@ -490,14 +472,23 @@ func (s *LocalConfigurationService) CloneNoAuth(configUUID string, newName strin
// ListAll returns all configurations without user filter
func (s *LocalConfigurationService) ListAll(page, perPage int) ([]models.Configuration, int64, error) {
return s.ListAllWithStatus(page, perPage, "active")
}
// ListAllWithStatus returns configurations filtered by status: active|archived|all.
func (s *LocalConfigurationService) ListAllWithStatus(page, perPage int, status string) ([]models.Configuration, int64, error) {
localConfigs, err := s.localDB.GetConfigurations()
if err != nil {
return nil, 0, err
}
configs := make([]models.Configuration, len(localConfigs))
for i, lc := range localConfigs {
configs[i] = *localdb.LocalToConfiguration(&lc)
configs = configs[:0]
for _, lc := range localConfigs {
if !matchesConfigStatus(lc.IsActive, status) {
continue
}
configs = append(configs, *localdb.LocalToConfiguration(&lc))
}
total := int64(len(configs))
@@ -532,6 +523,9 @@ func (s *LocalConfigurationService) ListTemplates(page, perPage int) ([]models.C
var templates []models.Configuration
for _, lc := range localConfigs {
if !lc.IsActive {
continue
}
if lc.IsTemplate {
templates = append(templates, *localdb.LocalToConfiguration(&lc))
}
@@ -604,21 +598,10 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
localCfg.UpdatedAt = now
localCfg.SyncStatus = "pending"
// Save to local SQLite
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
return nil, err
}
// Add to pending sync queue
cfg := localdb.LocalToConfiguration(localCfg)
payload, err := json.Marshal(cfg)
cfg, err := s.saveWithVersionAndPending(localCfg, "update", "")
if err != nil {
return nil, err
return nil, fmt.Errorf("refresh prices without auth with version: %w", err)
}
if err := s.localDB.AddPendingChange("configuration", uuid, "update", string(payload)); err != nil {
return nil, err
}
return cfg, nil
}
@@ -627,6 +610,102 @@ func (s *LocalConfigurationService) ImportFromServer() (*sync.ConfigImportResult
return s.syncService.ImportConfigurationsToLocal()
}
// GetCurrentVersion returns the currently active version row for configuration UUID.
func (s *LocalConfigurationService) GetCurrentVersion(configurationUUID string) (*localdb.LocalConfigurationVersion, error) {
var cfg localdb.LocalConfiguration
if err := s.localDB.DB().Where("uuid = ?", configurationUUID).First(&cfg).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrConfigNotFound
}
return nil, fmt.Errorf("get configuration for current version: %w", err)
}
var version localdb.LocalConfigurationVersion
if cfg.CurrentVersionID != nil && *cfg.CurrentVersionID != "" {
if err := s.localDB.DB().
Where("id = ? AND configuration_uuid = ?", *cfg.CurrentVersionID, configurationUUID).
First(&version).Error; err == nil {
return &version, nil
}
}
if err := s.localDB.DB().
Where("configuration_uuid = ?", configurationUUID).
Order("version_no DESC").
First(&version).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrConfigVersionNotFound
}
return nil, fmt.Errorf("get latest version for current pointer fallback: %w", err)
}
return &version, nil
}
// ListVersions returns versions by configuration UUID in descending order by version number.
func (s *LocalConfigurationService) ListVersions(configurationUUID string, limit, offset int) ([]localdb.LocalConfigurationVersion, error) {
if limit <= 0 {
limit = 20
}
if limit > 200 {
limit = 200
}
if offset < 0 {
return nil, ErrInvalidVersionNumber
}
var cfgCount int64
if err := s.localDB.DB().Model(&localdb.LocalConfiguration{}).
Where("uuid = ?", configurationUUID).
Count(&cfgCount).Error; err != nil {
return nil, fmt.Errorf("check configuration before list versions: %w", err)
}
if cfgCount == 0 {
return nil, ErrConfigNotFound
}
var versions []localdb.LocalConfigurationVersion
if err := s.localDB.DB().
Where("configuration_uuid = ?", configurationUUID).
Order("version_no DESC").
Limit(limit).
Offset(offset).
Find(&versions).Error; err != nil {
return nil, fmt.Errorf("list versions: %w", err)
}
return versions, nil
}
// GetVersion returns one version by configuration UUID and version number.
func (s *LocalConfigurationService) GetVersion(configurationUUID string, versionNo int) (*localdb.LocalConfigurationVersion, error) {
if versionNo <= 0 {
return nil, ErrInvalidVersionNumber
}
var version localdb.LocalConfigurationVersion
if err := s.localDB.DB().
Where("configuration_uuid = ? AND version_no = ?", configurationUUID, versionNo).
First(&version).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrConfigVersionNotFound
}
return nil, fmt.Errorf("get version %d for %s: %w", versionNo, configurationUUID, err)
}
return &version, nil
}
// RollbackToVersion creates a new version from target snapshot and marks it current.
func (s *LocalConfigurationService) RollbackToVersion(configurationUUID string, targetVersionNo int, userID string) (*models.Configuration, error) {
return s.rollbackToVersion(configurationUUID, targetVersionNo, userID, "")
}
// RollbackToVersionWithNote same as RollbackToVersion, with optional user note.
func (s *LocalConfigurationService) RollbackToVersionWithNote(configurationUUID string, targetVersionNo int, userID string, note string) (*models.Configuration, error) {
return s.rollbackToVersion(configurationUUID, targetVersionNo, userID, note)
}
func (s *LocalConfigurationService) isOwner(cfg *localdb.LocalConfiguration, ownerUsername string) bool {
if cfg == nil || ownerUsername == "" {
return false
@@ -636,3 +715,298 @@ func (s *LocalConfigurationService) isOwner(cfg *localdb.LocalConfiguration, own
}
return false
}
func (s *LocalConfigurationService) createWithVersion(localCfg *localdb.LocalConfiguration, createdBy string) error {
return s.localDB.DB().Transaction(func(tx *gorm.DB) error {
if err := tx.Create(localCfg).Error; err != nil {
return fmt.Errorf("create local configuration: %w", err)
}
version, err := s.appendVersionTx(tx, localCfg, "create", createdBy)
if err != nil {
return fmt.Errorf("append create version: %w", err)
}
if err := tx.Model(&localdb.LocalConfiguration{}).
Where("uuid = ?", localCfg.UUID).
Update("current_version_id", version.ID).Error; err != nil {
return fmt.Errorf("set current version id: %w", err)
}
localCfg.CurrentVersionID = &version.ID
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, "create", version, createdBy); err != nil {
return fmt.Errorf("enqueue create pending change: %w", err)
}
return nil
})
}
func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.LocalConfiguration, operation string, createdBy string) (*models.Configuration, error) {
var cfg *models.Configuration
err := s.localDB.DB().Transaction(func(tx *gorm.DB) error {
var locked localdb.LocalConfiguration
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("uuid = ?", localCfg.UUID).
First(&locked).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrConfigNotFound
}
return fmt.Errorf("lock configuration row: %w", err)
}
if err := tx.Save(localCfg).Error; err != nil {
return fmt.Errorf("save local configuration: %w", err)
}
version, err := s.appendVersionTx(tx, localCfg, operation, createdBy)
if err != nil {
return fmt.Errorf("append %s version: %w", operation, err)
}
if err := tx.Model(&localdb.LocalConfiguration{}).
Where("uuid = ?", localCfg.UUID).
Update("current_version_id", version.ID).Error; err != nil {
return fmt.Errorf("update current version id: %w", err)
}
localCfg.CurrentVersionID = &version.ID
cfg = localdb.LocalToConfiguration(localCfg)
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, operation, version, createdBy); err != nil {
return fmt.Errorf("enqueue %s pending change: %w", operation, err)
}
return nil
})
if err != nil {
return nil, err
}
return cfg, nil
}
func (s *LocalConfigurationService) appendVersionTx(
tx *gorm.DB,
localCfg *localdb.LocalConfiguration,
operation string,
createdBy string,
) (*localdb.LocalConfigurationVersion, error) {
snapshot, err := s.buildConfigurationSnapshot(localCfg)
if err != nil {
return nil, fmt.Errorf("build snapshot: %w", err)
}
changeNote := fmt.Sprintf("%s via local-first flow", operation)
var createdByPtr *string
if createdBy != "" {
createdByPtr = &createdBy
}
for attempt := 0; attempt < 3; attempt++ {
var maxVersion int
if err := tx.Model(&localdb.LocalConfigurationVersion{}).
Where("configuration_uuid = ?", localCfg.UUID).
Select("COALESCE(MAX(version_no), 0)").
Scan(&maxVersion).Error; err != nil {
return nil, fmt.Errorf("read max version: %w", err)
}
versionID := uuid.New().String()
version := &localdb.LocalConfigurationVersion{
ID: versionID,
ConfigurationUUID: localCfg.UUID,
VersionNo: maxVersion + 1,
Data: snapshot,
ChangeNote: &changeNote,
CreatedBy: createdByPtr,
}
if err := tx.Create(version).Error; err != nil {
// SQLite equivalent safety: serialized writer tx + UNIQUE(configuration_uuid, version_no) + retry.
if strings.Contains(err.Error(), "UNIQUE constraint failed: local_configuration_versions.configuration_uuid, local_configuration_versions.version_no") {
continue
}
return nil, fmt.Errorf("insert configuration version: %w", err)
}
return version, nil
}
return nil, fmt.Errorf("%w: exceeded retries for %s", ErrVersionConflict, localCfg.UUID)
}
func (s *LocalConfigurationService) buildConfigurationSnapshot(localCfg *localdb.LocalConfiguration) (string, error) {
return localdb.BuildConfigurationSnapshot(localCfg)
}
func (s *LocalConfigurationService) rollbackToVersion(configurationUUID string, targetVersionNo int, userID string, note string) (*models.Configuration, error) {
if targetVersionNo <= 0 {
return nil, ErrInvalidVersionNumber
}
var resultCfg *models.Configuration
err := s.localDB.DB().Transaction(func(tx *gorm.DB) error {
var current localdb.LocalConfiguration
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("uuid = ?", configurationUUID).
First(&current).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrConfigNotFound
}
return fmt.Errorf("lock configuration for rollback: %w", err)
}
var target localdb.LocalConfigurationVersion
if err := tx.Where("configuration_uuid = ? AND version_no = ?", configurationUUID, targetVersionNo).
First(&target).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrConfigVersionNotFound
}
return fmt.Errorf("load target rollback version: %w", err)
}
rollbackData, err := s.decodeConfigurationSnapshot(target.Data)
if err != nil {
return fmt.Errorf("decode target rollback snapshot: %w", err)
}
// Keep stable identity/sync linkage; restore editable config content from target snapshot.
current.Name = rollbackData.Name
current.Items = rollbackData.Items
current.TotalPrice = rollbackData.TotalPrice
current.CustomPrice = rollbackData.CustomPrice
current.Notes = rollbackData.Notes
current.IsTemplate = rollbackData.IsTemplate
current.ServerCount = rollbackData.ServerCount
current.PriceUpdatedAt = rollbackData.PriceUpdatedAt
current.UpdatedAt = time.Now()
current.SyncStatus = "pending"
current.IsActive = rollbackData.IsActive
if rollbackData.OriginalUsername != "" {
current.OriginalUsername = rollbackData.OriginalUsername
}
if rollbackData.OriginalUserID != 0 {
current.OriginalUserID = rollbackData.OriginalUserID
}
if err := tx.Save(&current).Error; err != nil {
return fmt.Errorf("save rolled back configuration: %w", err)
}
var maxVersion int
if err := tx.Model(&localdb.LocalConfigurationVersion{}).
Where("configuration_uuid = ?", configurationUUID).
Select("COALESCE(MAX(version_no), 0)").
Scan(&maxVersion).Error; err != nil {
return fmt.Errorf("read max version before rollback append: %w", err)
}
changeNote := fmt.Sprintf("rollback to v%d", targetVersionNo)
if trimmed := strings.TrimSpace(note); trimmed != "" {
changeNote = fmt.Sprintf("%s (%s)", changeNote, trimmed)
}
version := &localdb.LocalConfigurationVersion{
ID: uuid.New().String(),
ConfigurationUUID: configurationUUID,
VersionNo: maxVersion + 1,
Data: target.Data,
ChangeNote: &changeNote,
CreatedBy: stringPtrOrNil(userID),
}
if err := tx.Create(version).Error; err != nil {
if strings.Contains(err.Error(), "UNIQUE constraint failed: local_configuration_versions.configuration_uuid, local_configuration_versions.version_no") {
return ErrVersionConflict
}
return fmt.Errorf("create rollback version: %w", err)
}
if err := tx.Model(&localdb.LocalConfiguration{}).
Where("uuid = ?", configurationUUID).
Update("current_version_id", version.ID).Error; err != nil {
return fmt.Errorf("update current version after rollback: %w", err)
}
current.CurrentVersionID = &version.ID
resultCfg = localdb.LocalToConfiguration(&current)
if err := s.enqueueConfigurationPendingChangeTx(tx, &current, "rollback", version, userID); err != nil {
return fmt.Errorf("enqueue rollback pending change: %w", err)
}
return nil
})
if err != nil {
return nil, err
}
return resultCfg, nil
}
func (s *LocalConfigurationService) enqueueConfigurationPendingChangeTx(
tx *gorm.DB,
localCfg *localdb.LocalConfiguration,
operation string,
version *localdb.LocalConfigurationVersion,
createdBy string,
) error {
cfg := localdb.LocalToConfiguration(localCfg)
payload := sync.ConfigurationChangePayload{
EventID: uuid.New().String(),
IdempotencyKey: fmt.Sprintf("%s:v%d:%s", localCfg.UUID, version.VersionNo, operation),
ConfigurationUUID: localCfg.UUID,
Operation: operation,
CurrentVersionID: version.ID,
CurrentVersionNo: version.VersionNo,
ConflictPolicy: "last_write_wins",
Snapshot: *cfg,
CreatedAt: time.Now().UTC(),
CreatedBy: stringPtrOrNil(createdBy),
}
rawPayload, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal pending payload: %w", err)
}
change := &localdb.PendingChange{
EntityType: "configuration",
EntityUUID: localCfg.UUID,
Operation: operation,
Payload: string(rawPayload),
CreatedAt: time.Now(),
Attempts: 0,
}
if err := tx.Create(change).Error; err != nil {
return fmt.Errorf("insert pending change: %w", err)
}
return nil
}
func (s *LocalConfigurationService) decodeConfigurationSnapshot(data string) (*localdb.LocalConfiguration, error) {
return localdb.DecodeConfigurationSnapshot(data)
}
func stringPtrOrNil(value string) *string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return nil
}
return &trimmed
}
func matchesConfigStatus(isActive bool, status string) bool {
switch status {
case "active", "":
return isActive
case "archived":
return !isActive
case "all":
return true
default:
return isActive
}
}