Local-first runtime cleanup and recovery hardening

This commit is contained in:
Mikhail Chusavitin
2026-03-07 23:18:07 +03:00
parent 4e977737ee
commit 06397a6bd1
53 changed files with 1856 additions and 2080 deletions

View File

@@ -1,6 +1,7 @@
package sync
import (
"encoding/json"
"fmt"
"log/slog"
"time"
@@ -23,13 +24,14 @@ func (s *Service) PullPartnumberBooks() (int, error) {
localBookRepo := repository.NewPartnumberBookRepository(s.localDB.DB())
type serverBook struct {
ID int `gorm:"column:id"`
Version string `gorm:"column:version"`
CreatedAt time.Time `gorm:"column:created_at"`
IsActive bool `gorm:"column:is_active"`
ID int `gorm:"column:id"`
Version string `gorm:"column:version"`
CreatedAt time.Time `gorm:"column:created_at"`
IsActive bool `gorm:"column:is_active"`
PartnumbersJSON string `gorm:"column:partnumbers_json"`
}
var serverBooks []serverBook
if err := mariaDB.Raw("SELECT id, version, created_at, is_active FROM qt_partnumber_books ORDER BY created_at DESC, id DESC").Scan(&serverBooks).Error; err != nil {
if err := mariaDB.Raw("SELECT id, version, created_at, is_active, partnumbers_json FROM qt_partnumber_books ORDER BY created_at DESC, id DESC").Scan(&serverBooks).Error; err != nil {
return 0, fmt.Errorf("querying server partnumber books: %w", err)
}
slog.Info("partnumber books found on server", "count", len(serverBooks))
@@ -38,16 +40,28 @@ func (s *Service) PullPartnumberBooks() (int, error) {
for _, sb := range serverBooks {
var existing localdb.LocalPartnumberBook
err := s.localDB.DB().Where("server_id = ?", sb.ID).First(&existing).Error
partnumbers, errPartnumbers := decodeServerPartnumbers(sb.PartnumbersJSON)
if errPartnumbers != nil {
slog.Error("failed to decode server partnumbers_json", "server_id", sb.ID, "error", errPartnumbers)
continue
}
if err == nil {
// Header exists — check whether items were saved
existing.Version = sb.Version
existing.CreatedAt = sb.CreatedAt
existing.IsActive = sb.IsActive
existing.PartnumbersJSON = partnumbers
if err := localBookRepo.SaveBook(&existing); err != nil {
slog.Error("failed to update local partnumber book header", "server_id", sb.ID, "error", err)
continue
}
localItemCount := localBookRepo.CountBookItems(existing.ID)
if localItemCount > 0 {
if localItemCount > 0 && localBookRepo.HasAllBookItems(existing.ID) {
slog.Debug("partnumber book already synced, skipping", "server_id", sb.ID, "version", sb.Version, "items", localItemCount)
continue
}
// Items missing re-pull them
slog.Info("partnumber book header exists but has no items, re-pulling items", "server_id", sb.ID, "version", sb.Version)
n, err := pullBookItems(mariaDB, localBookRepo, sb.ID, existing.ID)
slog.Info("partnumber book header exists but catalog items are missing, re-pulling items", "server_id", sb.ID, "version", sb.Version)
n, err := pullBookItems(mariaDB, localBookRepo, existing.PartnumbersJSON)
if err != nil {
slog.Error("failed to re-pull items for existing book", "server_id", sb.ID, "error", err)
} else {
@@ -60,17 +74,18 @@ func (s *Service) PullPartnumberBooks() (int, error) {
slog.Info("pulling new partnumber book", "server_id", sb.ID, "version", sb.Version, "is_active", sb.IsActive)
localBook := &localdb.LocalPartnumberBook{
ServerID: sb.ID,
Version: sb.Version,
CreatedAt: sb.CreatedAt,
IsActive: sb.IsActive,
ServerID: sb.ID,
Version: sb.Version,
CreatedAt: sb.CreatedAt,
IsActive: sb.IsActive,
PartnumbersJSON: partnumbers,
}
if err := localBookRepo.SaveBook(localBook); err != nil {
slog.Error("failed to save local partnumber book", "server_id", sb.ID, "error", err)
continue
}
n, err := pullBookItems(mariaDB, localBookRepo, sb.ID, localBook.ID)
n, err := pullBookItems(mariaDB, localBookRepo, localBook.PartnumbersJSON)
if err != nil {
slog.Error("failed to pull items for new book", "server_id", sb.ID, "error", err)
continue
@@ -84,39 +99,39 @@ func (s *Service) PullPartnumberBooks() (int, error) {
return pulled, nil
}
// pullBookItems fetches items for a single book from MariaDB and saves them to SQLite.
// pullBookItems fetches catalog items for a partnumber list from MariaDB and saves them to SQLite.
// Returns the number of items saved.
func pullBookItems(mariaDB *gorm.DB, repo *repository.PartnumberBookRepository, serverBookID int, localBookID uint) (int, error) {
func pullBookItems(mariaDB *gorm.DB, repo *repository.PartnumberBookRepository, partnumbers localdb.LocalStringList) (int, error) {
if len(partnumbers) == 0 {
return 0, nil
}
type serverItem struct {
Partnumber string `gorm:"column:partnumber"`
LotName string `gorm:"column:lot_name"`
IsPrimaryPN bool `gorm:"column:is_primary_pn"`
LotsJSON string `gorm:"column:lots_json"`
Description string `gorm:"column:description"`
}
// description column may not exist yet on older server schemas — query without it first,
// then retry with it to populate descriptions if available.
var serverItems []serverItem
err := mariaDB.Raw("SELECT partnumber, lot_name, is_primary_pn, description FROM qt_partnumber_book_items WHERE book_id = ?", serverBookID).Scan(&serverItems).Error
err := mariaDB.Raw("SELECT partnumber, lots_json, description FROM qt_partnumber_book_items WHERE partnumber IN ?", []string(partnumbers)).Scan(&serverItems).Error
if err != nil {
slog.Warn("description column not available on server, retrying without it", "server_book_id", serverBookID, "error", err)
if err2 := mariaDB.Raw("SELECT partnumber, lot_name, is_primary_pn FROM qt_partnumber_book_items WHERE book_id = ?", serverBookID).Scan(&serverItems).Error; err2 != nil {
return 0, fmt.Errorf("querying items from server: %w", err2)
}
return 0, fmt.Errorf("querying items from server: %w", err)
}
slog.Info("partnumber book items fetched from server", "server_book_id", serverBookID, "count", len(serverItems))
slog.Info("partnumber book items fetched from server", "count", len(serverItems), "requested_partnumbers", len(partnumbers))
if len(serverItems) == 0 {
slog.Warn("server returned 0 items for book — check qt_partnumber_book_items on server", "server_book_id", serverBookID)
slog.Warn("server returned 0 partnumber book items")
return 0, nil
}
localItems := make([]localdb.LocalPartnumberBookItem, 0, len(serverItems))
for _, si := range serverItems {
var lots localdb.LocalPartnumberBookLots
if err := json.Unmarshal([]byte(si.LotsJSON), &lots); err != nil {
return 0, fmt.Errorf("decode lots_json for %s: %w", si.Partnumber, err)
}
localItems = append(localItems, localdb.LocalPartnumberBookItem{
BookID: localBookID,
Partnumber: si.Partnumber,
LotName: si.LotName,
IsPrimaryPN: si.IsPrimaryPN,
LotsJSON: lots,
Description: si.Description,
})
}
@@ -125,3 +140,14 @@ func pullBookItems(mariaDB *gorm.DB, repo *repository.PartnumberBookRepository,
}
return len(localItems), nil
}
func decodeServerPartnumbers(raw string) (localdb.LocalStringList, error) {
if raw == "" {
return localdb.LocalStringList{}, nil
}
var items []string
if err := json.Unmarshal([]byte(raw), &items); err != nil {
return nil, err
}
return localdb.LocalStringList(items), nil
}