V0.6.2 docs openapi (#37)
All checks were successful
All checks were successful
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #37.
This commit is contained in:
231
server/handlers/backup_tables.go
Normal file
231
server/handlers/backup_tables.go
Normal file
@@ -0,0 +1,231 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"switchboard-core/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
|
||||
}
|
||||
Reference in New Issue
Block a user