added database migration

This commit is contained in:
Linrador 2026-06-23 15:03:27 +02:00
parent 1800d98eff
commit b55705c582
10 changed files with 621 additions and 34 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -117,10 +117,6 @@ func embeddedPoseModelPath() (string, error) {
return dstPath, nil return dstPath, nil
} }
func embeddedDotEnvBytes() ([]byte, error) {
return nil, os.ErrNotExist
}
func embeddedTLSCertBytes() ([]byte, error) { func embeddedTLSCertBytes() ([]byte, error) {
return embeddedMLFiles.ReadFile("recorder-cert.pem") return embeddedMLFiles.ReadFile("recorder-cert.pem")
} }

View File

@ -303,6 +303,11 @@ func (s *ModelStore) init() error {
_ = db.Close() _ = db.Close()
return err return err
} }
} else {
if err := s.ensurePostgresSchema(); err != nil {
_ = db.Close()
return err
}
} }
if err := s.normalizeNameOnlyChaturbate(); err != nil { if err := s.normalizeNameOnlyChaturbate(); err != nil {
@ -383,6 +388,56 @@ func (s *ModelStore) ensureSQLiteSchema() error {
return nil return nil
} }
func (s *ModelStore) ensurePostgresSchema() error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS models (
id TEXT PRIMARY KEY,
input TEXT NOT NULL DEFAULT '',
is_url BOOLEAN NOT NULL DEFAULT false,
host TEXT DEFAULT '',
path TEXT DEFAULT '',
model_key TEXT NOT NULL DEFAULT '',
tags TEXT DEFAULT '',
last_stream TIMESTAMPTZ NULL,
last_seen_online BOOLEAN NULL,
last_seen_online_at TIMESTAMPTZ NULL,
cb_online_json TEXT DEFAULT '',
cb_online_fetched_at TIMESTAMPTZ NULL,
cb_online_last_error TEXT DEFAULT '',
profile_image_url TEXT DEFAULT '',
profile_image_mime TEXT DEFAULT '',
profile_image_blob BYTEA NULL,
profile_image_updated_at TIMESTAMPTZ NULL,
room_status TEXT DEFAULT '',
is_online BOOLEAN NOT NULL DEFAULT false,
chat_room_url TEXT DEFAULT '',
image_url TEXT DEFAULT '',
last_online_at TIMESTAMPTZ NULL,
last_offline_at TIMESTAMPTZ NULL,
last_room_sync_at TIMESTAMPTZ NULL,
biocontext_json TEXT DEFAULT '',
biocontext_fetched_at TIMESTAMPTZ NULL,
watching BOOLEAN NOT NULL DEFAULT false,
favorite BOOLEAN NOT NULL DEFAULT false,
hot BOOLEAN NOT NULL DEFAULT false,
keep BOOLEAN NOT NULL DEFAULT false,
liked BOOLEAN NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);`,
`CREATE INDEX IF NOT EXISTS idx_models_host_model_key ON models(host, model_key);`,
`CREATE INDEX IF NOT EXISTS idx_models_model_key ON models(model_key);`,
`CREATE INDEX IF NOT EXISTS idx_models_watching_host ON models(watching, host);`,
`CREATE INDEX IF NOT EXISTS idx_models_updated_at ON models(updated_at);`,
}
for _, stmt := range stmts {
if _, err := s.db.Exec(stmt); err != nil {
return err
}
}
return nil
}
func (s *ModelStore) SetChaturbateRoomState( func (s *ModelStore) SetChaturbateRoomState(
host string, host string,
modelKey string, modelKey string,

View File

@ -51,3 +51,68 @@ func TestModelStoreSQLiteCreatesSchemaAndPersists(t *testing.T) {
t.Fatal("expected meta updatedAt") t.Fatal("expected meta updatedAt")
} }
} }
func TestMigrateModelStoreDataSQLiteToSQLiteReplacesTarget(t *testing.T) {
tmp := t.TempDir()
sourcePath := filepath.Join(tmp, "source.sqlite")
targetPath := filepath.Join(tmp, "target.sqlite")
sourceConfig := ModelStoreConfig{Type: databaseTypeSQLite, DSN: sourcePath, SQLitePath: sourcePath}
targetConfig := ModelStoreConfig{Type: databaseTypeSQLite, DSN: targetPath, SQLitePath: targetPath}
source := NewModelStore(sourceConfig)
defer source.Close()
if err := source.Load(); err != nil {
t.Fatalf("load source: %v", err)
}
if _, err := source.EnsureByHostModelKey("chaturbate.com", "alice"); err != nil {
t.Fatalf("ensure source model: %v", err)
}
if err := source.SetProfileImage("chaturbate.com", "alice", "https://example.test/a.jpg", "image/jpeg", []byte{1, 2, 3, 4}, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
t.Fatalf("set profile image: %v", err)
}
if err := source.SetBioContext("chaturbate.com", "alice", `{"ok":true}`, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
t.Fatalf("set bio context: %v", err)
}
target := NewModelStore(targetConfig)
if err := target.Load(); err != nil {
t.Fatalf("load target: %v", err)
}
if _, err := target.EnsureByHostModelKey("chaturbate.com", "stale"); err != nil {
t.Fatalf("ensure stale target model: %v", err)
}
_ = target.Close()
_ = source.Close()
count, err := migrateModelStoreData(sourceConfig, targetConfig)
if err != nil {
t.Fatalf("migrate sqlite to sqlite: %v", err)
}
if count != 1 {
t.Fatalf("expected 1 migrated row, got %d", count)
}
migrated := NewModelStore(targetConfig)
defer migrated.Close()
if err := migrated.Load(); err != nil {
t.Fatalf("load migrated target: %v", err)
}
models := migrated.List()
if len(models) != 1 {
t.Fatalf("expected target replacement with 1 row, got %d", len(models))
}
if models[0].ModelKey != "alice" {
t.Fatalf("unexpected migrated model: %q", models[0].ModelKey)
}
if models[0].ProfileImageCached == "" {
t.Fatal("expected migrated profile image blob marker")
}
bio, _, ok, err := migrated.GetBioContext("chaturbate.com", "alice")
if err != nil {
t.Fatalf("get migrated bio context: %v", err)
}
if !ok || bio != `{"ok":true}` {
t.Fatalf("unexpected migrated bio context ok=%v bio=%q", ok, bio)
}
}

View File

@ -56,6 +56,7 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
api.HandleFunc("/api/settings/cleanup", settingsCleanupHandler) api.HandleFunc("/api/settings/cleanup", settingsCleanupHandler)
api.HandleFunc("/api/settings/database/backup", settingsDatabaseBackupHandler) api.HandleFunc("/api/settings/database/backup", settingsDatabaseBackupHandler)
api.HandleFunc("/api/settings/database/restore", settingsDatabaseRestoreHandler) api.HandleFunc("/api/settings/database/restore", settingsDatabaseRestoreHandler)
api.HandleFunc("/api/settings/database/migrate", settingsDatabaseMigrateHandler)
api.HandleFunc("/api/pending-autostart", handlePendingAutoStart(auth)) api.HandleFunc("/api/pending-autostart", handlePendingAutoStart(auth))

View File

@ -2,7 +2,6 @@
package main package main
import ( import (
"bytes"
"context" "context"
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
@ -807,26 +806,6 @@ func upsertEnv(env []string, key string, value string) []string {
return out return out
} }
func loadEmbeddedDotEnv() {
b, err := embeddedDotEnvBytes()
if err != nil {
appLogln(" Keine eingebettete .env gefunden.")
return
}
envMap, err := godotenv.Parse(bytes.NewReader(b))
if err != nil {
appLogln("⚠️ Eingebettete .env konnte nicht gelesen werden:", err)
return
}
for k, v := range envMap {
setEnvIfMissing(k, v)
}
appLogln("✅ Eingebettete .env geladen")
}
func loadExternalDotEnvNextToExe() { func loadExternalDotEnvNextToExe() {
exePath, err := os.Executable() exePath, err := os.Executable()
if err != nil { if err != nil {
@ -840,17 +819,16 @@ func loadExternalDotEnvNextToExe() {
envPath := filepath.Join(exeDir, ".env") envPath := filepath.Join(exeDir, ".env")
if err := godotenv.Overload(envPath); err == nil { if err := godotenv.Overload(envPath); err == nil {
appLogln("✅ Externe .env geladen und überschreibt embedded/env:", envPath) appLogln("✅ Externe .env geladen:", envPath)
return return
} }
if err := godotenv.Overload(); err == nil { if err := godotenv.Overload(); err == nil {
appLogln("✅ Externe .env geladen aus aktuellem Arbeitsverzeichnis und überschreibt embedded/env") appLogln("✅ Externe .env geladen aus aktuellem Arbeitsverzeichnis")
} }
} }
func loadAppEnv() { func loadAppEnv() {
loadEmbeddedDotEnv()
loadExternalDotEnvNextToExe() loadExternalDotEnvNextToExe()
} }

View File

@ -0,0 +1,405 @@
package main
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
"path/filepath"
"strconv"
"strings"
"time"
)
type databaseMigrationRequest struct {
DatabaseType string `json:"databaseType"`
SQLitePath string `json:"sqlitePath"`
DatabaseURL string `json:"databaseUrl"`
DBPassword string `json:"dbPassword,omitempty"`
}
type modelMigrationColumn struct {
name string
kind string
notNull bool
}
const (
modelMigrationText = "text"
modelMigrationBool = "bool"
modelMigrationTime = "time"
modelMigrationBlob = "blob"
)
var modelMigrationColumns = []modelMigrationColumn{
{name: "id", kind: modelMigrationText, notNull: true},
{name: "input", kind: modelMigrationText, notNull: true},
{name: "is_url", kind: modelMigrationBool, notNull: true},
{name: "host", kind: modelMigrationText},
{name: "path", kind: modelMigrationText},
{name: "model_key", kind: modelMigrationText, notNull: true},
{name: "tags", kind: modelMigrationText},
{name: "last_stream", kind: modelMigrationTime},
{name: "last_seen_online", kind: modelMigrationBool},
{name: "last_seen_online_at", kind: modelMigrationTime},
{name: "cb_online_json", kind: modelMigrationText},
{name: "cb_online_fetched_at", kind: modelMigrationTime},
{name: "cb_online_last_error", kind: modelMigrationText},
{name: "profile_image_url", kind: modelMigrationText},
{name: "profile_image_mime", kind: modelMigrationText},
{name: "profile_image_blob", kind: modelMigrationBlob},
{name: "profile_image_updated_at", kind: modelMigrationTime},
{name: "room_status", kind: modelMigrationText},
{name: "is_online", kind: modelMigrationBool, notNull: true},
{name: "chat_room_url", kind: modelMigrationText},
{name: "image_url", kind: modelMigrationText},
{name: "last_online_at", kind: modelMigrationTime},
{name: "last_offline_at", kind: modelMigrationTime},
{name: "last_room_sync_at", kind: modelMigrationTime},
{name: "biocontext_json", kind: modelMigrationText},
{name: "biocontext_fetched_at", kind: modelMigrationTime},
{name: "watching", kind: modelMigrationBool, notNull: true},
{name: "favorite", kind: modelMigrationBool, notNull: true},
{name: "hot", kind: modelMigrationBool, notNull: true},
{name: "keep", kind: modelMigrationBool, notNull: true},
{name: "liked", kind: modelMigrationBool},
{name: "created_at", kind: modelMigrationTime, notNull: true},
{name: "updated_at", kind: modelMigrationTime, notNull: true},
}
type dbNullBool struct {
sql.NullBool
}
func (b *dbNullBool) Scan(value any) error {
if value == nil {
b.Valid = false
b.Bool = false
return nil
}
switch v := value.(type) {
case bool:
b.Valid = true
b.Bool = v
return nil
case int64:
b.Valid = true
b.Bool = v != 0
return nil
case int:
b.Valid = true
b.Bool = v != 0
return nil
case []byte:
return b.scanString(string(v))
case string:
return b.scanString(v)
default:
var raw sql.NullBool
if err := raw.Scan(value); err != nil {
return err
}
b.NullBool = raw
return nil
}
}
func (b *dbNullBool) scanString(value string) error {
value = strings.TrimSpace(strings.ToLower(value))
if value == "" {
b.Valid = false
b.Bool = false
return nil
}
switch value {
case "1", "t", "true", "yes", "y", "on":
b.Valid = true
b.Bool = true
return nil
case "0", "f", "false", "no", "n", "off":
b.Valid = true
b.Bool = false
return nil
default:
parsed, err := strconv.ParseBool(value)
if err != nil {
return fmt.Errorf("invalid bool value %q", value)
}
b.Valid = true
b.Bool = parsed
return nil
}
}
type modelMigrationValue struct {
column modelMigrationColumn
text sql.NullString
bool dbNullBool
tm dbNullTime
blob []byte
}
func (v *modelMigrationValue) dest() any {
switch v.column.kind {
case modelMigrationBool:
return &v.bool
case modelMigrationTime:
return &v.tm
case modelMigrationBlob:
return &v.blob
default:
return &v.text
}
}
func (v modelMigrationValue) arg(now time.Time) any {
switch v.column.kind {
case modelMigrationBool:
if !v.bool.Valid {
if v.column.notNull {
return false
}
return nil
}
return v.bool.Bool
case modelMigrationTime:
if !v.tm.Valid || v.tm.Time.IsZero() {
if v.column.notNull {
return now
}
return nil
}
return v.tm.Time.UTC()
case modelMigrationBlob:
if len(v.blob) == 0 {
return nil
}
return v.blob
default:
if !v.text.Valid {
if v.column.notNull {
return ""
}
return nil
}
return v.text.String
}
}
func (v modelMigrationValue) textValue() string {
if !v.text.Valid {
return ""
}
return strings.TrimSpace(v.text.String)
}
func settingsDatabaseMigrateHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed)
return
}
var req databaseMigrationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid json: "+err.Error(), http.StatusBadRequest)
return
}
current := getSettings()
target, err := databaseMigrationTargetSettings(current, req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
sourceConfig, err := buildModelStoreConfigFromRecorderSettings(current)
if err != nil {
http.Error(w, "Quell-Datenbank ungueltig: "+err.Error(), http.StatusBadRequest)
return
}
targetConfig, err := buildModelStoreConfigFromRecorderSettings(target)
if err != nil {
http.Error(w, "Ziel-Datenbank ungueltig: "+err.Error(), http.StatusBadRequest)
return
}
count, err := migrateModelStoreData(sourceConfig, targetConfig)
if err != nil {
http.Error(w, "Migration fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
refreshedStore := NewModelStore(targetConfig)
if err := refreshedStore.Load(); err != nil {
http.Error(w, "Ziel-Datenbank konnte nach Migration nicht geoeffnet werden: "+err.Error(), http.StatusInternalServerError)
return
}
settingsMu.Lock()
settings = target
settingsMu.Unlock()
saveSettingsToDisk()
setModelStore(refreshedStore)
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"rows": count,
"sourceType": sourceConfig.Type,
"targetType": targetConfig.Type,
"targetSQLite": targetConfig.SQLitePath,
"targetDatabase": sanitizeDSNForLog(targetConfig.DSN),
})
}
func databaseMigrationTargetSettings(current RecorderSettings, req databaseMigrationRequest) (RecorderSettings, error) {
next := current
next.DatabaseType = normalizeDatabaseType(req.DatabaseType)
next.SQLitePath = normalizeSQLitePath(req.SQLitePath)
next.DatabaseURL = strings.TrimSpace(req.DatabaseURL)
sanitizedURL, pwFromURL := stripPasswordFromPostgresURL(next.DatabaseURL)
pwFromURL = strings.TrimSpace(pwFromURL)
if pwFromURL == "****" {
pwFromURL = ""
}
if sanitizedURL != "" {
next.DatabaseURL = sanitizedURL
}
plainPW := strings.TrimSpace(req.DBPassword)
if plainPW == "" {
plainPW = pwFromURL
}
if plainPW != "" {
enc, err := encryptSettingString(plainPW)
if err != nil {
return RecorderSettings{}, appErrorf("konnte DB-Passwort nicht verschluesseln: %w", err)
}
next.EncryptedDBPassword = enc
}
if next.DatabaseType == databaseTypePostgres && strings.TrimSpace(next.DatabaseURL) == "" {
return RecorderSettings{}, errors.New("Database URL fehlt")
}
if next.DatabaseType == databaseTypeSQLite && strings.TrimSpace(next.SQLitePath) == "" {
return RecorderSettings{}, errors.New("SQLite-Datei fehlt")
}
return next, nil
}
func migrateModelStoreData(sourceConfig, targetConfig ModelStoreConfig) (int64, error) {
if sameModelStoreConfig(sourceConfig, targetConfig) {
return 0, errors.New("Quelle und Ziel sind identisch")
}
source := NewModelStore(sourceConfig)
if err := source.Load(); err != nil {
return 0, appErrorf("Quelle konnte nicht geoeffnet werden: %w", err)
}
defer source.Close()
target := NewModelStore(targetConfig)
if err := target.Load(); err != nil {
return 0, appErrorf("Ziel konnte nicht geoeffnet werden: %w", err)
}
defer target.Close()
return migrateModelsTable(source.db, target.db)
}
func sameModelStoreConfig(a, b ModelStoreConfig) bool {
a.Type = normalizeDatabaseType(a.Type)
b.Type = normalizeDatabaseType(b.Type)
if a.Type != b.Type {
return false
}
if a.Type == databaseTypeSQLite {
return filepath.Clean(strings.TrimSpace(a.SQLitePath)) == filepath.Clean(strings.TrimSpace(b.SQLitePath))
}
return strings.TrimSpace(a.DSN) == strings.TrimSpace(b.DSN)
}
func migrateModelsTable(sourceDB, targetDB *sql.DB) (int64, error) {
columnNames := make([]string, 0, len(modelMigrationColumns))
for _, column := range modelMigrationColumns {
columnNames = append(columnNames, column.name)
}
rows, err := sourceDB.Query("SELECT " + strings.Join(columnNames, ",") + " FROM models ORDER BY updated_at ASC, id ASC;")
if err != nil {
return 0, err
}
defer rows.Close()
tx, err := targetDB.Begin()
if err != nil {
return 0, err
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.Exec(`DELETE FROM models;`); err != nil {
return 0, err
}
insertSQL := modelMigrationInsertSQL(columnNames)
stmt, err := tx.Prepare(insertSQL)
if err != nil {
return 0, err
}
defer stmt.Close()
var count int64
now := time.Now().UTC()
for rows.Next() {
values := make([]modelMigrationValue, len(modelMigrationColumns))
dests := make([]any, len(modelMigrationColumns))
for i, column := range modelMigrationColumns {
values[i] = modelMigrationValue{column: column}
dests[i] = values[i].dest()
}
if err := rows.Scan(dests...); err != nil {
return count, err
}
if values[0].textValue() == "" {
continue
}
args := make([]any, len(values))
for i, value := range values {
args[i] = value.arg(now)
}
if _, err := stmt.Exec(args...); err != nil {
return count, err
}
count++
}
if err := rows.Err(); err != nil {
return count, err
}
if err := tx.Commit(); err != nil {
return count, err
}
return count, nil
}
func modelMigrationInsertSQL(columnNames []string) string {
placeholders := make([]string, 0, len(columnNames))
updates := make([]string, 0, len(columnNames)-1)
for i, column := range columnNames {
placeholders = append(placeholders, fmt.Sprintf("$%d", i+1))
if column != "id" {
updates = append(updates, column+"=EXCLUDED."+column)
}
}
return fmt.Sprintf(
"INSERT INTO models (%s) VALUES (%s) ON CONFLICT(id) DO UPDATE SET %s;",
strings.Join(columnNames, ","),
strings.Join(placeholders, ","),
strings.Join(updates, ","),
)
}

View File

@ -547,6 +547,10 @@ function normalizeSqlitePath(value: unknown) {
return path || 'nsfwapp.sqlite' return path || 'nsfwapp.sqlite'
} }
function databaseTypeLabel(value: 'sqlite' | 'postgres') {
return value === 'sqlite' ? 'SQLite' : 'Postgres'
}
function formatDatabaseBackupBytes(bytes: unknown) { function formatDatabaseBackupBytes(bytes: unknown) {
const n = Number(bytes) const n = Number(bytes)
if (!Number.isFinite(n) || n <= 0) return '' if (!Number.isFinite(n) || n <= 0) return ''
@ -703,6 +707,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
const [pendingDbPassword, setPendingDbPassword] = useState('') // wird nur beim Speichern gesendet const [pendingDbPassword, setPendingDbPassword] = useState('') // wird nur beim Speichern gesendet
const [dbBackupBusy, setDbBackupBusy] = useState(false) const [dbBackupBusy, setDbBackupBusy] = useState(false)
const [dbRestoreBusy, setDbRestoreBusy] = useState(false) const [dbRestoreBusy, setDbRestoreBusy] = useState(false)
const [dbMigrationBusy, setDbMigrationBusy] = useState(false)
const [dbMaintenanceMsg, setDbMaintenanceMsg] = useState<string | null>(null) const [dbMaintenanceMsg, setDbMaintenanceMsg] = useState<string | null>(null)
const [dbMaintenanceErr, setDbMaintenanceErr] = useState<string | null>(null) const [dbMaintenanceErr, setDbMaintenanceErr] = useState<string | null>(null)
const now = Date.now() const now = Date.now()
@ -711,13 +716,17 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
const currentDatabaseType = normalizeDatabaseType((value as any).databaseType) const currentDatabaseType = normalizeDatabaseType((value as any).databaseType)
const currentSqlitePath = normalizeSqlitePath((value as any).sqlitePath) const currentSqlitePath = normalizeSqlitePath((value as any).sqlitePath)
const currentDatabaseUrl = String((value as any).databaseUrl ?? '').trim() const currentDatabaseUrl = String((value as any).databaseUrl ?? '').trim()
const databaseSettingsDirty = const databaseLocationDirty =
currentDatabaseType !== loadedDatabaseType || currentDatabaseType !== loadedDatabaseType ||
currentSqlitePath !== loadedSqlitePath || currentSqlitePath !== loadedSqlitePath ||
currentDatabaseUrl !== loadedDatabaseUrl || currentDatabaseUrl !== loadedDatabaseUrl
Boolean((pendingDbPassword || '').trim()) const databaseSettingsDirty =
const databaseConfigured = currentDatabaseType === 'sqlite' ? Boolean(loadedSqlitePath) : Boolean(loadedDatabaseUrl) databaseLocationDirty || Boolean((pendingDbPassword || '').trim())
const dbMaintenanceBusy = dbBackupBusy || dbRestoreBusy const savedDatabaseConfigured = loadedDatabaseType === 'sqlite' ? Boolean(loadedSqlitePath) : Boolean(loadedDatabaseUrl)
const targetDatabaseConfigured = currentDatabaseType === 'sqlite' ? Boolean(currentSqlitePath) : Boolean(currentDatabaseUrl)
const databaseConfigured = savedDatabaseConfigured
const canMigrateDatabase = savedDatabaseConfigured && targetDatabaseConfigured && databaseLocationDirty
const dbMaintenanceBusy = dbBackupBusy || dbRestoreBusy || dbMigrationBusy
const [authStatus, setAuthStatus] = useState<AuthSecurityStatus | null>(null) const [authStatus, setAuthStatus] = useState<AuthSecurityStatus | null>(null)
const [securityBusy, setSecurityBusy] = useState(false) const [securityBusy, setSecurityBusy] = useState(false)
@ -1362,6 +1371,74 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
} }
} }
async function migrateDatabase() {
setDbMaintenanceMsg(null)
setDbMaintenanceErr(null)
const databaseType = currentDatabaseType
const sqlitePath = currentSqlitePath
const databaseUrl = currentDatabaseUrl
if (!savedDatabaseConfigured) {
setDbMaintenanceErr('Bitte zuerst eine Quell-Datenbank speichern.')
return
}
if (!targetDatabaseConfigured) {
setDbMaintenanceErr('Bitte zuerst die Ziel-Datenbank angeben.')
return
}
if (!databaseLocationDirty) {
setDbMaintenanceErr('Quelle und Ziel sind identisch.')
return
}
const ok = window.confirm(
`Aktive Datenbank von ${databaseTypeLabel(loadedDatabaseType)} nach ${databaseTypeLabel(databaseType)} migrieren?\n\n` +
'Die Ziel-Datenbank wird mit den aktuellen Model-Daten ueberschrieben. Danach wird die App auf die Ziel-Datenbank umgestellt.'
)
if (!ok) return
setDbMigrationBusy(true)
try {
const res = await fetch('/api/settings/database/migrate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
body: JSON.stringify({
databaseType,
sqlitePath,
databaseUrl,
dbPassword: pendingDbPassword || undefined,
}),
})
if (!res.ok) {
const t = await res.text().catch(() => '')
throw new Error(t || `HTTP ${res.status}`)
}
const data = await res.json().catch(() => null)
const rows = Number(data?.rows ?? 0)
setDbMaintenanceMsg(`Migration abgeschlossen (${rows} Model${rows === 1 ? '' : 's'}).`)
setLoadedDatabaseType(databaseType)
setLoadedSqlitePath(sqlitePath)
setLoadedDatabaseUrl(databaseUrl)
setPendingDbPassword('')
window.dispatchEvent(new CustomEvent('recorder-settings-updated'))
window.dispatchEvent(new Event('models-changed'))
window.dispatchEvent(
new CustomEvent('models-db-changed', {
detail: { databaseType, sqlitePath, databaseUrl, migrated: true },
})
)
} catch (e: any) {
setDbMaintenanceErr(e?.message ?? String(e))
} finally {
setDbMigrationBusy(false)
}
}
async function save() { async function save() {
setErr(null) setErr(null)
setMsg(null) setMsg(null)
@ -2269,12 +2346,22 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
</div> </div>
{databaseSettingsDirty ? ( {databaseSettingsDirty ? (
<div className="mt-2 text-xs text-amber-700 dark:text-amber-200"> <div className="mt-2 text-xs text-amber-700 dark:text-amber-200">
Die Datenbank-Einstellungen wurden geaendert. Bitte erst speichern. Backup/Restore nutzen die gespeicherte Datenbank. Migration nutzt diese geaenderten Werte als Ziel.
</div> </div>
) : null} ) : null}
</div> </div>
<div className="flex shrink-0 flex-wrap gap-2"> <div className="flex shrink-0 flex-wrap gap-2">
<Button
variant="secondary"
color="blue"
isLoading={dbMigrationBusy}
disabled={saving || dbMaintenanceBusy || !canMigrateDatabase}
onClick={() => void migrateDatabase()}
>
Migrieren
</Button>
<Button <Button
variant="secondary" variant="secondary"
color="emerald" color="emerald"