Fix database migration backfill regressions

This commit is contained in:
rishikanthc
2026-04-23 12:10:36 -07:00
parent f926509ac9
commit a5f88fb638
3 changed files with 354 additions and 13 deletions

View File

@@ -302,6 +302,46 @@ func TestSchemaUpgradeRunsVersionedBackfill(t *testing.T) {
assert.Equal(t, "large-v3", reloadedB.Parameters.Model)
}
func TestSchemaUpgradePreservesUpdatedAt(t *testing.T) {
db := openUnmigratedTestDB(t, "schema-upgrade-updated-at.db")
originalUpdatedAt := time.Date(2024, 12, 31, 12, 0, 0, 0, time.UTC)
user := models.User{
ID: 77,
Username: "preserve-updated-at",
Password: "pw",
UpdatedAt: originalUpdatedAt,
}
require.NoError(t, db.Session(&gorm.Session{SkipHooks: true}).Create(&user).Error)
require.NoError(t, recordSchemaVersion(db, 1))
require.NoError(t, db.Exec("UPDATE users SET settings_json = '' WHERE id = ?", user.ID).Error)
require.NoError(t, Migrate(db))
var reloaded models.User
require.NoError(t, db.First(&reloaded, "id = ?", user.ID).Error)
assert.True(t, reloaded.UpdatedAt.Equal(originalUpdatedAt))
}
func TestDetectLegacySchemaWithLegacySameNameTables(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "legacy-same-name.db")
createLegacyDatabase(t, dbPath, false)
db, err := Open(dbPath)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, closeDB(db)) })
require.NoError(t, db.Exec("DROP TABLE transcription_jobs").Error)
require.NoError(t, db.Exec("DROP TABLE transcription_job_executions").Error)
require.NoError(t, db.Exec("DROP TABLE multi_track_files").Error)
require.NoError(t, db.Exec("DROP TABLE llm_configs").Error)
require.NoError(t, db.Exec("DROP TABLE summary_settings").Error)
legacy, err := detectLegacySchema(db)
require.NoError(t, err)
assert.True(t, legacy)
}
func TestLegacyMigrationPreservesData(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "legacy.db")
createLegacyDatabase(t, dbPath, true)

View File

@@ -71,6 +71,66 @@ func detectLegacySchema(db *gorm.DB) (bool, error) {
}
}
sameNameTables := []string{
"users",
"api_keys",
"refresh_tokens",
"transcription_profiles",
"speaker_mappings",
"summary_templates",
"summaries",
"notes",
"chat_sessions",
"chat_messages",
}
for _, table := range sameNameTables {
if !db.Migrator().HasTable(table) {
continue
}
legacyLike, err := isLegacySameNameTable(db, table)
if err != nil {
return false, err
}
if legacyLike {
return true, nil
}
}
return false, nil
}
func isLegacySameNameTable(db *gorm.DB, table string) (bool, error) {
columns, err := db.Migrator().ColumnTypes(table)
if err != nil {
return false, fmt.Errorf("inspect columns for %s: %w", table, err)
}
columnNames := make(map[string]struct{}, len(columns))
for _, column := range columns {
columnNames[column.Name()] = struct{}{}
}
requiredCurrentColumns := map[string][]string{
"users": {"settings_json", "password_hash"},
"api_keys": {"key_hash", "metadata_json"},
"refresh_tokens": {"token_hash", "revoked_at"},
"transcription_profiles": {"config_json", "user_id"},
"speaker_mappings": {"display_name", "user_id", "transcription_id"},
"summary_templates": {"config_json", "user_id"},
"summaries": {"model_name", "user_id"},
"notes": {"start_ms", "end_ms", "metadata_json", "user_id"},
"chat_sessions": {"system_prompt", "user_id"},
"chat_messages": {"chat_session_id", "user_id"},
}
required := requiredCurrentColumns[table]
if len(required) == 0 {
return false, nil
}
for _, column := range required {
if _, ok := columnNames[column]; !ok {
return true, nil
}
}
return false, nil
}

View File

@@ -2,6 +2,7 @@ package database
import (
"fmt"
"time"
"scriberr/internal/models"
@@ -40,44 +41,284 @@ func migrateStepV1ToV2(tx *gorm.DB) error {
}
func backfillCompatibilityColumns(tx *gorm.DB) error {
if err := resaveAll(tx, &[]models.User{}); err != nil {
if err := backfillUsers(tx); err != nil {
return err
}
if err := resaveAll(tx, &[]models.APIKey{}); err != nil {
if err := backfillAPIKeys(tx); err != nil {
return err
}
if err := resaveAll(tx, &[]models.TranscriptionProfile{}); err != nil {
if err := backfillTranscriptionProfiles(tx); err != nil {
return err
}
if err := resaveAll(tx, &[]models.TranscriptionJob{}); err != nil {
if err := backfillTranscriptionJobs(tx); err != nil {
return err
}
if err := resaveAll(tx, &[]models.TranscriptionJobExecution{}); err != nil {
if err := backfillTranscriptionExecutions(tx); err != nil {
return err
}
if err := resaveAll(tx, &[]models.MultiTrackFile{}); err != nil {
if err := backfillMultiTrackFiles(tx); err != nil {
return err
}
if err := resaveAll(tx, &[]models.SummaryTemplate{}); err != nil {
if err := backfillSummaryTemplates(tx); err != nil {
return err
}
if err := resaveAll(tx, &[]models.Note{}); err != nil {
if err := backfillNotes(tx); err != nil {
return err
}
if err := resaveAll(tx, &[]models.LLMConfig{}); err != nil {
if err := backfillLLMConfigs(tx); err != nil {
return err
}
return nil
}
func resaveAll[T any](tx *gorm.DB, rows *[]T) error {
if err := tx.Find(rows).Error; err != nil {
func backfillUsers(tx *gorm.DB) error {
var rows []models.User
if err := tx.Find(&rows).Error; err != nil {
return err
}
for _, row := range *rows {
if err := tx.Save(&row).Error; err != nil {
for _, row := range rows {
if err := row.BeforeSave(tx); err != nil {
return err
}
updates := map[string]any{
"settings_json": row.SettingsJSON,
}
if err := withPreservedUpdatedAt(tx.Model(&models.User{}).Where("id = ?", row.ID), updates, row.UpdatedAt).
Updates(updates).Error; err != nil {
return err
}
}
return nil
}
func backfillAPIKeys(tx *gorm.DB) error {
var rows []models.APIKey
if err := tx.Find(&rows).Error; err != nil {
return err
}
for _, row := range rows {
if err := row.BeforeSave(tx); err != nil {
return err
}
updates := map[string]any{
"key_hash": row.KeyHash,
"key_prefix": row.KeyPrefix,
"metadata_json": row.MetadataJSON,
}
if err := withPreservedUpdatedAt(tx.Model(&models.APIKey{}).Where("id = ?", row.ID), updates, row.CreatedAt).
Updates(updates).Error; err != nil {
return err
}
}
return nil
}
func backfillTranscriptionProfiles(tx *gorm.DB) error {
var rows []models.TranscriptionProfile
if err := tx.Find(&rows).Error; err != nil {
return err
}
for _, row := range rows {
if err := row.BeforeSave(tx); err != nil {
return err
}
updates := map[string]any{
"user_id": row.UserID,
"model_name": row.ModelName,
"model_family": row.ModelFamily,
"language": row.Language,
"diarization_enabled": row.DiarizationEnabled,
"device": row.Device,
"compute_type": row.ComputeType,
"config_json": row.ConfigJSON,
"is_default": row.IsDefault,
}
if err := withPreservedUpdatedAt(tx.Model(&models.TranscriptionProfile{}).Where("id = ?", row.ID), updates, row.UpdatedAt).
Updates(updates).Error; err != nil {
return err
}
}
return nil
}
func backfillTranscriptionJobs(tx *gorm.DB) error {
var rows []models.TranscriptionJob
if err := tx.Find(&rows).Error; err != nil {
return err
}
for _, row := range rows {
if err := row.BeforeSave(tx); err != nil {
return err
}
updates := map[string]any{
"user_id": row.UserID,
"source_file_path": row.AudioPath,
"source_file_name": row.SourceFileName,
"source_file_hash": row.SourceFileHash,
"source_duration_ms": row.SourceDurationMs,
"language": row.Language,
"transcript_text": row.Transcript,
"output_json_path": row.OutputJSONPath,
"output_srt_path": row.OutputSRTPath,
"output_vtt_path": row.OutputVTTPath,
"latest_execution_id": row.LatestExecutionID,
"last_error": row.ErrorMessage,
"metadata_json": row.MetadataJSON,
"completed_at": row.CompletedAt,
"status": row.Status,
"title": row.Title,
}
if err := withPreservedUpdatedAt(tx.Model(&models.TranscriptionJob{}).Where("id = ?", row.ID), updates, row.UpdatedAt).
Updates(updates).Error; err != nil {
return err
}
}
return nil
}
func backfillTranscriptionExecutions(tx *gorm.DB) error {
var rows []models.TranscriptionJobExecution
if err := tx.Find(&rows).Error; err != nil {
return err
}
for _, row := range rows {
if err := row.BeforeSave(tx); err != nil {
return err
}
updates := map[string]any{
"user_id": row.UserID,
"execution_number": row.ExecutionNumber,
"trigger_type": row.TriggerType,
"status": row.Status,
"profile_id": row.ProfileID,
"model_name": row.ModelName,
"model_family": row.ModelFamily,
"provider": row.Provider,
"device": row.Device,
"compute_type": row.ComputeType,
"request_json": row.RequestJSON,
"config_json": row.ConfigJSON,
"started_at": row.StartedAt,
"completed_at": row.CompletedAt,
"failed_at": row.FailedAt,
"error_message": row.ErrorMessage,
"logs_path": row.LogsPath,
"merged_file_path": row.MergedFilePath,
"output_json_path": row.OutputJSONPath,
}
if err := withPreservedUpdatedAt(tx.Model(&models.TranscriptionJobExecution{}).Where("id = ?", row.ID), updates, row.CreatedAt).
Updates(updates).Error; err != nil {
return err
}
}
return nil
}
func backfillMultiTrackFiles(tx *gorm.DB) error {
var rows []models.MultiTrackFile
if err := tx.Find(&rows).Error; err != nil {
return err
}
for _, row := range rows {
if err := row.BeforeSave(tx); err != nil {
return err
}
updates := map[string]any{
"user_id": row.UserID,
"file_name": row.FileName,
"file_path": row.FilePath,
"track_index": row.TrackIndex,
"label": row.Label,
"speaker_hint": row.SpeakerHint,
"metadata_json": row.MetadataJSON,
}
if err := withPreservedUpdatedAt(tx.Model(&models.MultiTrackFile{}).Where("id = ?", row.ID), updates, row.CreatedAt).
Updates(updates).Error; err != nil {
return err
}
}
return nil
}
func backfillSummaryTemplates(tx *gorm.DB) error {
var rows []models.SummaryTemplate
if err := tx.Find(&rows).Error; err != nil {
return err
}
for _, row := range rows {
if err := row.BeforeSave(tx); err != nil {
return err
}
updates := map[string]any{
"user_id": row.UserID,
"name": row.Name,
"prompt": row.Prompt,
"description": row.Description,
"config_json": row.ConfigJSON,
"is_default": row.IsDefault,
}
if err := withPreservedUpdatedAt(tx.Model(&models.SummaryTemplate{}).Where("id = ?", row.ID), updates, row.UpdatedAt).
Updates(updates).Error; err != nil {
return err
}
}
return nil
}
func backfillNotes(tx *gorm.DB) error {
var rows []models.Note
if err := tx.Find(&rows).Error; err != nil {
return err
}
for _, row := range rows {
if err := row.BeforeSave(tx); err != nil {
return err
}
updates := map[string]any{
"user_id": row.UserID,
"transcription_id": row.TranscriptionID,
"content": row.Content,
"start_ms": row.StartMS,
"end_ms": row.EndMS,
"metadata_json": row.MetadataJSON,
}
if err := withPreservedUpdatedAt(tx.Model(&models.Note{}).Where("id = ?", row.ID), updates, row.UpdatedAt).
Updates(updates).Error; err != nil {
return err
}
}
return nil
}
func backfillLLMConfigs(tx *gorm.DB) error {
var rows []models.LLMConfig
if err := tx.Find(&rows).Error; err != nil {
return err
}
for _, row := range rows {
if err := row.BeforeSave(tx); err != nil {
return err
}
updates := map[string]any{
"user_id": row.UserID,
"name": row.Name,
"provider": row.Provider,
"model_name": row.ModelName,
"base_url": row.BaseURL,
"config_json": row.ConfigJSON,
"is_default": row.IsDefault,
}
if err := withPreservedUpdatedAt(tx.Model(&models.LLMConfig{}).Where("id = ?", row.ID), updates, row.UpdatedAt).
Updates(updates).Error; err != nil {
return err
}
}
return nil
}
func withPreservedUpdatedAt(db *gorm.DB, updates map[string]any, updatedAt time.Time) *gorm.DB {
if !updatedAt.IsZero() {
updates["updated_at"] = updatedAt
}
return db.Set("gorm:update_track_time", false)
}