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

@@ -108,12 +108,19 @@ func main() {
}
gin.SetMode(cfg.Server.Mode)
router, err := setupRouter(db, cfg, local, dbUserID)
router, syncService, err := setupRouter(db, cfg, local, dbUserID)
if err != nil {
slog.Error("failed to setup router", "error", err)
os.Exit(1)
}
// Start background sync worker
workerCtx, workerCancel := context.WithCancel(context.Background())
defer workerCancel()
syncWorker := sync.NewWorker(syncService, db, 5*time.Minute)
go syncWorker.Start(workerCtx)
srv := &http.Server{
Addr: cfg.Address(),
Handler: router,
@@ -135,6 +142,11 @@ func main() {
slog.Info("shutting down server...")
// Stop background sync worker first
syncWorker.Stop()
workerCancel()
// Then shutdown HTTP server
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@@ -282,7 +294,7 @@ func setupDatabaseFromDSN(dsn string) (*gorm.DB, error) {
return db, nil
}
func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUserID uint) (*gin.Engine, error) {
func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUserID uint) (*gin.Engine, *sync.Service, error) {
// Repositories
componentRepo := repository.NewComponentRepository(db)
categoryRepo := repository.NewCategoryRepository(db)
@@ -299,8 +311,19 @@ func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUser
exportService := services.NewExportService(cfg.Export, categoryRepo)
alertService := alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing)
pricelistService := pricelist.NewService(db, pricelistRepo, componentRepo)
configService := services.NewConfigurationService(configRepo, componentRepo, quoteService)
syncService := sync.NewService(pricelistRepo, local)
syncService := sync.NewService(pricelistRepo, configRepo, local)
// isOnline function for local-first architecture
isOnline := func() bool {
sqlDB, err := db.DB()
if err != nil {
return false
}
return sqlDB.Ping() == nil
}
// Local-first configuration service (replaces old ConfigurationService)
configService := services.NewLocalConfigurationService(local, syncService, quoteService, isOnline)
// Handlers
componentHandler := handlers.NewComponentHandler(componentService)
@@ -313,13 +336,13 @@ func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUser
// Setup handler (for reconfiguration)
setupHandler, err := handlers.NewSetupHandler(local, "web/templates")
if err != nil {
return nil, fmt.Errorf("creating setup handler: %w", err)
return nil, nil, fmt.Errorf("creating setup handler: %w", err)
}
// Web handler (templates)
webHandler, err := handlers.NewWebHandler("web/templates", componentService)
if err != nil {
return nil, err
return nil, nil, err
}
// Router
@@ -584,10 +607,13 @@ func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUser
syncAPI.POST("/components", syncHandler.SyncComponents)
syncAPI.POST("/pricelists", syncHandler.SyncPricelists)
syncAPI.POST("/all", syncHandler.SyncAll)
syncAPI.POST("/push", syncHandler.PushPendingChanges)
syncAPI.GET("/pending/count", syncHandler.GetPendingCount)
syncAPI.GET("/pending", syncHandler.GetPendingChanges)
}
}
return router, nil
return router, syncService, nil
}
func requestLogger() gin.HandlerFunc {