Make full sync push pending and pull projects/configurations
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -64,6 +65,13 @@ type ConfigImportResult struct {
|
||||
Skipped int `json:"skipped"`
|
||||
}
|
||||
|
||||
// ProjectImportResult represents server->local project import stats.
|
||||
type ProjectImportResult struct {
|
||||
Imported int `json:"imported"`
|
||||
Updated int `json:"updated"`
|
||||
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 {
|
||||
@@ -153,6 +161,77 @@ func (s *Service) ImportConfigurationsToLocal() (*ConfigImportResult, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ImportProjectsToLocal imports projects from MariaDB into local SQLite.
|
||||
// Existing local projects with pending local changes are skipped to avoid data loss.
|
||||
func (s *Service) ImportProjectsToLocal() (*ProjectImportResult, error) {
|
||||
mariaDB, err := s.getDB()
|
||||
if err != nil {
|
||||
return nil, ErrOffline
|
||||
}
|
||||
|
||||
projectRepo := repository.NewProjectRepository(mariaDB)
|
||||
result := &ProjectImportResult{}
|
||||
|
||||
offset := 0
|
||||
const limit = 200
|
||||
for {
|
||||
serverProjects, _, err := projectRepo.List(offset, limit, true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing server projects: %w", err)
|
||||
}
|
||||
if len(serverProjects) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
for i := range serverProjects {
|
||||
project := serverProjects[i]
|
||||
|
||||
existing, getErr := s.localDB.GetProjectByUUID(project.UUID)
|
||||
if getErr != nil && !errors.Is(getErr, gorm.ErrRecordNotFound) {
|
||||
return nil, fmt.Errorf("getting local project %s: %w", project.UUID, getErr)
|
||||
}
|
||||
|
||||
if existing != nil && getErr == nil {
|
||||
// Keep unsynced local changes intact.
|
||||
if existing.SyncStatus == "pending" {
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
existing.OwnerUsername = project.OwnerUsername
|
||||
existing.Name = project.Name
|
||||
existing.IsActive = project.IsActive
|
||||
existing.IsSystem = project.IsSystem
|
||||
existing.CreatedAt = project.CreatedAt
|
||||
existing.UpdatedAt = project.UpdatedAt
|
||||
serverID := project.ID
|
||||
existing.ServerID = &serverID
|
||||
existing.SyncStatus = "synced"
|
||||
existing.SyncedAt = &now
|
||||
|
||||
if err := s.localDB.SaveProject(existing); err != nil {
|
||||
return nil, fmt.Errorf("saving local project %s: %w", project.UUID, err)
|
||||
}
|
||||
result.Updated++
|
||||
continue
|
||||
}
|
||||
|
||||
localProject := localdb.ProjectToLocal(&project)
|
||||
localProject.SyncStatus = "synced"
|
||||
localProject.SyncedAt = &now
|
||||
if err := s.localDB.SaveProject(localProject); err != nil {
|
||||
return nil, fmt.Errorf("saving local project %s: %w", project.UUID, err)
|
||||
}
|
||||
result.Imported++
|
||||
}
|
||||
|
||||
offset += len(serverProjects)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetStatus returns the current sync status
|
||||
func (s *Service) GetStatus() (*SyncStatus, error) {
|
||||
lastSync := s.localDB.GetLastSyncTime()
|
||||
@@ -371,21 +450,85 @@ func (s *Service) ListUserSyncStatuses(onlineThreshold time.Duration) ([]UserSyn
|
||||
return nil, fmt.Errorf("load sync status rows: %w", err)
|
||||
}
|
||||
|
||||
activeUsers, err := s.listConnectedDBUsers(mariaDB)
|
||||
if err != nil {
|
||||
slog.Debug("sync status: failed to load connected DB users", "error", err)
|
||||
activeUsers = map[string]struct{}{}
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
result := make([]UserSyncStatus, 0, len(rows))
|
||||
result := make([]UserSyncStatus, 0, len(rows)+len(activeUsers))
|
||||
for i := range rows {
|
||||
r := rows[i]
|
||||
username := strings.TrimSpace(r.Username)
|
||||
if username == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
isOnline := now.Sub(r.LastSyncAt) <= onlineThreshold
|
||||
if _, connected := activeUsers[username]; connected {
|
||||
isOnline = true
|
||||
delete(activeUsers, username)
|
||||
}
|
||||
|
||||
appVersion := strings.TrimSpace(r.AppVersion)
|
||||
|
||||
result = append(result, UserSyncStatus{
|
||||
Username: r.Username,
|
||||
Username: username,
|
||||
LastSyncAt: r.LastSyncAt,
|
||||
AppVersion: strings.TrimSpace(r.AppVersion),
|
||||
IsOnline: now.Sub(r.LastSyncAt) <= onlineThreshold,
|
||||
AppVersion: appVersion,
|
||||
IsOnline: isOnline,
|
||||
})
|
||||
}
|
||||
|
||||
for username := range activeUsers {
|
||||
result = append(result, UserSyncStatus{
|
||||
Username: username,
|
||||
LastSyncAt: now,
|
||||
AppVersion: "",
|
||||
IsOnline: true,
|
||||
})
|
||||
}
|
||||
|
||||
sort.SliceStable(result, func(i, j int) bool {
|
||||
if result[i].IsOnline != result[j].IsOnline {
|
||||
return result[i].IsOnline
|
||||
}
|
||||
if result[i].LastSyncAt.Equal(result[j].LastSyncAt) {
|
||||
return strings.ToLower(result[i].Username) < strings.ToLower(result[j].Username)
|
||||
}
|
||||
return result[i].LastSyncAt.After(result[j].LastSyncAt)
|
||||
})
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) listConnectedDBUsers(mariaDB *gorm.DB) (map[string]struct{}, error) {
|
||||
type processUserRow struct {
|
||||
Username string `gorm:"column:username"`
|
||||
}
|
||||
|
||||
var rows []processUserRow
|
||||
if err := mariaDB.Raw(`
|
||||
SELECT DISTINCT TRIM(USER) AS username
|
||||
FROM information_schema.PROCESSLIST
|
||||
WHERE COALESCE(TRIM(USER), '') <> ''
|
||||
AND DB = DATABASE()
|
||||
`).Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
users := make(map[string]struct{}, len(rows))
|
||||
for i := range rows {
|
||||
username := strings.TrimSpace(rows[i].Username)
|
||||
if username == "" {
|
||||
continue
|
||||
}
|
||||
users[username] = struct{}{}
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func ensureUserSyncStatusTable(db *gorm.DB) error {
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS qt_pricelist_sync_status (
|
||||
|
||||
Reference in New Issue
Block a user