V0.6.2 docs openapi (#37)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-go-pg (push) Successful in 2m46s
CI/CD / test-sqlite (push) Successful in 2m49s
CI/CD / build-and-deploy (push) Successful in 28s

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:
2026-03-31 12:01:51 +00:00
committed by xcaliber
parent 1a7f41493d
commit a887b4c78b
32 changed files with 3862 additions and 548 deletions

View 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
}