Add background sync worker and complete local-first architecture

Implements automatic background synchronization every 5 minutes:
- Worker pushes pending changes to server (PushPendingChanges)
- Worker pulls new pricelists (SyncPricelistsIfNeeded)
- Graceful shutdown with context cancellation
- Automatic online/offline detection via DB ping

New files:
- internal/services/sync/worker.go - Background sync worker
- internal/services/local_configuration.go - Local-first CRUD
- internal/localdb/converters.go - MariaDB ↔ SQLite converters

Extended sync infrastructure:
- Pending changes queue (pending_changes table)
- Push/pull sync endpoints (/api/sync/push, /pending)
- ConfigurationGetter interface for handler compatibility
- LocalConfigurationService replaces ConfigurationService

All configuration operations now run through SQLite with automatic
background sync to MariaDB when online. Phase 2.5 nearly complete.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-01 22:17:00 +03:00
parent 143d217397
commit be77256d4e
11 changed files with 1131 additions and 27 deletions

View File

@@ -0,0 +1,161 @@
package localdb
import (
"time"
"git.mchus.pro/mchus/quoteforge/internal/models"
)
// ConfigurationToLocal converts models.Configuration to LocalConfiguration
func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
items := make(LocalConfigItems, len(cfg.Items))
for i, item := range cfg.Items {
items[i] = LocalConfigItem{
LotName: item.LotName,
Quantity: item.Quantity,
UnitPrice: item.UnitPrice,
}
}
local := &LocalConfiguration{
UUID: cfg.UUID,
Name: cfg.Name,
Items: items,
TotalPrice: cfg.TotalPrice,
CustomPrice: cfg.CustomPrice,
Notes: cfg.Notes,
IsTemplate: cfg.IsTemplate,
ServerCount: cfg.ServerCount,
CreatedAt: cfg.CreatedAt,
UpdatedAt: time.Now(),
SyncStatus: "pending",
OriginalUserID: cfg.UserID,
}
if cfg.ID > 0 {
serverID := cfg.ID
local.ServerID = &serverID
}
return local
}
// LocalToConfiguration converts LocalConfiguration to models.Configuration
func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
items := make(models.ConfigItems, len(local.Items))
for i, item := range local.Items {
items[i] = models.ConfigItem{
LotName: item.LotName,
Quantity: item.Quantity,
UnitPrice: item.UnitPrice,
}
}
cfg := &models.Configuration{
UUID: local.UUID,
UserID: local.OriginalUserID,
Name: local.Name,
Items: items,
TotalPrice: local.TotalPrice,
CustomPrice: local.CustomPrice,
Notes: local.Notes,
IsTemplate: local.IsTemplate,
ServerCount: local.ServerCount,
CreatedAt: local.CreatedAt,
}
if local.ServerID != nil {
cfg.ID = *local.ServerID
}
return cfg
}
// PricelistToLocal converts models.Pricelist to LocalPricelist
func PricelistToLocal(pl *models.Pricelist) *LocalPricelist {
name := pl.Notification
if name == "" {
name = pl.Version
}
return &LocalPricelist{
ServerID: pl.ID,
Version: pl.Version,
Name: name,
CreatedAt: pl.CreatedAt,
SyncedAt: time.Now(),
IsUsed: false,
}
}
// LocalToPricelist converts LocalPricelist to models.Pricelist
func LocalToPricelist(local *LocalPricelist) *models.Pricelist {
return &models.Pricelist{
ID: local.ServerID,
Version: local.Version,
Notification: local.Name,
CreatedAt: local.CreatedAt,
IsActive: true,
}
}
// PricelistItemToLocal converts models.PricelistItem to LocalPricelistItem
func PricelistItemToLocal(item *models.PricelistItem, localPricelistID uint) *LocalPricelistItem {
return &LocalPricelistItem{
PricelistID: localPricelistID,
LotName: item.LotName,
Price: item.Price,
}
}
// LocalToPricelistItem converts LocalPricelistItem to models.PricelistItem
func LocalToPricelistItem(local *LocalPricelistItem, serverPricelistID uint) *models.PricelistItem {
return &models.PricelistItem{
ID: local.ID,
PricelistID: serverPricelistID,
LotName: local.LotName,
Price: local.Price,
}
}
// ComponentToLocal converts models.LotMetadata to LocalComponent
func ComponentToLocal(meta *models.LotMetadata) *LocalComponent {
var lotDesc string
var category string
if meta.Lot != nil {
lotDesc = meta.Lot.LotDescription
}
// Extract category from lot_name (e.g., "CPU_AMD_9654" -> "CPU")
if len(meta.LotName) > 0 {
for i, ch := range meta.LotName {
if ch == '_' {
category = meta.LotName[:i]
break
}
}
}
return &LocalComponent{
LotName: meta.LotName,
LotDescription: lotDesc,
Category: category,
Model: meta.Model,
CurrentPrice: meta.CurrentPrice,
SyncedAt: time.Now(),
}
}
// LocalToComponent converts LocalComponent to models.LotMetadata
func LocalToComponent(local *LocalComponent) *models.LotMetadata {
return &models.LotMetadata{
LotName: local.LotName,
Model: local.Model,
CurrentPrice: local.CurrentPrice,
Lot: &models.Lot{
LotName: local.LotName,
LotDescription: local.LotDescription,
},
}
}

View File

@@ -56,6 +56,7 @@ func New(dbPath string) (*LocalDB, error) {
&LocalPricelistItem{},
&LocalComponent{},
&AppSetting{},
&PendingChange{},
); err != nil {
return nil, fmt.Errorf("migrating sqlite database: %w", err)
}
@@ -337,3 +338,73 @@ func (l *LocalDB) DeleteLocalPricelist(id uint) error {
// Delete pricelist
return l.db.Delete(&LocalPricelist{}, id).Error
}
// PendingChange methods
// AddPendingChange adds a change to the sync queue
func (l *LocalDB) AddPendingChange(entityType, entityUUID, operation, payload string) error {
change := PendingChange{
EntityType: entityType,
EntityUUID: entityUUID,
Operation: operation,
Payload: payload,
CreatedAt: time.Now(),
Attempts: 0,
}
return l.db.Create(&change).Error
}
// GetPendingChanges returns all pending changes ordered by creation time
func (l *LocalDB) GetPendingChanges() ([]PendingChange, error) {
var changes []PendingChange
err := l.db.Order("created_at ASC").Find(&changes).Error
return changes, err
}
// GetPendingChangesByEntity returns pending changes for a specific entity
func (l *LocalDB) GetPendingChangesByEntity(entityType, entityUUID string) ([]PendingChange, error) {
var changes []PendingChange
err := l.db.Where("entity_type = ? AND entity_uuid = ?", entityType, entityUUID).
Order("created_at ASC").Find(&changes).Error
return changes, err
}
// DeletePendingChange removes a change from the sync queue after successful sync
func (l *LocalDB) DeletePendingChange(id int64) error {
return l.db.Delete(&PendingChange{}, id).Error
}
// IncrementPendingChangeAttempts updates the attempt counter and last error
func (l *LocalDB) IncrementPendingChangeAttempts(id int64, errorMsg string) error {
return l.db.Model(&PendingChange{}).Where("id = ?", id).Updates(map[string]interface{}{
"attempts": gorm.Expr("attempts + 1"),
"last_error": errorMsg,
}).Error
}
// CountPendingChanges returns the total number of pending changes
func (l *LocalDB) CountPendingChanges() int64 {
var count int64
l.db.Model(&PendingChange{}).Count(&count)
return count
}
// CountPendingChangesByType returns the number of pending changes by entity type
func (l *LocalDB) CountPendingChangesByType(entityType string) int64 {
var count int64
l.db.Model(&PendingChange{}).Where("entity_type = ?", entityType).Count(&count)
return count
}
// MarkChangesSynced marks multiple pending changes as synced by deleting them
func (l *LocalDB) MarkChangesSynced(ids []int64) error {
if len(ids) == 0 {
return nil
}
return l.db.Where("id IN ?", ids).Delete(&PendingChange{}).Error
}
// GetPendingCount returns the total number of pending changes (alias for CountPendingChanges)
func (l *LocalDB) GetPendingCount() int64 {
return l.CountPendingChanges()
}

View File

@@ -120,3 +120,19 @@ type LocalComponent struct {
func (LocalComponent) TableName() string {
return "local_components"
}
// PendingChange stores changes that need to be synced to the server
type PendingChange struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
EntityType string `gorm:"not null;index" json:"entity_type"` // "configuration", "project", "specification"
EntityUUID string `gorm:"not null;index" json:"entity_uuid"`
Operation string `gorm:"not null" json:"operation"` // "create", "update", "delete"
Payload string `gorm:"type:text" json:"payload"` // JSON snapshot of the entity
CreatedAt time.Time `gorm:"not null" json:"created_at"`
Attempts int `gorm:"default:0" json:"attempts"` // Retry count for sync
LastError string `gorm:"type:text" json:"last_error,omitempty"`
}
func (PendingChange) TableName() string {
return "pending_changes"
}