package handlers import ( "bufio" "context" "database/sql" "encoding/json" "fmt" "io" "log" "strings" "armature/database" ) // coreTableOrder lists tables for backup in FK-safe import order. // Ephemeral tables (node_registry, ws_tickets, rate_limit_counters, // user_presence, oidc_auth_state, refresh_tokens) are excluded. var coreTableOrder = []string{ "schema_migrations", "users", "teams", "team_members", "groups", "group_members", "platform_policies", "global_settings", "packages", "package_user_settings", "package_team_settings", "extension_permissions", "ext_data_tables", "ext_connections", "ext_dependencies", "resource_grants", "notifications", "notification_preferences", "audit_log", "workflows", "workflow_stages", "workflow_versions", "workflow_instances", "workflow_assignments", "workflow_signoffs", "triggers", "scheduled_tasks", "trigger_logs", } // dumpTable writes all rows of a table as newline-delimited JSON to w. // Columns are discovered dynamically via rows.ColumnTypes(). func dumpTable(ctx context.Context, db *sql.DB, table string, w io.Writer) (int, error) { rows, err := db.QueryContext(ctx, fmt.Sprintf("SELECT * FROM %s", table)) if err != nil { return 0, fmt.Errorf("query %s: %w", table, err) } defer rows.Close() cols, err := rows.Columns() if err != nil { return 0, fmt.Errorf("columns %s: %w", table, err) } count := 0 enc := json.NewEncoder(w) enc.SetEscapeHTML(false) for rows.Next() { // Create scan destinations — all as interface{} vals := make([]interface{}, len(cols)) ptrs := make([]interface{}, len(cols)) for i := range vals { ptrs[i] = &vals[i] } if err := rows.Scan(ptrs...); err != nil { return count, fmt.Errorf("scan %s row %d: %w", table, count, err) } row := make(map[string]interface{}, len(cols)) for i, col := range cols { row[col] = normalizeValue(vals[i]) } if err := enc.Encode(row); err != nil { return count, fmt.Errorf("encode %s row %d: %w", table, count, err) } count++ } return count, rows.Err() } // normalizeValue converts database-specific types to JSON-friendly values. func normalizeValue(v interface{}) interface{} { if v == nil { return nil } switch val := v.(type) { case []byte: // Try to parse as JSON first (for JSONB columns) var j interface{} if err := json.Unmarshal(val, &j); err == nil { return j } return string(val) default: return val } } // restoreTable reads JSONL from r and inserts rows into the table. // Column names come from the first JSON object. Uses dialect-adapted SQL. func restoreTable(ctx context.Context, db *sql.DB, table string, r io.Reader) (int, error) { scanner := bufio.NewScanner(r) // Allow large lines (e.g. JSONB audit_log entries) scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024) count := 0 for scanner.Scan() { line := scanner.Bytes() if len(line) == 0 { continue } var row map[string]interface{} if err := json.Unmarshal(line, &row); err != nil { return count, fmt.Errorf("unmarshal %s line %d: %w", table, count+1, err) } cols := make([]string, 0, len(row)) vals := make([]interface{}, 0, len(row)) for k, v := range row { cols = append(cols, k) vals = append(vals, marshalIfComplex(v)) } // Build INSERT with positional placeholders placeholders := make([]string, len(cols)) for i := range placeholders { placeholders[i] = fmt.Sprintf("$%d", i+1) } query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", table, strings.Join(cols, ", "), strings.Join(placeholders, ", "), ) if _, err := database.ExecContext(ctx, query, vals...); err != nil { return count, fmt.Errorf("insert %s row %d: %w", table, count+1, err) } count++ } return count, scanner.Err() } // marshalIfComplex converts maps/slices back to JSON strings for DB storage. func marshalIfComplex(v interface{}) interface{} { if v == nil { return nil } switch v.(type) { case map[string]interface{}, []interface{}: b, _ := json.Marshal(v) return string(b) default: return v } } // listExtDataTables returns all (package_id, physical_table_name) pairs // from the ext_data_tables catalog. func listExtDataTables(ctx context.Context, db *sql.DB) ([]extDataTableEntry, error) { rows, err := db.QueryContext(ctx, "SELECT package_id, table_name FROM ext_data_tables ORDER BY package_id, table_name") if err != nil { return nil, err } defer rows.Close() var entries []extDataTableEntry for rows.Next() { var e extDataTableEntry if err := rows.Scan(&e.PackageID, &e.TableName); err != nil { return nil, err } e.PhysicalName = fmt.Sprintf("ext_%s_%s", e.PackageID, e.TableName) entries = append(entries, e) } return entries, rows.Err() } type extDataTableEntry struct { PackageID string TableName string PhysicalName string } // tableExists checks if a table exists in the database. func tableExists(ctx context.Context, db *sql.DB, table string) bool { if database.IsPostgres() { var exists bool db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_name = $1)", table).Scan(&exists) return exists } // SQLite var name string err := db.QueryRowContext(ctx, "SELECT name FROM sqlite_master WHERE type='table' AND name=?", table).Scan(&name) return err == nil } // wipeTables deletes all data from the given tables in reverse order (FK-safe). func wipeTables(ctx context.Context, db *sql.DB, tables []string) error { if database.IsSQLite() { db.ExecContext(ctx, "PRAGMA foreign_keys = OFF") defer db.ExecContext(ctx, "PRAGMA foreign_keys = ON") } // Delete in reverse order to respect FK constraints for i := len(tables) - 1; i >= 0; i-- { table := tables[i] if !tableExists(ctx, db, table) { continue } if database.IsPostgres() { if _, err := db.ExecContext(ctx, fmt.Sprintf("TRUNCATE TABLE %s CASCADE", table)); err != nil { log.Printf("[backup] warning: truncate %s: %v", table, err) } } else { if _, err := db.ExecContext(ctx, fmt.Sprintf("DELETE FROM %s", table)); err != nil { log.Printf("[backup] warning: delete %s: %v", table, err) } } } return nil }