package localdb import ( "errors" "fmt" "log/slog" "strings" "time" "github.com/google/uuid" "gorm.io/gorm" ) type LocalSchemaMigration struct { ID string `gorm:"primaryKey;size:128"` Name string `gorm:"not null;size:255"` AppliedAt time.Time `gorm:"not null"` } func (LocalSchemaMigration) TableName() string { return "local_schema_migrations" } type localMigration struct { id string name string run func(tx *gorm.DB) error } var localMigrations = []localMigration{ { id: "2026_02_04_versioning_backfill", name: "Ensure configuration versioning data and current pointers", run: backfillConfigurationVersions, }, { id: "2026_02_04_is_active_backfill", name: "Ensure is_active defaults to true for existing configurations", run: backfillConfigurationIsActive, }, { id: "2026_02_06_projects_backfill", name: "Create default projects and attach existing configurations", run: backfillProjectsForConfigurations, }, { id: "2026_02_06_pricelist_backfill", name: "Attach existing configurations to latest local pricelist and recalc usage", run: backfillConfigurationPricelists, }, { id: "2026_02_06_pricelist_index_fix", name: "Use unique server_id for local pricelists and allow duplicate versions", run: fixLocalPricelistIndexes, }, { id: "2026_02_06_pricelist_source", name: "Backfill source for local pricelists and create source indexes", run: backfillLocalPricelistSource, }, } func runLocalMigrations(db *gorm.DB) error { if err := db.AutoMigrate(&LocalSchemaMigration{}); err != nil { return fmt.Errorf("migrate local schema migrations table: %w", err) } for _, migration := range localMigrations { var count int64 if err := db.Model(&LocalSchemaMigration{}).Where("id = ?", migration.id).Count(&count).Error; err != nil { return fmt.Errorf("check local migration %s: %w", migration.id, err) } if count > 0 { continue } if err := db.Transaction(func(tx *gorm.DB) error { if err := migration.run(tx); err != nil { return fmt.Errorf("run migration %s: %w", migration.id, err) } record := &LocalSchemaMigration{ ID: migration.id, Name: migration.name, AppliedAt: time.Now(), } if err := tx.Create(record).Error; err != nil { return fmt.Errorf("insert migration %s record: %w", migration.id, err) } return nil }); err != nil { return err } slog.Info("local migration applied", "id", migration.id, "name", migration.name) } return nil } func backfillConfigurationVersions(tx *gorm.DB) error { var configs []LocalConfiguration if err := tx.Find(&configs).Error; err != nil { return fmt.Errorf("load local configurations for backfill: %w", err) } for i := range configs { cfg := configs[i] var versionCount int64 if err := tx.Model(&LocalConfigurationVersion{}). Where("configuration_uuid = ?", cfg.UUID). Count(&versionCount).Error; err != nil { return fmt.Errorf("count versions for %s: %w", cfg.UUID, err) } if versionCount == 0 { snapshot, err := BuildConfigurationSnapshot(&cfg) if err != nil { return fmt.Errorf("build initial snapshot for %s: %w", cfg.UUID, err) } note := "Initial snapshot backfill (v1)" version := LocalConfigurationVersion{ ID: uuid.NewString(), ConfigurationUUID: cfg.UUID, VersionNo: 1, Data: snapshot, ChangeNote: ¬e, AppVersion: "backfill", CreatedAt: chooseNonZeroTime(cfg.CreatedAt, time.Now()), } if err := tx.Create(&version).Error; err != nil { return fmt.Errorf("create v1 backfill for %s: %w", cfg.UUID, err) } } if cfg.CurrentVersionID == nil || *cfg.CurrentVersionID == "" { var latest LocalConfigurationVersion if err := tx.Where("configuration_uuid = ?", cfg.UUID). Order("version_no DESC"). First(&latest).Error; err != nil { return fmt.Errorf("load latest version for %s: %w", cfg.UUID, err) } if err := tx.Model(&LocalConfiguration{}). Where("uuid = ?", cfg.UUID). Update("current_version_id", latest.ID).Error; err != nil { return fmt.Errorf("set current version for %s: %w", cfg.UUID, err) } } } return nil } func backfillConfigurationIsActive(tx *gorm.DB) error { return tx.Exec("UPDATE local_configurations SET is_active = 1 WHERE is_active IS NULL").Error } func backfillProjectsForConfigurations(tx *gorm.DB) error { var owners []string if err := tx.Model(&LocalConfiguration{}). Distinct("original_username"). Pluck("original_username", &owners).Error; err != nil { return fmt.Errorf("load owners for projects backfill: %w", err) } for _, owner := range owners { project, err := ensureDefaultProjectTx(tx, owner) if err != nil { return err } if err := tx.Model(&LocalConfiguration{}). Where("original_username = ? AND (project_uuid IS NULL OR project_uuid = '')", owner). Update("project_uuid", project.UUID).Error; err != nil { return fmt.Errorf("assign default project for owner %s: %w", owner, err) } } return nil } func ensureDefaultProjectTx(tx *gorm.DB, ownerUsername string) (*LocalProject, error) { var project LocalProject err := tx.Where("owner_username = ? AND is_system = ? AND name = ?", ownerUsername, true, "Без проекта"). First(&project).Error if err == nil { return &project, nil } if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, fmt.Errorf("load system project for %s: %w", ownerUsername, err) } now := time.Now() project = LocalProject{ UUID: uuid.NewString(), OwnerUsername: ownerUsername, Name: "Без проекта", IsActive: true, IsSystem: true, CreatedAt: now, UpdatedAt: now, SyncStatus: "pending", } if err := tx.Create(&project).Error; err != nil { return nil, fmt.Errorf("create system project for %s: %w", ownerUsername, err) } return &project, nil } func backfillConfigurationPricelists(tx *gorm.DB) error { var latest LocalPricelist if err := tx.Where("source = ?", "estimate").Order("created_at DESC").First(&latest).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil } return fmt.Errorf("load latest local pricelist: %w", err) } if err := tx.Model(&LocalConfiguration{}). Where("pricelist_id IS NULL"). Update("pricelist_id", latest.ServerID).Error; err != nil { return fmt.Errorf("backfill configuration pricelist_id: %w", err) } if err := tx.Model(&LocalPricelist{}).Where("1 = 1").Update("is_used", false).Error; err != nil { return fmt.Errorf("reset local pricelist usage flags: %w", err) } if err := tx.Exec(` UPDATE local_pricelists SET is_used = 1 WHERE server_id IN ( SELECT DISTINCT pricelist_id FROM local_configurations WHERE pricelist_id IS NOT NULL AND is_active = 1 ) `).Error; err != nil { return fmt.Errorf("recalculate local pricelist usage flags: %w", err) } return nil } func chooseNonZeroTime(candidate time.Time, fallback time.Time) time.Time { if candidate.IsZero() { return fallback } return candidate } func fixLocalPricelistIndexes(tx *gorm.DB) error { type indexRow struct { Name string `gorm:"column:name"` Unique int `gorm:"column:unique"` } var indexes []indexRow if err := tx.Raw("PRAGMA index_list('local_pricelists')").Scan(&indexes).Error; err != nil { return fmt.Errorf("list local_pricelists indexes: %w", err) } for _, idx := range indexes { if idx.Unique == 0 { continue } type indexInfoRow struct { Name string `gorm:"column:name"` } var info []indexInfoRow if err := tx.Raw(fmt.Sprintf("PRAGMA index_info('%s')", strings.ReplaceAll(idx.Name, "'", "''"))).Scan(&info).Error; err != nil { return fmt.Errorf("load index info for %s: %w", idx.Name, err) } if len(info) != 1 || info[0].Name != "version" { continue } quoted := strings.ReplaceAll(idx.Name, `"`, `""`) if err := tx.Exec(fmt.Sprintf(`DROP INDEX IF EXISTS "%s"`, quoted)).Error; err != nil { return fmt.Errorf("drop unique version index %s: %w", idx.Name, err) } } if err := tx.Exec(` CREATE UNIQUE INDEX IF NOT EXISTS idx_local_pricelists_server_id ON local_pricelists(server_id) `).Error; err != nil { return fmt.Errorf("ensure unique index local_pricelists(server_id): %w", err) } if err := tx.Exec(` CREATE INDEX IF NOT EXISTS idx_local_pricelists_version ON local_pricelists(version) `).Error; err != nil { return fmt.Errorf("ensure index local_pricelists(version): %w", err) } return nil } func backfillLocalPricelistSource(tx *gorm.DB) error { if err := tx.Exec(` UPDATE local_pricelists SET source = 'estimate' WHERE source IS NULL OR source = '' `).Error; err != nil { return fmt.Errorf("backfill local_pricelists.source: %w", err) } if err := tx.Exec(` CREATE INDEX IF NOT EXISTS idx_local_pricelists_source_created_at ON local_pricelists(source, created_at DESC) `).Error; err != nil { return fmt.Errorf("ensure idx_local_pricelists_source_created_at: %w", err) } return nil }