Implement local DB migrations and archived configuration lifecycle
This commit is contained in:
@@ -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(¤t).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(¤t).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(¤t)
|
||||
if err := s.enqueueConfigurationPendingChangeTx(tx, ¤t, "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
|
||||
}
|
||||
}
|
||||
|
||||
357
internal/services/local_configuration_versioning_test.go
Normal file
357
internal/services/local_configuration_versioning_test.go
Normal file
@@ -0,0 +1,357 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||
)
|
||||
|
||||
func TestSaveCreatesNewVersionAndUpdatesCurrentPointer(t *testing.T) {
|
||||
service, local := newLocalConfigServiceForTest(t)
|
||||
|
||||
created, err := service.Create("tester", &CreateConfigRequest{
|
||||
Name: "v1",
|
||||
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1000}},
|
||||
ServerCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create config: %v", err)
|
||||
}
|
||||
|
||||
if _, err := service.RenameNoAuth(created.UUID, "v2"); err != nil {
|
||||
t.Fatalf("rename config: %v", err)
|
||||
}
|
||||
|
||||
versions := loadVersions(t, local, created.UUID)
|
||||
if len(versions) != 2 {
|
||||
t.Fatalf("expected 2 versions, got %d", len(versions))
|
||||
}
|
||||
if versions[0].VersionNo != 1 || versions[1].VersionNo != 2 {
|
||||
t.Fatalf("expected version_no [1,2], got [%d,%d]", versions[0].VersionNo, versions[1].VersionNo)
|
||||
}
|
||||
|
||||
cfg, err := local.GetConfigurationByUUID(created.UUID)
|
||||
if err != nil {
|
||||
t.Fatalf("load local config: %v", err)
|
||||
}
|
||||
if cfg.CurrentVersionID == nil || *cfg.CurrentVersionID != versions[1].ID {
|
||||
t.Fatalf("current_version_id should point to v2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollbackCreatesNewVersionWithTargetData(t *testing.T) {
|
||||
service, local := newLocalConfigServiceForTest(t)
|
||||
|
||||
created, err := service.Create("tester", &CreateConfigRequest{
|
||||
Name: "base",
|
||||
Items: models.ConfigItems{{LotName: "RAM_A", Quantity: 2, UnitPrice: 100}},
|
||||
ServerCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create config: %v", err)
|
||||
}
|
||||
|
||||
if _, err := service.RenameNoAuth(created.UUID, "changed"); err != nil {
|
||||
t.Fatalf("rename config: %v", err)
|
||||
}
|
||||
if _, err := service.RollbackToVersionWithNote(created.UUID, 1, "tester", "test rollback"); err != nil {
|
||||
t.Fatalf("rollback to v1: %v", err)
|
||||
}
|
||||
|
||||
versions := loadVersions(t, local, created.UUID)
|
||||
if len(versions) != 3 {
|
||||
t.Fatalf("expected 3 versions, got %d", len(versions))
|
||||
}
|
||||
if versions[2].VersionNo != 3 {
|
||||
t.Fatalf("expected v3 as rollback version, got v%d", versions[2].VersionNo)
|
||||
}
|
||||
if versions[2].Data != versions[0].Data {
|
||||
t.Fatalf("expected rollback snapshot data equal to v1 data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendOnlyInvariantOldRowsUnchanged(t *testing.T) {
|
||||
service, local := newLocalConfigServiceForTest(t)
|
||||
|
||||
created, err := service.Create("tester", &CreateConfigRequest{
|
||||
Name: "initial",
|
||||
Items: models.ConfigItems{{LotName: "SSD_A", Quantity: 1, UnitPrice: 300}},
|
||||
ServerCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create config: %v", err)
|
||||
}
|
||||
|
||||
versionsBefore := loadVersions(t, local, created.UUID)
|
||||
if len(versionsBefore) != 1 {
|
||||
t.Fatalf("expected exactly one version after create")
|
||||
}
|
||||
v1Before := versionsBefore[0]
|
||||
|
||||
if _, err := service.RenameNoAuth(created.UUID, "after-rename"); err != nil {
|
||||
t.Fatalf("rename config: %v", err)
|
||||
}
|
||||
if _, err := service.RollbackToVersion(created.UUID, 1, "tester"); err != nil {
|
||||
t.Fatalf("rollback: %v", err)
|
||||
}
|
||||
|
||||
versionsAfter := loadVersions(t, local, created.UUID)
|
||||
if len(versionsAfter) != 3 {
|
||||
t.Fatalf("expected 3 versions, got %d", len(versionsAfter))
|
||||
}
|
||||
v1After := versionsAfter[0]
|
||||
|
||||
if v1After.ID != v1Before.ID {
|
||||
t.Fatalf("v1 id changed: before=%s after=%s", v1Before.ID, v1After.ID)
|
||||
}
|
||||
if v1After.Data != v1Before.Data {
|
||||
t.Fatalf("v1 data changed")
|
||||
}
|
||||
if !v1After.CreatedAt.Equal(v1Before.CreatedAt) {
|
||||
t.Fatalf("v1 created_at changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentSaveNoDuplicateVersionNumbers(t *testing.T) {
|
||||
service, local := newLocalConfigServiceForTest(t)
|
||||
|
||||
created, err := service.Create("tester", &CreateConfigRequest{
|
||||
Name: "base",
|
||||
Items: models.ConfigItems{{LotName: "NIC_A", Quantity: 1, UnitPrice: 150}},
|
||||
ServerCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create config: %v", err)
|
||||
}
|
||||
|
||||
const workers = 8
|
||||
start := make(chan struct{})
|
||||
errCh := make(chan error, workers)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < workers; i++ {
|
||||
i := i
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
if err := renameWithRetry(service, created.UUID, fmt.Sprintf("name-%d", i)); err != nil {
|
||||
errCh <- err
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(errCh)
|
||||
|
||||
for err := range errCh {
|
||||
if err != nil {
|
||||
t.Fatalf("concurrent save failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type counts struct {
|
||||
Total int64
|
||||
DistinctCount int64
|
||||
Max int
|
||||
}
|
||||
var c counts
|
||||
if err := local.DB().Raw(`
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(DISTINCT version_no) as distinct_count,
|
||||
COALESCE(MAX(version_no), 0) as max
|
||||
FROM local_configuration_versions
|
||||
WHERE configuration_uuid = ?`, created.UUID).Scan(&c).Error; err != nil {
|
||||
t.Fatalf("query version counts: %v", err)
|
||||
}
|
||||
|
||||
if c.Total != c.DistinctCount {
|
||||
t.Fatalf("duplicate version numbers detected: total=%d distinct=%d", c.Total, c.DistinctCount)
|
||||
}
|
||||
expected := int64(workers + 1) // initial create version + each successful save
|
||||
if c.Total != expected || c.Max != int(expected) {
|
||||
t.Fatalf("expected total=max=%d, got total=%d max=%d", expected, c.Total, c.Max)
|
||||
}
|
||||
}
|
||||
|
||||
func newLocalConfigServiceForTest(t *testing.T) (*LocalConfigurationService, *localdb.LocalDB) {
|
||||
t.Helper()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "local.db")
|
||||
local, err := localdb.New(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("init local db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = local.Close()
|
||||
})
|
||||
|
||||
return NewLocalConfigurationService(
|
||||
local,
|
||||
syncsvc.NewService(nil, local),
|
||||
&QuoteService{},
|
||||
func() bool { return false },
|
||||
), local
|
||||
}
|
||||
|
||||
func loadVersions(t *testing.T, local *localdb.LocalDB, configurationUUID string) []localdb.LocalConfigurationVersion {
|
||||
t.Helper()
|
||||
var versions []localdb.LocalConfigurationVersion
|
||||
if err := local.DB().
|
||||
Where("configuration_uuid = ?", configurationUUID).
|
||||
Order("version_no ASC").
|
||||
Find(&versions).Error; err != nil {
|
||||
t.Fatalf("load versions: %v", err)
|
||||
}
|
||||
return versions
|
||||
}
|
||||
|
||||
func renameWithRetry(service *LocalConfigurationService, uuid string, name string) error {
|
||||
var lastErr error
|
||||
for i := 0; i < 6; i++ {
|
||||
_, err := service.RenameNoAuth(uuid, name)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
lastErr = err
|
||||
if errors.Is(err, ErrVersionConflict) || strings.Contains(err.Error(), "database is locked") {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("rename retries exhausted: %w", lastErr)
|
||||
}
|
||||
|
||||
func TestRollbackVersionSnapshotJSONMatchesV1(t *testing.T) {
|
||||
service, local := newLocalConfigServiceForTest(t)
|
||||
|
||||
created, err := service.Create("tester", &CreateConfigRequest{
|
||||
Name: "initial",
|
||||
Items: models.ConfigItems{{LotName: "GPU_A", Quantity: 1, UnitPrice: 2000}},
|
||||
ServerCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create config: %v", err)
|
||||
}
|
||||
if _, err := service.RenameNoAuth(created.UUID, "second"); err != nil {
|
||||
t.Fatalf("rename: %v", err)
|
||||
}
|
||||
if _, err := service.RollbackToVersion(created.UUID, 1, "tester"); err != nil {
|
||||
t.Fatalf("rollback: %v", err)
|
||||
}
|
||||
|
||||
versions := loadVersions(t, local, created.UUID)
|
||||
if len(versions) != 3 {
|
||||
t.Fatalf("expected 3 versions")
|
||||
}
|
||||
|
||||
var v1 map[string]any
|
||||
var v3 map[string]any
|
||||
if err := json.Unmarshal([]byte(versions[0].Data), &v1); err != nil {
|
||||
t.Fatalf("unmarshal v1: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(versions[2].Data), &v3); err != nil {
|
||||
t.Fatalf("unmarshal v3: %v", err)
|
||||
}
|
||||
if fmt.Sprintf("%v", v1["name"]) != fmt.Sprintf("%v", v3["name"]) {
|
||||
t.Fatalf("rollback snapshot differs from v1 snapshot by name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteMarksInactiveAndCreatesVersion(t *testing.T) {
|
||||
service, local := newLocalConfigServiceForTest(t)
|
||||
|
||||
created, err := service.Create("tester", &CreateConfigRequest{
|
||||
Name: "to-archive",
|
||||
Items: models.ConfigItems{{LotName: "CPU_Z", Quantity: 1, UnitPrice: 500}},
|
||||
ServerCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create config: %v", err)
|
||||
}
|
||||
if err := service.DeleteNoAuth(created.UUID); err != nil {
|
||||
t.Fatalf("delete no auth: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := local.GetConfigurationByUUID(created.UUID)
|
||||
if err != nil {
|
||||
t.Fatalf("load archived config: %v", err)
|
||||
}
|
||||
if cfg.IsActive {
|
||||
t.Fatalf("expected config to be inactive after delete")
|
||||
}
|
||||
|
||||
versions := loadVersions(t, local, created.UUID)
|
||||
if len(versions) != 2 {
|
||||
t.Fatalf("expected 2 versions after archive, got %d", len(versions))
|
||||
}
|
||||
if versions[1].VersionNo != 2 {
|
||||
t.Fatalf("expected archive to create version 2, got %d", versions[1].VersionNo)
|
||||
}
|
||||
|
||||
list, total, err := service.ListAll(1, 20)
|
||||
if err != nil {
|
||||
t.Fatalf("list all: %v", err)
|
||||
}
|
||||
if total != int64(len(list)) {
|
||||
t.Fatalf("unexpected total/list mismatch")
|
||||
}
|
||||
if len(list) != 0 {
|
||||
t.Fatalf("expected archived config to be hidden from list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReactivateRestoresArchivedConfigurationAndCreatesVersion(t *testing.T) {
|
||||
service, local := newLocalConfigServiceForTest(t)
|
||||
|
||||
created, err := service.Create("tester", &CreateConfigRequest{
|
||||
Name: "to-reactivate",
|
||||
Items: models.ConfigItems{{LotName: "CPU_R", Quantity: 1, UnitPrice: 700}},
|
||||
ServerCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create config: %v", err)
|
||||
}
|
||||
if err := service.DeleteNoAuth(created.UUID); err != nil {
|
||||
t.Fatalf("archive config: %v", err)
|
||||
}
|
||||
if _, err := service.ReactivateNoAuth(created.UUID); err != nil {
|
||||
t.Fatalf("reactivate config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := local.GetConfigurationByUUID(created.UUID)
|
||||
if err != nil {
|
||||
t.Fatalf("load reactivated config: %v", err)
|
||||
}
|
||||
if !cfg.IsActive {
|
||||
t.Fatalf("expected config to be active after reactivation")
|
||||
}
|
||||
|
||||
versions := loadVersions(t, local, created.UUID)
|
||||
if len(versions) != 3 {
|
||||
t.Fatalf("expected 3 versions after reactivation, got %d", len(versions))
|
||||
}
|
||||
if versions[2].VersionNo != 3 {
|
||||
t.Fatalf("expected reactivation version 3, got %d", versions[2].VersionNo)
|
||||
}
|
||||
|
||||
list, _, err := service.ListAll(1, 20)
|
||||
if err != nil {
|
||||
t.Fatalf("list all after reactivation: %v", err)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("expected reactivated config to be visible in list")
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,21 @@ type ConfigImportResult struct {
|
||||
Skipped int `json:"skipped"`
|
||||
}
|
||||
|
||||
// ConfigurationChangePayload is stored in pending_changes.payload for configuration events.
|
||||
// It carries version metadata so sync can push the latest snapshot and prepare for conflict resolution.
|
||||
type ConfigurationChangePayload struct {
|
||||
EventID string `json:"event_id"`
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
ConfigurationUUID string `json:"configuration_uuid"`
|
||||
Operation string `json:"operation"` // create/update/rollback/deactivate/reactivate/delete
|
||||
CurrentVersionID string `json:"current_version_id,omitempty"`
|
||||
CurrentVersionNo int `json:"current_version_no,omitempty"`
|
||||
ConflictPolicy string `json:"conflict_policy,omitempty"` // currently: last_write_wins
|
||||
Snapshot models.Configuration `json:"snapshot"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
CreatedBy *string `json:"created_by,omitempty"`
|
||||
}
|
||||
|
||||
// ImportConfigurationsToLocal imports configurations from MariaDB into local SQLite.
|
||||
// Existing local configs with pending local changes are skipped to avoid data loss.
|
||||
func (s *Service) ImportConfigurationsToLocal() (*ConfigImportResult, error) {
|
||||
@@ -78,6 +93,11 @@ func (s *Service) ImportConfigurationsToLocal() (*ConfigImportResult, error) {
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
if existing != nil && err == nil && !existing.IsActive {
|
||||
// Keep local deactivation sticky: do not resurrect hidden entries from server pull.
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
localCfg := localdb.ConfigurationToLocal(&cfg)
|
||||
now := time.Now()
|
||||
@@ -432,6 +452,12 @@ func (s *Service) pushConfigurationChange(change *localdb.PendingChange) error {
|
||||
return s.pushConfigurationCreate(change)
|
||||
case "update":
|
||||
return s.pushConfigurationUpdate(change)
|
||||
case "rollback":
|
||||
return s.pushConfigurationRollback(change)
|
||||
case "deactivate":
|
||||
return s.pushConfigurationDeactivate(change)
|
||||
case "reactivate":
|
||||
return s.pushConfigurationReactivate(change)
|
||||
case "delete":
|
||||
return s.pushConfigurationDelete(change)
|
||||
default:
|
||||
@@ -441,9 +467,13 @@ func (s *Service) pushConfigurationChange(change *localdb.PendingChange) error {
|
||||
|
||||
// pushConfigurationCreate creates a configuration on the server
|
||||
func (s *Service) pushConfigurationCreate(change *localdb.PendingChange) error {
|
||||
var cfg models.Configuration
|
||||
if err := json.Unmarshal([]byte(change.Payload), &cfg); err != nil {
|
||||
return fmt.Errorf("unmarshaling configuration: %w", err)
|
||||
payload, cfg, isStale, err := s.resolveConfigurationPayloadForPush(change)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isStale {
|
||||
slog.Debug("skipping stale create event, newer version exists", "uuid", payload.ConfigurationUUID, "idempotency_key", payload.IdempotencyKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get database connection
|
||||
@@ -457,7 +487,15 @@ func (s *Service) pushConfigurationCreate(change *localdb.PendingChange) error {
|
||||
|
||||
// Create on server
|
||||
if err := configRepo.Create(&cfg); err != nil {
|
||||
return fmt.Errorf("creating configuration on server: %w", err)
|
||||
// Idempotency fallback: configuration may already be created remotely.
|
||||
serverCfg, getErr := configRepo.GetByUUID(cfg.UUID)
|
||||
if getErr != nil {
|
||||
return fmt.Errorf("creating configuration on server: %w", err)
|
||||
}
|
||||
cfg.ID = serverCfg.ID
|
||||
if updateErr := configRepo.Update(&cfg); updateErr != nil {
|
||||
return fmt.Errorf("create fallback update on server: %w", updateErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Update local configuration with server ID
|
||||
@@ -469,15 +507,25 @@ func (s *Service) pushConfigurationCreate(change *localdb.PendingChange) error {
|
||||
s.localDB.SaveConfiguration(localCfg)
|
||||
}
|
||||
|
||||
slog.Info("configuration created on server", "uuid", cfg.UUID, "server_id", cfg.ID)
|
||||
slog.Info("configuration created on server",
|
||||
"uuid", cfg.UUID,
|
||||
"server_id", cfg.ID,
|
||||
"version_no", payload.CurrentVersionNo,
|
||||
"version_id", payload.CurrentVersionID,
|
||||
"idempotency_key", payload.IdempotencyKey,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// pushConfigurationUpdate updates a configuration on the server
|
||||
func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error {
|
||||
var cfg models.Configuration
|
||||
if err := json.Unmarshal([]byte(change.Payload), &cfg); err != nil {
|
||||
return fmt.Errorf("unmarshaling configuration: %w", err)
|
||||
payload, cfg, isStale, err := s.resolveConfigurationPayloadForPush(change)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isStale {
|
||||
slog.Debug("skipping stale update event, newer version exists", "uuid", payload.ConfigurationUUID, "idempotency_key", payload.IdempotencyKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get database connection
|
||||
@@ -526,10 +574,149 @@ func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error {
|
||||
s.localDB.SaveConfiguration(localCfg)
|
||||
}
|
||||
|
||||
slog.Info("configuration updated on server", "uuid", cfg.UUID)
|
||||
slog.Info("configuration updated on server",
|
||||
"uuid", cfg.UUID,
|
||||
"version_no", payload.CurrentVersionNo,
|
||||
"version_id", payload.CurrentVersionID,
|
||||
"idempotency_key", payload.IdempotencyKey,
|
||||
"operation", payload.Operation,
|
||||
"conflict_policy", payload.ConflictPolicy,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) pushConfigurationRollback(change *localdb.PendingChange) error {
|
||||
// Last-write-wins for now: rollback is pushed as an update with rollback metadata.
|
||||
return s.pushConfigurationUpdate(change)
|
||||
}
|
||||
|
||||
func (s *Service) pushConfigurationDeactivate(change *localdb.PendingChange) error {
|
||||
// Local deactivate is represented as the latest snapshot push.
|
||||
return s.pushConfigurationUpdate(change)
|
||||
}
|
||||
|
||||
func (s *Service) pushConfigurationReactivate(change *localdb.PendingChange) error {
|
||||
// Local reactivate is represented as the latest snapshot push.
|
||||
return s.pushConfigurationUpdate(change)
|
||||
}
|
||||
|
||||
func (s *Service) resolveConfigurationPayloadForPush(change *localdb.PendingChange) (ConfigurationChangePayload, models.Configuration, bool, error) {
|
||||
payload, err := decodeConfigurationChangePayload(change)
|
||||
if err != nil {
|
||||
return ConfigurationChangePayload{}, models.Configuration{}, false, fmt.Errorf("decode configuration payload: %w", err)
|
||||
}
|
||||
eventVersionNo := payload.CurrentVersionNo
|
||||
|
||||
currentCfg, currentVersionID, currentVersionNo, err := s.loadCurrentConfigurationState(payload.ConfigurationUUID)
|
||||
if err != nil {
|
||||
// create->deactivate race: config may no longer be active/visible locally, skip stale create.
|
||||
if change.Operation == "create" {
|
||||
return payload, payload.Snapshot, true, nil
|
||||
}
|
||||
return ConfigurationChangePayload{}, models.Configuration{}, false, fmt.Errorf("load current local configuration state: %w", err)
|
||||
}
|
||||
|
||||
if payload.ConflictPolicy == "" {
|
||||
payload.ConflictPolicy = "last_write_wins"
|
||||
}
|
||||
|
||||
if currentCfg.UUID != "" {
|
||||
payload.Snapshot = currentCfg
|
||||
if currentVersionID != "" {
|
||||
payload.CurrentVersionID = currentVersionID
|
||||
}
|
||||
if currentVersionNo > 0 {
|
||||
payload.CurrentVersionNo = currentVersionNo
|
||||
}
|
||||
}
|
||||
|
||||
isStale := false
|
||||
if eventVersionNo > 0 && currentVersionNo > eventVersionNo {
|
||||
// Keep only latest intent in queue; older versions become no-op.
|
||||
isStale = true
|
||||
}
|
||||
if !isStale && change.Operation == "create" {
|
||||
localCfg, getErr := s.localDB.GetConfigurationByUUID(payload.ConfigurationUUID)
|
||||
if getErr == nil && !localCfg.IsActive {
|
||||
isStale = true
|
||||
}
|
||||
}
|
||||
|
||||
return payload, payload.Snapshot, isStale, nil
|
||||
}
|
||||
|
||||
func decodeConfigurationChangePayload(change *localdb.PendingChange) (ConfigurationChangePayload, error) {
|
||||
var payload ConfigurationChangePayload
|
||||
if err := json.Unmarshal([]byte(change.Payload), &payload); err == nil && payload.ConfigurationUUID != "" && payload.Snapshot.UUID != "" {
|
||||
if payload.Operation == "" {
|
||||
payload.Operation = change.Operation
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// Backward compatibility: legacy queue stored raw models.Configuration JSON.
|
||||
var cfg models.Configuration
|
||||
if err := json.Unmarshal([]byte(change.Payload), &cfg); err != nil {
|
||||
return ConfigurationChangePayload{}, fmt.Errorf("unmarshal legacy configuration payload: %w", err)
|
||||
}
|
||||
|
||||
return ConfigurationChangePayload{
|
||||
EventID: "",
|
||||
IdempotencyKey: fmt.Sprintf("%s:%s:legacy", cfg.UUID, change.Operation),
|
||||
ConfigurationUUID: cfg.UUID,
|
||||
Operation: change.Operation,
|
||||
ConflictPolicy: "last_write_wins",
|
||||
Snapshot: cfg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) loadCurrentConfigurationState(configurationUUID string) (models.Configuration, string, int, error) {
|
||||
localCfg, err := s.localDB.GetConfigurationByUUID(configurationUUID)
|
||||
if err != nil {
|
||||
return models.Configuration{}, "", 0, fmt.Errorf("get local configuration by uuid: %w", err)
|
||||
}
|
||||
|
||||
cfg := *localdb.LocalToConfiguration(localCfg)
|
||||
|
||||
currentVersionID := ""
|
||||
if localCfg.CurrentVersionID != nil {
|
||||
currentVersionID = *localCfg.CurrentVersionID
|
||||
}
|
||||
|
||||
currentVersionNo := 0
|
||||
if currentVersionID != "" {
|
||||
var version localdb.LocalConfigurationVersion
|
||||
err = s.localDB.DB().
|
||||
Where("id = ? AND configuration_uuid = ?", currentVersionID, configurationUUID).
|
||||
First(&version).Error
|
||||
if err == nil {
|
||||
currentVersionNo = version.VersionNo
|
||||
}
|
||||
}
|
||||
|
||||
if currentVersionNo == 0 {
|
||||
var latest localdb.LocalConfigurationVersion
|
||||
err = s.localDB.DB().
|
||||
Where("configuration_uuid = ?", configurationUUID).
|
||||
Order("version_no DESC").
|
||||
First(&latest).Error
|
||||
if err == nil {
|
||||
currentVersionNo = latest.VersionNo
|
||||
currentVersionID = latest.ID
|
||||
}
|
||||
}
|
||||
|
||||
if currentVersionNo == 0 {
|
||||
return models.Configuration{}, "", 0, fmt.Errorf("no local configuration version found for %s", configurationUUID)
|
||||
}
|
||||
|
||||
return cfg, currentVersionID, currentVersionNo, nil
|
||||
}
|
||||
|
||||
// NOTE: prepared for future conflict resolution:
|
||||
// when server starts storing version metadata, we can compare payload.CurrentVersionNo
|
||||
// against remote version and branch into custom strategies. For now use last-write-wins.
|
||||
|
||||
// pushConfigurationDelete deletes a configuration from the server
|
||||
func (s *Service) pushConfigurationDelete(change *localdb.PendingChange) error {
|
||||
// Get database connection
|
||||
@@ -554,6 +741,6 @@ func (s *Service) pushConfigurationDelete(change *localdb.PendingChange) error {
|
||||
return fmt.Errorf("deleting configuration from server: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("configuration deleted from server", "uuid", change.EntityUUID)
|
||||
slog.Info("configuration deleted on server", "uuid", change.EntityUUID)
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user