diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index f91a78d..658c2fa 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -166,4 +166,4 @@ jobs: run: go mod download - name: Run integration tests - run: cd integration && go test -v -tags integration -count=1 ./... -timeout 120s + run: cd integration && go test -v -tags integration -count=1 ./... -timeout 300s diff --git a/README.md b/README.md index 8fd0871..91fae0e 100644 --- a/README.md +++ b/README.md @@ -73,8 +73,9 @@ seedstorm gaps \ - **Enum coverage** — every enum value appears at least `--rows` times, independently per column - **AI enrichment** — Gemini rewrites faker hints for domain-meaningful data; supply `--prompt` for richer context - **Gap analysis** — `gaps` shows which tables are empty with row counts and FK context; `--fill` seeds only the empty ones +- **Schema clone for test DBs** — copy schema-only structure from one connected Postgres/MySQL database into another matching local target, then seed it with safe fake data - **Interactive TUI** — wizard for table selection, global config, self-reference depth, per-table row volumes, and review before seeding -- **Web UI** — `seedstorm serve` exposes an interactive graph workspace with click-to-select tables, self-reference depth, per-table row overrides, live SSE job logs, multi-DB session switcher, and connection presets in `localStorage` +- **Web UI** — `seedstorm serve` exposes an interactive graph workspace with click-to-select tables, self-reference depth, per-table row overrides, truncate-only runs (`Rows = 0` + `truncate`), live SSE job logs, schema clone between connected DBs, multi-DB session switcher, and connection presets in `localStorage` - **Dry-run** — preview the seed plan and INSERT SQL without touching the database - **Export** — generate fake data as YAML, JSON, or SQL without a live connection diff --git a/docs/commands.md b/docs/commands.md index 99c2240..05e98c3 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -10,6 +10,7 @@ Every seedstorm command, with all flags and examples. - [`gaps`](#gaps) — find and fill empty tables - [`generate`](#generate) — generate data without a DB connection - [`export`](#export) — convert data between formats +- [`clone-schema`](#clone-schema) — copy schema structure into another DB - [`serve`](#serve) — local web UI for every feature - [`version`](#version) / [`completion`](#completion) @@ -271,6 +272,46 @@ seedstorm export --data data.yaml --format csv --out data.csv --- +## `clone-schema` + +Copies schema-only table structure from a source database into a target database of the same engine. This is designed for local/test database setup before running `seed`; it recreates the metadata seedstorm understands: tables, columns, nullability, PKs, FKs, single-column UNIQUE constraints, enum values, and simple CHECK constraints. + +```bash +seedstorm clone-schema \ + --source-db postgres \ + --source-dsn "postgres://user:pass@prod.example/app" \ + --target-db postgres \ + --target-dsn "postgres://seedstorm:seedstorm@localhost:5432/testdb" + +# Replace existing target tables +seedstorm clone-schema \ + --source-db mysql \ + --source-dsn "user:pass@tcp(staging.example:3306)/app" \ + --target-db mysql \ + --target-dsn "seedstorm:seedstorm@tcp(localhost:3306)/testdb" \ + --drop-existing + +# Preview generated DDL +seedstorm clone-schema \ + --source-db postgres \ + --source-dsn "postgres://..." \ + --target-db postgres \ + --target-dsn "postgres://..." \ + --dry-run +``` + +| Flag | Default | Description | +|------|---------|-------------| +| `--source-db` / `$SEEDSTORM_SOURCE_DB` | `postgres` | Source database type | +| `--source-dsn` / `$SEEDSTORM_SOURCE_DSN` | — | Source connection string (required) | +| `--target-db` / `$SEEDSTORM_TARGET_DB` | `postgres` | Target database type | +| `--target-dsn` / `$SEEDSTORM_TARGET_DSN` | — | Target connection string (required) | +| `--drop-existing` | false | Drop target tables before creating the cloned schema | +| `--dry-run` / `-n` | false | Print generated DDL, do not execute | +| `--interactive` / `-i` | false | Confirm the clone in the terminal UI | + +--- + ## `serve` Starts a local web UI that exposes every seedstorm feature behind an interactive graph workspace. The UI is bundled into the binary via `go:embed` — no extra files to ship. @@ -284,7 +325,8 @@ SEEDSTORM_ADDR=127.0.0.1:9000 seedstorm serve What the UI gives you: - **Workspace** — Cytoscape DAG of every table; click to select, non-nullable parents auto-lock as a dependency closure (mirrors the TUI). The selected-table panel lets you override row counts per table for **Seed**, **Fill empty**, and workspace **Generate** runs while `Rows` remains the default. `Self-ref` controls bounded generated depth for self-referential FK chains. Live SSE log stream + status pill. -- **Connection management** — multi-session: hold several DBs open in one browser and switch from a topbar dropdown. Saved connection presets in `localStorage` with optional password (eye-icon reveal, closed by default). Passwords are kept in process memory only on the server. +- **Seed controls** — `Rows = 0` with `truncate` enabled is a truncate-only run for the selected scope, including auto-required parents. No rows are generated afterward. +- **Connection management** — multi-session: hold several DBs open in one browser and switch from a topbar dropdown. Saved connection presets in `localStorage` with optional password (eye-icon reveal, closed by default). Passwords are kept in process memory only on the server. The workspace can clone schema from the active connection into another matching connected database. - **Standalone tools** — `/generate`, `/enrich`, `/export` mirror the CLI commands as forms. | Flag | Default | Description | diff --git a/docs/development.md b/docs/development.md index 9fb5a07..98c09d4 100644 --- a/docs/development.md +++ b/docs/development.md @@ -118,9 +118,9 @@ All tests run automatically on every PR via GitHub Actions (`.github/workflows/p | `validate` | Directory/file structure via structlint | | `test` | `go test ./...` + `make build` | | `lint` | `golangci-lint` | -| `integration` | Full 29-table suite on Postgres 15 + MySQL 8 | +| `integration` | Full 29-table suite plus schema-clone smoke tests on the configured Postgres/MySQL pair | -The integration job in CI uses `--timeout 120s`. Use `300s` locally when running both engines back-to-back. +The integration job in CI uses `--timeout 300s` across the database-version matrix. Use the same timeout locally when running both engines back-to-back. ### Supported database versions diff --git a/integration/integration_test.go b/integration/integration_test.go index e4bb693..620593b 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -164,6 +164,114 @@ func assertHardSelfRefSeeded(t *testing.T, conn *sql.DB) { } } +func filterTables(tables []db.Table, names ...string) []db.Table { + want := make(map[string]bool, len(names)) + for _, name := range names { + want[name] = true + } + var out []db.Table + for _, tbl := range tables { + if want[tbl.Name] { + out = append(out, tbl) + } + } + return out +} + +func cloneSmokeSchema(t *testing.T, driver string, conn *sql.DB) { + t.Helper() + if driver == postgresDriver { + execSQL(t, conn, ` + DROP TABLE IF EXISTS clone_orders; + DROP TABLE IF EXISTS clone_users; + CREATE TABLE clone_users ( + id integer PRIMARY KEY, + email varchar(255) NOT NULL UNIQUE, + status varchar(20) NOT NULL CHECK (status IN ('active', 'blocked')) + ); + CREATE TABLE clone_orders ( + id integer PRIMARY KEY, + user_id integer NOT NULL REFERENCES clone_users(id), + total integer NOT NULL CHECK (total BETWEEN 1 AND 500) + ); + `) + return + } + execSQL(t, conn, ` + SET FOREIGN_KEY_CHECKS=0; + DROP TABLE IF EXISTS clone_orders; + DROP TABLE IF EXISTS clone_users; + SET FOREIGN_KEY_CHECKS=1; + CREATE TABLE clone_users ( + id integer PRIMARY KEY, + email varchar(255) NOT NULL UNIQUE, + status varchar(20) NOT NULL CHECK (status IN ('active', 'blocked')) + ); + CREATE TABLE clone_orders ( + id integer PRIMARY KEY, + user_id integer NOT NULL, + total integer NOT NULL CHECK (total BETWEEN 1 AND 500), + FOREIGN KEY (user_id) REFERENCES clone_users(id) + ); + `) +} + +func dropCloneSmokeSchema(t *testing.T, driver string, conn *sql.DB) { + t.Helper() + if driver == postgresDriver { + execSQL(t, conn, `DROP TABLE IF EXISTS clone_orders; DROP TABLE IF EXISTS clone_users;`) + return + } + execSQL(t, conn, `SET FOREIGN_KEY_CHECKS=0; DROP TABLE IF EXISTS clone_orders; DROP TABLE IF EXISTS clone_users; SET FOREIGN_KEY_CHECKS=1;`) +} + +func assertCloneSchemaCanSeed(t *testing.T, driver string, conn *sql.DB, tables []db.Table) { + t.Helper() + s := &schema.Schema{Tables: make(map[string]schema.Table, len(tables))} + for _, tbl := range tables { + st := schema.Table{Columns: make(map[string]schema.Column, len(tbl.Columns))} + for _, col := range tbl.Columns { + sc := schema.Column{ + Type: col.Type, + PK: col.IsPK, + Nullable: col.IsNullable, + Faker: faker.MapColumnToFaker(driver, col), + } + if col.Name == "email" { + sc.Faker = "email" + } + if col.FK != nil { + sc.FK = fmt.Sprintf("%s.%s", col.FK.TableName, col.FK.ColumnName) + } + st.Columns[col.Name] = sc + } + s.Tables[tbl.Name] = st + } + g := graph.Build(s) + order, err := g.TopologicalSort() + if err != nil { + t.Fatalf("topological sort cloned schema: %v", err) + } + data, err := faker.Generate(s, order, 5, 0, conn, driver) + if err != nil { + t.Fatalf("generate cloned schema data: %v", err) + } + for _, tableName := range order { + for _, row := range data[tableName] { + query, values := db.BuildInsert(tableName, row, driver) + if _, err := conn.ExecContext(context.Background(), query, values...); err != nil { + t.Fatalf("insert cloned table %s: %v", tableName, err) + } + } + } + if got := countRows(t, conn, "clone_users"); got == 0 { + t.Fatal("clone_users was not seeded") + } + if got := countRows(t, conn, "clone_orders"); got == 0 { + t.Fatal("clone_orders was not seeded") + } +} + // buildAndSeed runs the full introspect → build schema → generate → seed pipeline. // It prints a summary at the end (not per-row during insert). func buildAndSeed(t *testing.T, label, driver, dsn string, conn *sql.DB) map[string][]map[string]interface{} { @@ -1465,6 +1573,40 @@ func TestPostgresIntegration(t *testing.T) { }) } +func TestPostgresSchemaCloneDDL(t *testing.T) { + dsn := envOrDefault("POSTGRES_DSN", postgresDSN) + conn := openDB(t, postgresDriver, dsn) + defer conn.Close() + dropCloneSmokeSchema(t, postgresDriver, conn) + cloneSmokeSchema(t, postgresDriver, conn) + + sourceTables, err := db.Introspect(postgresDriver, dsn) + if err != nil { + t.Fatalf("introspect source: %v", err) + } + sourceTables = filterTables(sourceTables, "clone_users", "clone_orders") + if len(sourceTables) != 2 { + t.Fatalf("source tables = %d, want 2", len(sourceTables)) + } + stmts, err := db.BuildSchemaDDL(sourceTables, postgresDriver, true) + if err != nil { + t.Fatalf("BuildSchemaDDL: %v", err) + } + if err := db.ExecSchemaDDL(context.Background(), conn, postgresDriver, stmts); err != nil { + t.Fatalf("ExecSchemaDDL: %v", err) + } + cloned, err := db.Introspect(postgresDriver, dsn) + if err != nil { + t.Fatalf("introspect cloned: %v", err) + } + cloned = filterTables(cloned, "clone_users", "clone_orders") + if len(cloned) != 2 { + t.Fatalf("cloned tables = %d, want 2", len(cloned)) + } + assertCloneSchemaCanSeed(t, postgresDriver, conn, cloned) + dropCloneSmokeSchema(t, postgresDriver, conn) +} + // ── MySQL ────────────────────────────────────────────────────────────────────── func TestMySQLIntegration(t *testing.T) { @@ -2667,6 +2809,40 @@ func TestMySQLIntegration(t *testing.T) { }) } +func TestMySQLSchemaCloneDDL(t *testing.T) { + dsn := envOrDefault("MYSQL_DSN", mysqlDSN) + conn := openDB(t, mysqlDriver, dsn) + defer conn.Close() + dropCloneSmokeSchema(t, mysqlDriver, conn) + cloneSmokeSchema(t, mysqlDriver, conn) + + sourceTables, err := db.Introspect(mysqlDriver, dsn) + if err != nil { + t.Fatalf("introspect source: %v", err) + } + sourceTables = filterTables(sourceTables, "clone_users", "clone_orders") + if len(sourceTables) != 2 { + t.Fatalf("source tables = %d, want 2", len(sourceTables)) + } + stmts, err := db.BuildSchemaDDL(sourceTables, mysqlDriver, true) + if err != nil { + t.Fatalf("BuildSchemaDDL: %v", err) + } + if err := db.ExecSchemaDDL(context.Background(), conn, mysqlDriver, stmts); err != nil { + t.Fatalf("ExecSchemaDDL: %v", err) + } + cloned, err := db.Introspect(mysqlDriver, dsn) + if err != nil { + t.Fatalf("introspect cloned: %v", err) + } + cloned = filterTables(cloned, "clone_users", "clone_orders") + if len(cloned) != 2 { + t.Fatalf("cloned tables = %d, want 2", len(cloned)) + } + assertCloneSchemaCanSeed(t, mysqlDriver, conn, cloned) + dropCloneSmokeSchema(t, mysqlDriver, conn) +} + // ── Gap Analysis ────────────────────────────────────────────────────────────── // // These tests exercise the gaps / GenerateFiltered feature end-to-end: diff --git a/internal/cli/clone_schema.go b/internal/cli/clone_schema.go new file mode 100644 index 0000000..eec5d1a --- /dev/null +++ b/internal/cli/clone_schema.go @@ -0,0 +1,83 @@ +package cli + +import ( + "context" + "fmt" + "strings" + + "github.com/AxeForging/seedstorm/internal/db" + "github.com/AxeForging/seedstorm/internal/logging" + "github.com/AxeForging/seedstorm/internal/tui" + "github.com/urfave/cli/v3" +) + +func cloneSchemaCmd() *cli.Command { + return &cli.Command{ + Name: "clone-schema", + Usage: "Copy schema-only structure from one database into another", + Description: `Introspects a source database and creates matching tables in a target database. +This is same-engine schema cloning for local/test databases, not a lossless migration tool.`, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "source-db", + Usage: "Source database type: mysql or postgres", + Value: "postgres", + Sources: cli.EnvVars("SEEDSTORM_SOURCE_DB"), + }, + &cli.StringFlag{ + Name: "source-dsn", + Usage: "Source data source name", + Required: true, + Sources: cli.EnvVars("SEEDSTORM_SOURCE_DSN"), + }, + &cli.StringFlag{ + Name: "target-db", + Usage: "Target database type: mysql or postgres", + Value: "postgres", + Sources: cli.EnvVars("SEEDSTORM_TARGET_DB"), + }, + &cli.StringFlag{ + Name: "target-dsn", + Usage: "Target data source name", + Required: true, + Sources: cli.EnvVars("SEEDSTORM_TARGET_DSN"), + }, + &cli.BoolFlag{ + Name: "drop-existing", + Usage: "Drop existing target tables before creating the cloned schema", + }, + &cli.BoolFlag{ + Name: "dry-run", + Aliases: []string{"n"}, + Usage: "Print DDL without executing it", + }, + &cli.BoolFlag{ + Name: "interactive", + Aliases: []string{"i"}, + Usage: "Review and confirm the clone in the terminal UI", + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + log := logging.Log + sourceType := normalizeDBType(cmd.String("source-db")) + targetType := normalizeDBType(cmd.String("target-db")) + opts := db.CloneOptions{ + DropExisting: cmd.Bool("drop-existing"), + DryRun: cmd.Bool("dry-run"), + } + if cmd.Bool("interactive") { + return tui.RunClone(ctx, sourceType, cmd.String("source-dsn"), targetType, cmd.String("target-dsn"), opts) + } + result, err := db.CloneSchema(ctx, sourceType, cmd.String("source-dsn"), targetType, cmd.String("target-dsn"), opts) + if err != nil { + return err + } + if opts.DryRun { + fmt.Println(strings.Join(result.Statements, ";\n") + ";") + return nil + } + log.Info().Int("tables", result.Tables).Int("statements", len(result.Statements)).Msg("Schema cloned") + return nil + }, + } +} diff --git a/internal/cli/helpers_test.go b/internal/cli/helpers_test.go index 1cfbd7d..ede880b 100644 --- a/internal/cli/helpers_test.go +++ b/internal/cli/helpers_test.go @@ -93,6 +93,15 @@ func TestNormalizeDBType(t *testing.T) { } } +func TestCommandsIncludesCloneSchema(t *testing.T) { + for _, cmd := range Commands() { + if cmd.Name == "clone-schema" { + return + } + } + t.Fatal("clone-schema command is not registered") +} + // --- Tests from batch-inserts branch --- func TestBuildInsert_Postgres(t *testing.T) { diff --git a/internal/cli/root.go b/internal/cli/root.go index d8410d0..0baebb8 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -16,6 +16,7 @@ func Commands() []*cli.Command { gapsCmd(), generateCmd(), exportCmd(), + cloneSchemaCmd(), serveCmd(), versionCmd(), completionCmd(), diff --git a/internal/db/clone.go b/internal/db/clone.go new file mode 100644 index 0000000..9e22d45 --- /dev/null +++ b/internal/db/clone.go @@ -0,0 +1,266 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "sort" + "strings" +) + +// CloneOptions controls schema-only cloning from one connected database to +// another. Cloning is intentionally same-engine: seedstorm's introspection +// metadata is useful for reproducible test databases, not lossless cross-engine +// migration. +type CloneOptions struct { + DropExisting bool + DryRun bool +} + +// CloneResult describes the schema copy operation. +type CloneResult struct { + Tables int + Statements []string +} + +// CloneSchema introspects source and creates the same table structure in target. +func CloneSchema(ctx context.Context, sourceType, sourceDSN, targetType, targetDSN string, opts CloneOptions) (CloneResult, error) { + if sourceType != targetType { + return CloneResult{}, fmt.Errorf("schema clone requires matching database types: source %q target %q", sourceType, targetType) + } + tables, err := Introspect(sourceType, sourceDSN) + if err != nil { + return CloneResult{}, fmt.Errorf("introspect source: %w", err) + } + stmts, err := BuildSchemaDDL(tables, sourceType, opts.DropExisting) + if err != nil { + return CloneResult{}, err + } + result := CloneResult{Tables: len(tables), Statements: stmts} + if opts.DryRun { + return result, nil + } + + conn, err := sql.Open(targetType, targetDSN) + if err != nil { + return CloneResult{}, fmt.Errorf("open target: %w", err) + } + defer conn.Close() + if err := conn.PingContext(ctx); err != nil { + return CloneResult{}, fmt.Errorf("ping target: %w", err) + } + if !opts.DropExisting { + existing, err := introspectWithConn(conn, targetType) + if err != nil { + return CloneResult{}, fmt.Errorf("inspect target: %w", err) + } + if len(existing) > 0 { + return CloneResult{}, fmt.Errorf("target database is not empty (%d tables); rerun with --drop-existing to replace it", len(existing)) + } + } + if err := ExecSchemaDDL(ctx, conn, targetType, stmts); err != nil { + return CloneResult{}, err + } + return result, nil +} + +// BuildSchemaDDL converts seedstorm's introspection metadata into executable +// schema DDL. It covers the constraints seedstorm understands and deliberately +// omits unsupported database objects such as indexes, views, triggers, and +// procedural code. +func BuildSchemaDDL(tables []Table, dbType string, dropExisting bool) ([]string, error) { + if dbType != "pgx" && dbType != "mysql" { + return nil, fmt.Errorf("unsupported database type %q", dbType) + } + ordered := orderTablesByName(tables) + var stmts []string + if dropExisting { + for i := len(ordered) - 1; i >= 0; i-- { + name := QuoteIdent(ordered[i].Name, dbType) + if dbType == "pgx" { + stmts = append(stmts, "DROP TABLE IF EXISTS "+name+" CASCADE") + } else { + stmts = append(stmts, "DROP TABLE IF EXISTS "+name) + } + } + } + if dbType == "mysql" && dropExisting { + stmts = append([]string{"SET FOREIGN_KEY_CHECKS=0"}, stmts...) + stmts = append(stmts, "SET FOREIGN_KEY_CHECKS=1") + } + for _, table := range ordered { + stmt, err := buildCreateTable(table, dbType) + if err != nil { + return nil, err + } + stmts = append(stmts, stmt) + } + stmts = append(stmts, buildForeignKeyDDL(ordered, dbType)...) + return stmts, nil +} + +// ExecSchemaDDL executes generated DDL in order. +func ExecSchemaDDL(ctx context.Context, conn *sql.DB, dbType string, stmts []string) error { + if len(stmts) == 0 { + return nil + } + if dbType == "pgx" { + tx, err := conn.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin schema clone: %w", err) + } + for _, stmt := range stmts { + if _, err := tx.ExecContext(ctx, stmt); err != nil { + _ = tx.Rollback() + return fmt.Errorf("execute DDL %q: %w", stmt, err) + } + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit schema clone: %w", err) + } + return nil + } + for _, stmt := range stmts { + if _, err := conn.ExecContext(ctx, stmt); err != nil { + return fmt.Errorf("execute DDL %q: %w", stmt, err) + } + } + return nil +} + +func introspectWithConn(conn *sql.DB, dbType string) ([]Table, error) { + switch dbType { + case "pgx": + return introspectPostgres(conn) + case "mysql": + return introspectMySQL(conn) + default: + return nil, fmt.Errorf("unsupported database type %q", dbType) + } +} + +func buildCreateTable(table Table, dbType string) (string, error) { + if table.Name == "" { + return "", errors.New("table name is empty") + } + cols := append([]Column(nil), table.Columns...) + sort.Slice(cols, func(i, j int) bool { return cols[i].Name < cols[j].Name }) + var defs []string + var pkCols []string + for _, col := range cols { + if col.Name == "" { + return "", fmt.Errorf("table %s has empty column name", table.Name) + } + def := fmt.Sprintf("%s %s", QuoteIdent(col.Name, dbType), cloneColumnType(col, dbType)) + if !col.IsNullable || col.IsPK { + def += " NOT NULL" + } + if col.Unique && !col.IsPK { + def += " UNIQUE" + } + if len(col.CheckValues) > 0 { + def += " CHECK (" + QuoteIdent(col.Name, dbType) + " IN (" + quotedLiterals(col.CheckValues) + "))" + } + if dbType == "pgx" && len(col.EnumValues) > 0 { + def += " CHECK (" + QuoteIdent(col.Name, dbType) + " IN (" + quotedLiterals(col.EnumValues) + "))" + } + if col.CheckMin != nil && col.CheckMax != nil { + def += fmt.Sprintf(" CHECK (%s BETWEEN %d AND %d)", QuoteIdent(col.Name, dbType), *col.CheckMin, *col.CheckMax) + } + defs = append(defs, def) + if col.IsPK { + pkCols = append(pkCols, QuoteIdent(col.Name, dbType)) + } + } + if len(pkCols) > 0 { + defs = append(defs, "PRIMARY KEY ("+strings.Join(pkCols, ", ")+")") + } + return fmt.Sprintf("CREATE TABLE %s (\n %s\n)", QuoteIdent(table.Name, dbType), strings.Join(defs, ",\n ")), nil +} + +func buildForeignKeyDDL(tables []Table, dbType string) []string { + var stmts []string + for _, table := range tables { + cols := append([]Column(nil), table.Columns...) + sort.Slice(cols, func(i, j int) bool { return cols[i].Name < cols[j].Name }) + for _, col := range cols { + if col.FK == nil { + continue + } + stmts = append(stmts, fmt.Sprintf("ALTER TABLE %s ADD FOREIGN KEY (%s) REFERENCES %s (%s)", + QuoteIdent(table.Name, dbType), + QuoteIdent(col.Name, dbType), + QuoteIdent(col.FK.TableName, dbType), + QuoteIdent(col.FK.ColumnName, dbType))) + } + } + return stmts +} + +func cloneColumnType(col Column, dbType string) string { + t := strings.ToLower(strings.TrimSpace(col.Type)) + if t == "" { + t = "text" + } + if dbType == "mysql" && len(col.EnumValues) > 0 { + return "ENUM(" + quotedLiterals(col.EnumValues) + ")" + } + if dbType == "pgx" && len(col.EnumValues) > 0 { + return "TEXT" + } + switch t { + case "character varying": + if dbType == "mysql" { + return "VARCHAR(255)" + } + return "VARCHAR" + case "timestamp without time zone", "timestamp with time zone": + return "TIMESTAMP" + case "double precision": + if dbType == "mysql" { + return "DOUBLE" + } + case "bool": + return "BOOLEAN" + case "int", "int4": + return "INTEGER" + case "int8": + return "BIGINT" + case "float8": + if dbType == "mysql" { + return "DOUBLE" + } + return "DOUBLE PRECISION" + } + if dbType == "mysql" { + switch t { + case "varchar": + return "VARCHAR(255)" + case "text", "longtext", "mediumtext", "json", "date", "time", "datetime", "timestamp", "boolean", "bool", "integer", "bigint", "smallint", "decimal", "numeric", "float", "double": + return strings.ToUpper(t) + } + return "TEXT" + } + switch t { + case "varchar": + return "VARCHAR" + case "text", "json", "jsonb", "date", "time", "timestamp", "boolean", "integer", "bigint", "smallint", "numeric", "decimal", "real", "uuid": + return strings.ToUpper(t) + } + return "TEXT" +} + +func quotedLiterals(values []string) string { + parts := make([]string, len(values)) + for i, v := range values { + parts[i] = "'" + strings.ReplaceAll(v, "'", "''") + "'" + } + return strings.Join(parts, ", ") +} + +func orderTablesByName(tables []Table) []Table { + out := append([]Table(nil), tables...) + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} diff --git a/internal/db/clone_test.go b/internal/db/clone_test.go new file mode 100644 index 0000000..4a43b3a --- /dev/null +++ b/internal/db/clone_test.go @@ -0,0 +1,151 @@ +package db + +import ( + "strings" + "testing" +) + +func TestBuildSchemaDDL_createsTablesBeforeForeignKeys(t *testing.T) { + tables := []Table{ + { + Name: "orders", + Columns: []Column{ + {Name: "id", Type: "integer", IsPK: true}, + {Name: "user_id", Type: "integer", FK: &ForeignKey{TableName: "users", ColumnName: "id"}}, + }, + }, + { + Name: "users", + Columns: []Column{ + {Name: "id", Type: "integer", IsPK: true}, + {Name: "email", Type: "varchar", Unique: true}, + }, + }, + } + stmts, err := BuildSchemaDDL(tables, "pgx", false) + if err != nil { + t.Fatalf("BuildSchemaDDL: %v", err) + } + joined := strings.Join(stmts, "\n") + createIdx := strings.Index(joined, `CREATE TABLE "orders"`) + fkIdx := strings.Index(joined, `ALTER TABLE "orders" ADD FOREIGN KEY ("user_id") REFERENCES "users" ("id")`) + if createIdx < 0 || fkIdx < 0 { + t.Fatalf("missing create statements:\n%s", joined) + } + if createIdx > fkIdx { + t.Fatalf("FK should be added after CREATE TABLE statements:\n%s", joined) + } +} + +func TestBuildSchemaDDL_includesBarrierDropStatements(t *testing.T) { + tables := []Table{{Name: "users", Columns: []Column{{Name: "id", Type: "integer", IsPK: true}}}} + stmts, err := BuildSchemaDDL(tables, "mysql", true) + if err != nil { + t.Fatalf("BuildSchemaDDL: %v", err) + } + if len(stmts) < 4 { + t.Fatalf("statements = %#v", stmts) + } + if stmts[0] != "SET FOREIGN_KEY_CHECKS=0" { + t.Fatalf("first stmt = %q", stmts[0]) + } + if !strings.HasPrefix(stmts[1], "DROP TABLE IF EXISTS `users`") { + t.Fatalf("drop stmt = %q", stmts[1]) + } + if stmts[2] != "SET FOREIGN_KEY_CHECKS=1" { + t.Fatalf("third stmt = %q", stmts[2]) + } +} + +func TestBuildSchemaDDL_rejectsCrossUnsupportedDriver(t *testing.T) { + if _, err := BuildSchemaDDL(nil, "sqlite", false); err == nil { + t.Fatal("expected unsupported driver error") + } +} + +func TestBuildSchemaDDL_supportsHardCycles(t *testing.T) { + tables := []Table{ + {Name: "a", Columns: []Column{{Name: "id", Type: "integer", IsPK: true}, {Name: "b_id", Type: "integer", FK: &ForeignKey{TableName: "b", ColumnName: "id"}}}}, + {Name: "b", Columns: []Column{{Name: "id", Type: "integer", IsPK: true}, {Name: "a_id", Type: "integer", FK: &ForeignKey{TableName: "a", ColumnName: "id"}}}}, + } + stmts, err := BuildSchemaDDL(tables, "pgx", false) + if err != nil { + t.Fatalf("BuildSchemaDDL should support cyclic FKs via ALTER TABLE: %v", err) + } + ddl := strings.Join(stmts, "\n") + for _, want := range []string{ + `ALTER TABLE "a" ADD FOREIGN KEY ("b_id") REFERENCES "b" ("id")`, + `ALTER TABLE "b" ADD FOREIGN KEY ("a_id") REFERENCES "a" ("id")`, + } { + if !strings.Contains(ddl, want) { + t.Fatalf("missing %q in:\n%s", want, ddl) + } + } +} + +func TestBuildSchemaDDL_checkConstraints(t *testing.T) { + min, max := int64(1), int64(5) + tables := []Table{{ + Name: "tickets", + Columns: []Column{ + {Name: "id", Type: "integer", IsPK: true}, + {Name: "status", Type: "varchar", CheckValues: []string{"new", "closed"}}, + {Name: "rating", Type: "integer", CheckMin: &min, CheckMax: &max}, + }, + }} + stmts, err := BuildSchemaDDL(tables, "pgx", false) + if err != nil { + t.Fatalf("BuildSchemaDDL: %v", err) + } + ddl := strings.Join(stmts, "\n") + for _, want := range []string{ + `CHECK ("status" IN ('new', 'closed'))`, + `CHECK ("rating" BETWEEN 1 AND 5)`, + } { + if !strings.Contains(ddl, want) { + t.Fatalf("missing %q in:\n%s", want, ddl) + } + } +} + +func TestBuildSchemaDDL_postgresEnumValuesBecomeCheck(t *testing.T) { + tables := []Table{{ + Name: "orders", + Columns: []Column{ + {Name: "id", Type: "integer", IsPK: true}, + {Name: "status", Type: "order_status", EnumValues: []string{"new", "done"}}, + }, + }} + stmts, err := BuildSchemaDDL(tables, "pgx", false) + if err != nil { + t.Fatalf("BuildSchemaDDL: %v", err) + } + ddl := strings.Join(stmts, "\n") + if !strings.Contains(ddl, `"status" TEXT NOT NULL CHECK ("status" IN ('new', 'done'))`) { + t.Fatalf("Postgres enum values should be preserved as a CHECK over TEXT:\n%s", ddl) + } +} + +func TestBuildSchemaDDL_mysqlColumnConstraintOrder(t *testing.T) { + tables := []Table{{ + Name: "users", + Columns: []Column{ + {Name: "id", Type: "integer", IsPK: true}, + {Name: "email", Type: "varchar", IsNullable: false, Unique: true}, + {Name: "status", Type: "varchar", IsNullable: false, CheckValues: []string{"active", "blocked"}}, + }, + }} + stmts, err := BuildSchemaDDL(tables, "mysql", false) + if err != nil { + t.Fatalf("BuildSchemaDDL: %v", err) + } + ddl := strings.Join(stmts, "\n") + for _, want := range []string{ + "`email` VARCHAR(255) NOT NULL UNIQUE", + "`status` VARCHAR(255) NOT NULL CHECK (`status` IN ('active', 'blocked'))", + } { + if !strings.Contains(ddl, want) { + t.Fatalf("missing %q in:\n%s", want, ddl) + } + } +} diff --git a/internal/tui/clone.go b/internal/tui/clone.go new file mode 100644 index 0000000..abb6aa6 --- /dev/null +++ b/internal/tui/clone.go @@ -0,0 +1,121 @@ +package tui + +import ( + "context" + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/AxeForging/seedstorm/internal/db" +) + +type cloneModel struct { + ctx context.Context + sourceType string + sourceDSN string + targetType string + targetDSN string + opts db.CloneOptions + running bool + done bool + confirmed bool + result db.CloneResult + err error +} + +type cloneDoneMsg struct { + result db.CloneResult + err error +} + +// RunClone presents a small confirmation UI around schema cloning. +func RunClone(ctx context.Context, sourceType, sourceDSN, targetType, targetDSN string, opts db.CloneOptions) error { + m := cloneModel{ + ctx: ctx, + sourceType: sourceType, + sourceDSN: sourceDSN, + targetType: targetType, + targetDSN: targetDSN, + opts: opts, + } + finalModel, err := tea.NewProgram(m, tea.WithContext(ctx)).Run() + if err != nil { + return fmt.Errorf("TUI error: %w", err) + } + fm := finalModel.(cloneModel) + if fm.err != nil { + return fm.err + } + if !fm.confirmed { + return fmt.Errorf("aborted by user") + } + return nil +} + +func (m cloneModel) Init() tea.Cmd { return nil } + +func (m cloneModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "esc", "q", "n": + return m, tea.Quit + case "y", "enter": + if m.running || m.done { + return m, nil + } + m.confirmed = true + m.running = true + return m, m.run() + } + case cloneDoneMsg: + m.running = false + m.done = true + m.result = msg.result + m.err = msg.err + return m, tea.Quit + } + return m, nil +} + +func (m cloneModel) View() string { + var sb strings.Builder + sb.WriteString(titleStyle.Render("Clone schema")) + sb.WriteString("\n\n") + sb.WriteString(fmt.Sprintf(" Source: %s\n", m.sourceType)) + sb.WriteString(fmt.Sprintf(" Target: %s\n", m.targetType)) + if m.opts.DropExisting { + sb.WriteString(errorStyle.Render(" Target tables will be dropped first.\n")) + } else { + sb.WriteString(" Target must be empty.\n") + } + if m.opts.DryRun { + sb.WriteString(" Dry-run: DDL will be printed by the command.\n") + } + sb.WriteString("\n") + if m.running { + sb.WriteString(" Cloning schema...\n") + return sb.String() + } + if m.done { + if m.err != nil { + sb.WriteString(errorStyle.Render(fmt.Sprintf(" Error: %v\n", m.err))) + } else { + sb.WriteString(successStyle.Render(fmt.Sprintf(" Complete: %d tables, %d statements\n", m.result.Tables, len(m.result.Statements)))) + } + return sb.String() + } + sb.WriteString(helpStyle.Render(" y/enter confirm • n/q/esc abort")) + return sb.String() +} + +func (m cloneModel) run() tea.Cmd { + return func() tea.Msg { + result, err := db.CloneSchema(m.ctx, m.sourceType, m.sourceDSN, m.targetType, m.targetDSN, m.opts) + if m.opts.DryRun && err == nil { + fmt.Println(strings.Join(result.Statements, ";\n") + ";") + } + return cloneDoneMsg{result: result, err: err} + } +} diff --git a/internal/web/clone_schema_test.go b/internal/web/clone_schema_test.go new file mode 100644 index 0000000..fa56c08 --- /dev/null +++ b/internal/web/clone_schema_test.go @@ -0,0 +1,49 @@ +package web + +import ( + "context" + "strings" + "testing" +) + +func TestRunCloneSchema_rejectsSameTarget(t *testing.T) { + s, err := New(Options{Addr: "127.0.0.1:0"}) + if err != nil { + t.Fatalf("New: %v", err) + } + sess := &Session{ID: "one", DBType: "pgx"} + s.sessions.sessions[sess.ID] = sess + + _, err = s.runCloneSchema(context.Background(), sess, CloneSchemaRequest{TargetID: "one"}, testJobControl{}) + if err == nil || !strings.Contains(err.Error(), "must be different") { + t.Fatalf("err = %v, want same-target barrier", err) + } +} + +func TestRunCloneSchema_rejectsMissingTarget(t *testing.T) { + s, err := New(Options{Addr: "127.0.0.1:0"}) + if err != nil { + t.Fatalf("New: %v", err) + } + sess := &Session{ID: "source", DBType: "pgx"} + + _, err = s.runCloneSchema(context.Background(), sess, CloneSchemaRequest{TargetID: "missing"}, testJobControl{}) + if err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("err = %v, want missing target barrier", err) + } +} + +func TestRunCloneSchema_rejectsCrossDatabaseType(t *testing.T) { + s, err := New(Options{Addr: "127.0.0.1:0"}) + if err != nil { + t.Fatalf("New: %v", err) + } + source := &Session{ID: "source", DBType: "pgx", Info: ConnectionInfo{DBType: "postgres"}} + target := &Session{ID: "target", DBType: "mysql", Info: ConnectionInfo{DBType: "mysql"}} + s.sessions.sessions[target.ID] = target + + _, err = s.runCloneSchema(context.Background(), source, CloneSchemaRequest{TargetID: "target"}, testJobControl{}) + if err == nil || !strings.Contains(err.Error(), "matching database types") { + t.Fatalf("err = %v, want cross-type barrier", err) + } +} diff --git a/internal/web/dsn.go b/internal/web/dsn.go index a239354..987cb64 100644 --- a/internal/web/dsn.go +++ b/internal/web/dsn.go @@ -4,6 +4,8 @@ import ( "errors" "fmt" "net/url" + "regexp" + "strconv" "strings" ) @@ -50,3 +52,73 @@ func buildDSN(info ConnectionInfo, password string) (driver, dsn string, err err return "", "", errors.New("unsupported database type (use postgres or mysql)") } } + +func buildRawDSN(dbType, raw string) (driver, dsn string, info ConnectionInfo, err error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", "", ConnectionInfo{}, errors.New("connection string is required") + } + switch strings.ToLower(dbType) { + case "postgres", "postgresql", "pgx": + u, err := url.Parse(raw) + if err != nil { + return "", "", ConnectionInfo{}, fmt.Errorf("parse postgres connection string: %w", err) + } + if u.Scheme != "postgres" && u.Scheme != "postgresql" { + return "", "", ConnectionInfo{}, errors.New("postgres connection string must start with postgres:// or postgresql://") + } + port := 5432 + if p := u.Port(); p != "" { + if n, err := strconv.Atoi(p); err == nil { + port = n + } + } + info := ConnectionInfo{ + DBType: "postgres", + Host: u.Hostname(), + Port: port, + DBName: strings.TrimPrefix(u.Path, "/"), + User: u.User.Username(), + SSL: u.Query().Get("sslmode"), + } + if info.Host == "" { + info.Host = "localhost" + } + return "pgx", raw, info, nil + case "mysql": + info := parseMySQLDisplayInfo(raw) + return "mysql", ensureMySQLParams(raw), info, nil + default: + return "", "", ConnectionInfo{}, errors.New("unsupported database type (use postgres or mysql)") + } +} + +var mysqlDSNRe = regexp.MustCompile(`^([^:]+)(?::[^@]*)?@tcp\(([^:)]+)(?::(\d+))?\)/([^?]+)`) + +func parseMySQLDisplayInfo(raw string) ConnectionInfo { + info := ConnectionInfo{DBType: "mysql", Host: "localhost", Port: 3306} + if m := mysqlDSNRe.FindStringSubmatch(raw); len(m) == 5 { + info.User = m[1] + info.Host = m[2] + if m[3] != "" { + if n, err := strconv.Atoi(m[3]); err == nil { + info.Port = n + } + } + info.DBName = m[4] + } + return info +} + +func ensureMySQLParams(raw string) string { + if strings.Contains(raw, "?") { + if !strings.Contains(raw, "multiStatements=") { + raw += "&multiStatements=true" + } + if !strings.Contains(raw, "parseTime=") { + raw += "&parseTime=true" + } + return raw + } + return raw + "?parseTime=true&multiStatements=true" +} diff --git a/internal/web/dsn_test.go b/internal/web/dsn_test.go index 3c8d487..02e096b 100644 --- a/internal/web/dsn_test.go +++ b/internal/web/dsn_test.go @@ -80,3 +80,35 @@ func TestBuildDSN_unsupported(t *testing.T) { t.Fatal("expected error for unsupported db type") } } + +func TestBuildRawDSN_postgresDisplayInfo(t *testing.T) { + driver, dsn, info, err := buildRawDSN("postgres", "postgres://alice:secret@db.example:5439/targetdb?sslmode=require") + if err != nil { + t.Fatalf("buildRawDSN: %v", err) + } + if driver != "pgx" { + t.Fatalf("driver = %q, want pgx", driver) + } + if dsn != "postgres://alice:secret@db.example:5439/targetdb?sslmode=require" { + t.Fatalf("dsn changed: %q", dsn) + } + if info.DBName != "targetdb" || info.User != "alice" || info.Host != "db.example" || info.Port != 5439 || info.SSL != "require" { + t.Fatalf("display info = %+v", info) + } +} + +func TestBuildRawDSN_mysqlDisplayInfoAndParams(t *testing.T) { + driver, dsn, info, err := buildRawDSN("mysql", "bob:secret@tcp(mysql.example:3310)/targetdb") + if err != nil { + t.Fatalf("buildRawDSN: %v", err) + } + if driver != "mysql" { + t.Fatalf("driver = %q, want mysql", driver) + } + if !strings.Contains(dsn, "parseTime=true") || !strings.Contains(dsn, "multiStatements=true") { + t.Fatalf("mysql params not added: %q", dsn) + } + if info.DBName != "targetdb" || info.User != "bob" || info.Host != "mysql.example" || info.Port != 3310 { + t.Fatalf("display info = %+v", info) + } +} diff --git a/internal/web/handlers_pages.go b/internal/web/handlers_pages.go index e097a54..d2707d3 100644 --- a/internal/web/handlers_pages.go +++ b/internal/web/handlers_pages.go @@ -34,6 +34,7 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) { } port, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("port"))) info := ConnectionInfo{ + Label: strings.TrimSpace(r.FormValue("label")), DBType: strings.TrimSpace(r.FormValue("dbType")), Host: strings.TrimSpace(r.FormValue("host")), Port: port, @@ -42,6 +43,33 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) { SSL: strings.TrimSpace(r.FormValue("ssl")), } password := r.FormValue("password") + rawDSN := strings.TrimSpace(r.FormValue("dsn")) + if rawDSN != "" { + driver, dsn, displayInfo, err := buildRawDSN(info.DBType, rawDSN) + if err != nil { + s.render(w, r, "connect", pageData{ + Title: "Connect", + Active: "connect", + Error: err.Error(), + Data: info, + }) + return + } + displayInfo.Label = info.Label + sess, err := s.sessions.OpenDSN(driver, dsn, displayInfo) + if err != nil { + s.render(w, r, "connect", pageData{ + Title: "Connect", + Active: "connect", + Error: err.Error(), + Data: info, + }) + return + } + setSessionCookie(w, sess.ID) + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } if info.DBType == "" || info.DBName == "" || info.User == "" { s.render(w, r, "connect", pageData{ Title: "Connect", @@ -106,11 +134,15 @@ func (s *Server) handleConnectionsJSON(w http.ResponseWriter, r *http.Request) { Active bool `json:"active"` } out := []entry{} - for _, sess := range s.sessions.All() { + activeID := "" + if current != nil { + activeID = current.Value + } + for _, sess := range dedupeConnections(s.sessions.All(), activeID) { out = append(out, entry{ ID: sess.ID, Info: sess.Info, - Active: current != nil && current.Value == sess.ID, + Active: activeID == sess.ID, }) } writeJSON(w, http.StatusOK, out) diff --git a/internal/web/handlers_pages_test.go b/internal/web/handlers_pages_test.go index 77e7e96..f1ea197 100644 --- a/internal/web/handlers_pages_test.go +++ b/internal/web/handlers_pages_test.go @@ -1,6 +1,7 @@ package web import ( + "encoding/json" "io" "net/http" "net/http/httptest" @@ -31,8 +32,14 @@ func TestServer_routes_smoke(t *testing.T) { {"/static/app.js", http.StatusOK, "GRAPH_ROUTE_KEY"}, {"/static/app.js", http.StatusOK, "route-step"}, {"/static/app.js", http.StatusOK, "routeColorFor"}, + {"/static/app.js", http.StatusOK, "/api/clone-schema"}, + {"/static/app.js", http.StatusOK, "setupConnectionMenuPresets"}, + {"/static/app.js", http.StatusOK, "presetConnectionInfo"}, + {"/static/app.js", http.StatusOK, "connectionKey"}, + {"/static/app.js", http.StatusOK, "dataset.kind = \"preset\""}, {"/static/style.css", http.StatusOK, ".result-shell"}, {"/static/style.css", http.StatusOK, ".ws-route-toggle"}, + {"/static/style.css", http.StatusOK, ".conn-preset-list"}, } for _, c := range cases { t.Run(c.path, func(t *testing.T) { @@ -55,6 +62,86 @@ func TestServer_routes_smoke(t *testing.T) { } } +func TestWorkspaceRendersConnectionLabels(t *testing.T) { + s, err := New(Options{Addr: "127.0.0.1:0"}) + if err != nil { + t.Fatalf("New: %v", err) + } + active := &Session{ID: "active", Info: ConnectionInfo{Label: "pg", DBType: "postgres", DBName: "testdb", Host: "localhost", Port: 5432, User: "seedstorm"}} + target := &Session{ID: "target", Info: ConnectionInfo{Label: "target-db", DBType: "postgres", DBName: "clonedb", Host: "localhost", Port: 5432, User: "seedstorm"}} + targetDup := &Session{ID: "target-dupe", Info: ConnectionInfo{Label: "target-db", DBType: "postgres", DBName: "clonedb", Host: "localhost", Port: 5432, User: "seedstorm"}} + s.sessions.sessions[active.ID] = active + s.sessions.sessions[target.ID] = target + s.sessions.sessions[targetDup.ID] = targetDup + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: targetDup.ID}) + rec := httptest.NewRecorder() + s.handleIndex(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + body := rec.Body.String() + if !strings.Contains(body, `target-db`) { + t.Fatalf("active connection should render preset label, body missing target label") + } + if strings.Count(body, `target-db`) != 1 { + t.Fatalf("duplicate target connection rendered in switcher:\n%s", body) + } + if !strings.Contains(body, `pg`) { + t.Fatalf("source connection should still render in switcher") + } + if strings.Contains(body, `clonedb`) { + t.Fatalf("target DB name leaked as switcher row instead of preset label") + } +} + +func TestConnectionsJSONDedupesDuplicateLiveConnections(t *testing.T) { + s, err := New(Options{Addr: "127.0.0.1:0"}) + if err != nil { + t.Fatalf("New: %v", err) + } + first := &Session{ID: "first", Info: ConnectionInfo{Label: "target-db", DBType: "postgres", DBName: "clonedb", Host: "localhost", Port: 5432, User: "seedstorm"}} + active := &Session{ID: "active", Info: ConnectionInfo{Label: "target-db", DBType: "postgres", DBName: "clonedb", Host: "localhost", Port: 5432, User: "seedstorm"}} + source := &Session{ID: "source", Info: ConnectionInfo{Label: "pg", DBType: "postgres", DBName: "testdb", Host: "localhost", Port: 5432, User: "seedstorm"}} + s.sessions.sessions[first.ID] = first + s.sessions.sessions[active.ID] = active + s.sessions.sessions[source.ID] = source + + req := httptest.NewRequest(http.MethodGet, "/api/connections", nil) + req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: active.ID}) + rec := httptest.NewRecorder() + s.handleConnectionsJSON(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + var rows []struct { + ID string `json:"id"` + Info ConnectionInfo `json:"info"` + Active bool `json:"active"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &rows); err != nil { + t.Fatalf("decode: %v", err) + } + if len(rows) != 2 { + t.Fatalf("connections len = %d, want 2: %#v", len(rows), rows) + } + targets := 0 + for _, row := range rows { + if row.Info.Label == "target-db" { + targets++ + if row.ID != active.ID || !row.Active { + t.Fatalf("dedupe should keep active target row, got %#v", row) + } + } + } + if targets != 1 { + t.Fatalf("target rows = %d, want 1: %#v", targets, rows) + } +} + func TestServer_apiRequiresSession(t *testing.T) { s, err := New(Options{Addr: "127.0.0.1:0"}) if err != nil { diff --git a/internal/web/handlers_runs.go b/internal/web/handlers_runs.go index ad442f4..0b1e6d5 100644 --- a/internal/web/handlers_runs.go +++ b/internal/web/handlers_runs.go @@ -61,3 +61,7 @@ func (s *Server) handleEnrichRun(w http.ResponseWriter, r *http.Request) { func (s *Server) handleExportRun(w http.ResponseWriter, r *http.Request) { startRun(s, w, r, "export", s.runExport) } + +func (s *Server) handleCloneSchemaRun(w http.ResponseWriter, r *http.Request) { + startRun(s, w, r, "clone-schema", s.runCloneSchema) +} diff --git a/internal/web/render.go b/internal/web/render.go index d9ea51e..01e7a13 100644 --- a/internal/web/render.go +++ b/internal/web/render.go @@ -19,7 +19,11 @@ type pageData struct { func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, data pageData) { sess, _ := s.sessions.fromRequest(r) data.Session = sess - data.Connections = s.sessions.All() + activeID := "" + if sess != nil { + activeID = sess.ID + } + data.Connections = dedupeConnections(s.sessions.All(), activeID) if data.Title == "" { data.Title = "seedstorm" } diff --git a/internal/web/runners.go b/internal/web/runners.go index ac180a9..e775c05 100644 --- a/internal/web/runners.go +++ b/internal/web/runners.go @@ -51,14 +51,107 @@ type SeedRequest struct { TableRows map[string]int `json:"tableRows,omitempty"` } +type CloneSchemaRequest struct { + TargetID string `json:"targetId"` + Target ConnectionInfo `json:"target,omitempty"` + TargetDSN string `json:"targetDsn,omitempty"` + Password string `json:"password,omitempty"` + DropExisting bool `json:"dropExisting"` + DryRun bool `json:"dryRun"` +} + +func (s *Server) runCloneSchema(ctx context.Context, sess *Session, req CloneSchemaRequest, jc JobControl) (map[string]any, error) { + target, err := s.resolveCloneTarget(req, sess) + if err != nil { + return nil, err + } + if sess.DBType != target.DBType { + return nil, fmt.Errorf("schema clone requires matching database types: source %q target %q", sess.Info.DBType, target.Info.DBType) + } + + jc.Phase("introspect") + tables, err := sess.RawTables() + if err != nil { + return nil, fmt.Errorf("source introspection: %w", err) + } + jc.Phase("plan") + stmts, err := db.BuildSchemaDDL(tables, sess.DBType, req.DropExisting) + if err != nil { + return nil, err + } + if req.DryRun { + return map[string]any{ + "tables": len(tables), + "statements": len(stmts), + "dryRun": true, + "format": "sql", + "output": strings.Join(stmts, ";\n") + ";", + }, nil + } + + if !req.DropExisting { + existing, err := target.RawTables() + if err != nil { + return nil, fmt.Errorf("target inspection: %w", err) + } + if len(existing) > 0 { + return nil, fmt.Errorf("target database is not empty (%d tables); enable drop existing to replace it", len(existing)) + } + } + jc.Phase("create") + conn, err := target.OpenRunConn(ctx) + if err != nil { + return nil, err + } + defer conn.Close() + if err := db.ExecSchemaDDL(ctx, conn, target.DBType, stmts); err != nil { + return nil, err + } + target.SetSchema(nil) + jc.Phase("done") + return map[string]any{ + "tables": len(tables), + "statements": len(stmts), + "target": target.Info.DBName, + }, nil +} + +func (s *Server) resolveCloneTarget(req CloneSchemaRequest, source *Session) (*Session, error) { + if req.TargetID != "" { + if req.TargetID == source.ID { + return nil, fmt.Errorf("target connection must be different from source") + } + target, ok := s.sessions.Get(req.TargetID) + if !ok { + return nil, fmt.Errorf("target connection not found") + } + return target, nil + } + if strings.TrimSpace(req.TargetDSN) != "" { + driver, dsn, info, err := buildRawDSN(req.Target.DBType, req.TargetDSN) + if err != nil { + return nil, err + } + info.Label = req.Target.Label + return s.sessions.OpenDSN(driver, dsn, info) + } + if req.Target.DBType == "" || req.Target.DBName == "" || req.Target.User == "" { + return nil, fmt.Errorf("target connection is required") + } + return s.sessions.Open(req.Target, req.Password) +} + func (s *Server) runSeed(ctx context.Context, sess *Session, req SeedRequest, jc JobControl) (map[string]any, error) { log := jobLogger(jc) - if req.Rows <= 0 { + tableRows := cleanTableRows(req.TableRows) + truncateOnly := req.Truncate && req.Rows == 0 && req.EnumRows == 0 && len(tableRows) == 0 + if req.Rows < 0 || (req.Rows == 0 && !truncateOnly) { req.Rows = 100 } if req.BatchSize <= 0 { req.BatchSize = 100 } + start := time.Now() jc.Phase("build") sc, err := sess.Schema(false) if err != nil { @@ -121,7 +214,33 @@ func (s *Server) runSeed(ctx context.Context, sess *Session, req SeedRequest, jc log.Info().Msg("Truncate complete") } - start := time.Now() + if truncateOnly { + tableCounts := make(map[string]int, len(targetTables)) + for _, tableName := range targetTables { + tableCounts[tableName] = 0 + } + elapsed := time.Since(start).Round(time.Millisecond) + jc.Phase("done") + log.Info(). + Int("tables", len(targetTables)). + Dur("duration", elapsed). + Msg("Truncate-only seed run complete") + autoList := make([]string, 0, len(autoSelected)) + for t := range autoSelected { + autoList = append(autoList, t) + } + return map[string]any{ + "tables": len(targetTables), + "totalRows": 0, + "durationMs": elapsed.Milliseconds(), + "dryRun": req.DryRun, + "truncated": true, + "order": targetTables, + "auto": autoList, + "tableCounts": tableCounts, + }, nil + } + jc.Phase("generate") log.Info().Int("rows", req.Rows).Msg("Generating fake data") connArg := conn @@ -130,7 +249,7 @@ func (s *Server) runSeed(ctx context.Context, sess *Session, req SeedRequest, jc } // GenerateFiltered preloads PKs from allSorted so target tables can FK-ref // already-populated parents; targetTables alone is what gets generated. - data, err := faker.GenerateFilteredWithOptions(sc, allSorted, targetTables, req.Rows, req.EnumRows, cleanTableRows(req.TableRows), connArg, sess.DBType, faker.GenerateOptions{ + data, err := faker.GenerateFilteredWithOptions(sc, allSorted, targetTables, req.Rows, req.EnumRows, tableRows, connArg, sess.DBType, faker.GenerateOptions{ SelfRefDepth: requestSelfRefDepth(req.SelfRefDepth), }) if err != nil { diff --git a/internal/web/runners_test.go b/internal/web/runners_test.go index 737569f..f0aa3e4 100644 --- a/internal/web/runners_test.go +++ b/internal/web/runners_test.go @@ -198,6 +198,62 @@ func TestRunSeedUsesFreshConnectionForMutatingServeJob(t *testing.T) { } } +func TestRunSeedTruncateWithZeroRowsDoesNotReSeed(t *testing.T) { + registerServeRunnerTestDriver() + staleConn, err := sql.Open(serveRunnerTestDriverName, "stale") + if err != nil { + t.Fatalf("open stale conn: %v", err) + } + defer staleConn.Close() + + oldOpen := sqlOpen + sqlOpen = func(driverName, dataSourceName string) (*sql.DB, error) { + if driverName != "pgx" || dataSourceName != "truncate-only" { + t.Fatalf("sqlOpen called with %s %s, want pgx truncate-only", driverName, dataSourceName) + } + return sql.Open(serveRunnerTestDriverName, "truncate-only") + } + defer func() { sqlOpen = oldOpen }() + + srv, err := New(Options{Addr: "127.0.0.1:0"}) + if err != nil { + t.Fatalf("New: %v", err) + } + sess := &Session{ + DBType: "pgx", + DSN: "truncate-only", + conn: staleConn, + schema: runnerRowCountSchema(), + } + + result, err := srv.runSeed(context.Background(), sess, SeedRequest{ + Rows: 0, + BatchSize: 0, + Truncate: true, + Tables: []string{"orders"}, + }, testJobControl{}) + if err != nil { + t.Fatalf("runSeed truncate-only should not generate inserts: %v", err) + } + if got := result["totalRows"]; got != 0 { + t.Fatalf("totalRows = %v, want 0", got) + } + if got := result["truncated"]; got != true { + t.Fatalf("truncated = %v, want true", got) + } + counts, ok := result["tableCounts"].(map[string]int) + if !ok { + t.Fatalf("tableCounts type = %T, want map[string]int", result["tableCounts"]) + } + if counts["users"] != 0 || counts["orders"] != 0 { + t.Fatalf("tableCounts = %+v, want users=0 orders=0", counts) + } + auto, ok := result["auto"].([]string) + if !ok || len(auto) != 1 || auto[0] != "users" { + t.Fatalf("auto = %#v, want users auto-selected for orders", result["auto"]) + } +} + func TestRunGenerateHandlesHardSelfReference(t *testing.T) { srv, err := New(Options{Addr: "127.0.0.1:0"}) if err != nil { @@ -279,6 +335,9 @@ func (c *serveRunnerTestConn) ExecContext(_ context.Context, query string, _ []d if c.name == "stale" && strings.Contains(query, "INSERT") { return nil, errors.New("cache lookup failed for type 34868 (SQLSTATE XX000)") } + if c.name == "truncate-only" && strings.Contains(query, "INSERT") { + return nil, errors.New("truncate-only run should not insert rows") + } return driver.RowsAffected(1), nil } diff --git a/internal/web/server.go b/internal/web/server.go index 78178aa..0d7a9a5 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -91,6 +91,7 @@ func (s *Server) routes() { s.mux.HandleFunc("/api/generate", s.handleGenerateRun) s.mux.HandleFunc("/api/enrich", s.handleEnrichRun) s.mux.HandleFunc("/api/export", s.handleExportRun) + s.mux.HandleFunc("/api/clone-schema", s.handleCloneSchemaRun) } // loadTemplates parses each page template as its own template set, with the @@ -139,5 +140,14 @@ func templateFuncs() template.FuncMap { } return b }, + "connName": func(info ConnectionInfo) string { + if info.Label != "" { + return info.Label + } + if info.DBName != "" { + return info.DBName + } + return info.Host + }, } } diff --git a/internal/web/session.go b/internal/web/session.go index 5ce929b..3d26c80 100644 --- a/internal/web/session.go +++ b/internal/web/session.go @@ -8,6 +8,8 @@ import ( "errors" "fmt" "net/http" + "strconv" + "strings" "sync" "time" @@ -23,6 +25,7 @@ var sqlOpen = sql.Open // ConnectionInfo is the non-secret view of an active connection, safe to // surface in templates and logs. type ConnectionInfo struct { + Label string `json:"label,omitempty"` DBType string `json:"dbType"` Host string `json:"host"` Port int `json:"port"` @@ -63,7 +66,18 @@ func (r *SessionRegistry) Open(info ConnectionInfo, password string) (*Session, if err != nil { return nil, err } + return r.open(driver, dsn, info) +} + +// OpenDSN dials an already-built DSN and registers a new session. +func (r *SessionRegistry) OpenDSN(driver, dsn string, info ConnectionInfo) (*Session, error) { + return r.open(driver, dsn, info) +} +func (r *SessionRegistry) open(driver, dsn string, info ConnectionInfo) (*Session, error) { + if existing := r.findByDSN(driver, dsn); existing != nil { + return existing, nil + } conn, err := sql.Open(driver, dsn) if err != nil { return nil, fmt.Errorf("open connection: %w", err) @@ -89,6 +103,17 @@ func (r *SessionRegistry) Open(info ConnectionInfo, password string) (*Session, return s, nil } +func (r *SessionRegistry) findByDSN(driver, dsn string) *Session { + r.mu.RLock() + defer r.mu.RUnlock() + for _, s := range r.sessions { + if s.DBType == driver && s.DSN == dsn { + return s + } + } + return nil +} + // Get fetches a session by ID. func (r *SessionRegistry) Get(id string) (*Session, bool) { r.mu.RLock() @@ -115,6 +140,37 @@ func (r *SessionRegistry) All() []*Session { return out } +func sessionConnectionKey(s *Session) string { + if s == nil { + return "" + } + info := s.Info + return strings.Join([]string{ + strings.ToLower(info.DBType), + strings.ToLower(info.Host), + strconv.Itoa(info.Port), + info.DBName, + info.User, + }, "|") +} + +func dedupeConnections(conns []*Session, activeID string) []*Session { + seen := make(map[string]int, len(conns)) + out := make([]*Session, 0, len(conns)) + for _, sess := range conns { + key := sessionConnectionKey(sess) + if idx, ok := seen[key]; ok { + if sess.ID == activeID && out[idx].ID != activeID { + out[idx] = sess + } + continue + } + seen[key] = len(out) + out = append(out, sess) + } + return out +} + // Pick returns any session that is not the given one (used to fall back to a // remaining session after disconnecting the active one). func (r *SessionRegistry) Pick(exclude string) *Session { diff --git a/internal/web/session_test.go b/internal/web/session_test.go new file mode 100644 index 0000000..96077f1 --- /dev/null +++ b/internal/web/session_test.go @@ -0,0 +1,25 @@ +package web + +import "testing" + +func TestSessionRegistryOpenDSNReusesExactExistingSession(t *testing.T) { + r := NewSessionRegistry() + existing := &Session{ + ID: "existing", + DBType: "pgx", + DSN: "postgres://seedstorm:secret@localhost:5432/clonedb?sslmode=disable", + Info: ConnectionInfo{Label: "target-db", DBType: "postgres", Host: "localhost", Port: 5432, DBName: "clonedb", User: "seedstorm"}, + } + r.sessions[existing.ID] = existing + + got, err := r.OpenDSN(existing.DBType, existing.DSN, existing.Info) + if err != nil { + t.Fatalf("OpenDSN: %v", err) + } + if got != existing { + t.Fatalf("OpenDSN returned a new session, want existing") + } + if len(r.sessions) != 1 { + t.Fatalf("session count = %d, want 1", len(r.sessions)) + } +} diff --git a/internal/web/static/app.js b/internal/web/static/app.js index 53568d8..b656e98 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -20,11 +20,21 @@ const picker = document.getElementById("preset-picker"); const deleteBtn = document.getElementById("preset-delete"); const includePw = document.getElementById("preset-include-pw"); + const labelInput = document.getElementById("connection-label"); const pwInput = document.getElementById("conn-password"); const eyeBtn = document.getElementById("toggle-password"); const dbType = form.querySelector('[name="dbType"]'); const port = form.querySelector('[name="port"]'); + const rawDSN = form.querySelector('[name="dsn"]'); const defaultPorts = { postgres: "5432", mysql: "3306" }; + const syncRawDSNMode = () => { + if (!rawDSN) return; + const usingRaw = rawDSN.value.trim() !== ""; + ["host", "port", "dbName", "user"].forEach((name) => { + const el = form.querySelector(`[name="${name}"]`); + if (el) el.required = !usingRaw; + }); + }; // Eye toggle: closed by default, click reveals if (eyeBtn && pwInput) { @@ -51,6 +61,10 @@ const el = form.querySelector(`[name="${k}"]`); if (el) el.value = v; } + const nameInput = document.getElementById("preset-name"); + if (nameInput) nameInput.value = picker.value; + if (labelInput) labelInput.value = picker.value; + syncRawDSNMode(); // Reset eye to closed after auto-fill, regardless of whether password // was loaded — never show secrets without an explicit click. if (eyeBtn) { @@ -65,6 +79,9 @@ if (next && (port.value === "" || known)) port.value = next; }); } + if (rawDSN) { + rawDSN.addEventListener("input", syncRawDSNMode); + } deleteBtn.addEventListener("click", () => { const all = loadPresets(); delete all[picker.value]; @@ -76,10 +93,11 @@ form.addEventListener("submit", () => { const nameInput = document.getElementById("preset-name"); const name = nameInput ? nameInput.value.trim() : ""; + if (labelInput) labelInput.value = name || picker.value || ""; if (!name) return; const data = new FormData(form); const preset = {}; - ["dbType", "host", "port", "dbName", "user", "ssl"].forEach((k) => { + ["dbType", "dsn", "host", "port", "dbName", "user", "ssl"].forEach((k) => { preset[k] = data.get(k) || ""; }); if (includePw && includePw.checked) { @@ -91,6 +109,107 @@ }); } + function presetConnectionInfo(name, p) { + p = p || {}; + return { + label: name || "", + dbType: p.dbType || "postgres", + dsn: p.dsn || "", + host: p.host || "", + port: Number(p.port || 0) || 0, + dbName: p.dbName || "", + user: p.user || "", + ssl: p.ssl || "", + }; + } + + function connectionKey(info) { + info = info || {}; + const dbType = String(info.dbType || "").toLowerCase(); + const dsn = String(info.dsn || "").trim(); + if (dsn && !info.host && !info.dbName && !info.user) return `dsn:${dbType}:${dsn}`; + return [ + dbType, + String(info.host || "").toLowerCase(), + String(info.port || ""), + String(info.dbName || ""), + String(info.user || ""), + ].join("|"); + } + + function connectionLabel(info) { + info = info || {}; + const name = info.label || info.dbName || "database"; + const host = info.host || "connection"; + const port = info.port ? `:${info.port}` : ""; + return `${name} @ ${host}${port}`; + } + + async function fetchConnections() { + try { + const res = await fetch("/api/connections"); + if (!res.ok) return []; + const conns = await res.json(); + return Array.isArray(conns) ? conns : []; + } catch (_) { + return []; + } + } + + async function setupConnectionMenuPresets() { + const list = document.getElementById("conn-preset-list"); + if (!list) return; + const presets = loadPresets(); + const live = await fetchConnections(); + const liveKeys = new Set(live.map((c) => connectionKey(c.info))); + const liveLabels = new Set(live.map((c) => c.info?.label).filter(Boolean)); + const names = Object.keys(presets).sort().filter((name) => { + const info = presetConnectionInfo(name, presets[name]); + return !liveLabels.has(name) && !liveKeys.has(connectionKey(info)); + }); + if (!names.length) return; + const heading = document.createElement("div"); + heading.className = "conn-menu-heading"; + heading.textContent = "Saved presets"; + list.appendChild(heading); + names.forEach((name) => { + const p = presets[name] || {}; + const form = document.createElement("form"); + form.method = "post"; + form.action = "/connect"; + form.className = "conn-menu-row"; + const fields = { + label: name, + dbType: p.dbType || "postgres", + dsn: p.dsn || "", + host: p.host || "", + port: p.port || "", + dbName: p.dbName || "", + user: p.user || "", + password: p.password || "", + ssl: p.ssl || "", + }; + Object.entries(fields).forEach(([k, v]) => { + const input = document.createElement("input"); + input.type = "hidden"; + input.name = k; + input.value = v; + form.appendChild(input); + }); + const btn = document.createElement("button"); + btn.type = "submit"; + btn.className = "conn-menu-btn"; + btn.disabled = !p.password && !p.dsn; + btn.innerHTML = + `` + + `
${escapeHTML(name)}` + + `${escapeHTML(p.dbName || p.dsn || "open preset")}
` + + (btn.disabled ? 'needs secret' : ""); + form.appendChild(btn); + list.appendChild(form); + }); + } + // ── shared job streaming ────────────────────────────────────────────── let elapsedTimer = null; function startElapsed() { @@ -652,6 +771,7 @@ modal: { table: "", limit: 50, offset: 0 }, peek: new Set(), schemaColumns: {}, + connections: [], }; function setupWorkspace() { @@ -672,6 +792,7 @@ document.querySelectorAll(".ws-mode-pill").forEach(x => x.classList.remove("active")); b.classList.add("active"); ws.mode = b.dataset.mode; + updateCloneControls(); recomputeAuto(); refreshSelectionUI(); }); @@ -710,9 +831,67 @@ // Run document.getElementById("ws-run").addEventListener("click", runMode); + loadCloneTargets(); fetch("/api/graph").then(r => r.json()).then(initGraph); } + async function loadCloneTargets() { + const target = document.getElementById("cfg-clone-target"); + if (!target) return; + ws.connections = await fetchConnections(); + const active = ws.connections.find(c => c.active); + target.innerHTML = ""; + const activeInfo = active?.info || {}; + const activeKey = connectionKey(activeInfo); + const seen = new Set([activeKey]); + ws.connections.filter(c => !c.active && (!active || c.info.dbType === active.info.dbType)).forEach((c) => { + const key = connectionKey(c.info); + if (seen.has(key)) return; + seen.add(key); + const opt = document.createElement("option"); + opt.value = c.id; + opt.dataset.kind = "connection"; + opt.textContent = connectionLabel(c.info); + target.appendChild(opt); + }); + const presets = loadPresets(); + Object.keys(presets).sort().forEach((name) => { + const p = presets[name] || {}; + const info = presetConnectionInfo(name, p); + const key = connectionKey(info); + if (active && info.dbType !== activeInfo.dbType) return; + if (seen.has(key)) return; + seen.add(key); + const opt = document.createElement("option"); + opt.value = name; + opt.dataset.kind = "preset"; + opt.dataset.preset = name; + opt.textContent = connectionLabel(info); + if (!p.password && !p.dsn) { + opt.disabled = true; + opt.textContent += " (needs secret)"; + } + target.appendChild(opt); + }); + if (!target.options.length) { + const opt = document.createElement("option"); + opt.value = ""; + opt.dataset.kind = "empty"; + opt.textContent = "connect another matching database"; + target.appendChild(opt); + } + updateCloneControls(); + } + + function updateCloneControls() { + const clone = ws.mode === "clone"; + const cloneCfg = document.getElementById("ws-clone-config"); + if (cloneCfg) cloneCfg.hidden = !clone; + document.querySelectorAll(".ws-config, .ws-risk").forEach((el) => { + el.hidden = clone; + }); + } + function activateTab(name) { document.querySelectorAll(".ws-tab").forEach((b) => { b.classList.toggle("active", b.dataset.tab === name); @@ -1182,7 +1361,8 @@ } function defaultRows() { - return Number(document.getElementById("cfg-rows")?.value || 0) || 20; + const value = Number(document.getElementById("cfg-rows")?.value); + return Number.isFinite(value) && value >= 0 ? value : 20; } function selectedChildCount(tableName) { @@ -1259,18 +1439,22 @@ const effective = explicit + auto; const scope = document.getElementById("ws-scope"); const run = document.getElementById("ws-run"); - const modeLabel = ws.mode === "gaps" ? "Fill empty" : (ws.mode === "generate" ? "Generate" : "Seed"); + const modeLabel = ws.mode === "gaps" ? "Fill empty" : (ws.mode === "generate" ? "Generate" : (ws.mode === "clone" ? "Clone schema" : "Seed")); if (scope) { const overrideCount = Object.keys(tableRowPayload()).length; const volumeText = overrideCount > 0 ? ` · ${overrideCount} customized` : ""; - scope.textContent = effective === 0 + scope.textContent = ws.mode === "clone" + ? "Run scope: full source schema" + : effective === 0 ? `Run scope: all ${total} tables` : `Run scope: ${effective} tables (${explicit} selected, ${auto} required)${volumeText}`; } if (run) { - run.textContent = effective === 0 ? `${modeLabel} all tables` : `${modeLabel} ${effective} tables`; + run.textContent = ws.mode === "clone" + ? modeLabel + : effective === 0 ? `${modeLabel} all tables` : `${modeLabel} ${effective} tables`; } } @@ -1559,6 +1743,9 @@ // ── run dispatcher ──────────────────────────────────────────────────── async function runMode() { + if (ws.mode === "clone") { + return runCloneSchema(); + } const tables = [...ws.selected]; const cfg = { rows: Number(document.getElementById("cfg-rows").value || 0), @@ -1598,6 +1785,43 @@ }); } + async function runCloneSchema() { + const target = document.getElementById("cfg-clone-target"); + const selected = target?.selectedOptions?.[0]; + const cfg = { + dropExisting: !!document.getElementById("cfg-clone-drop")?.checked, + dryRun: !!document.getElementById("cfg-clone-dryrun")?.checked, + }; + if (selected?.dataset.kind === "connection") { + cfg.targetId = selected.value; + } else if (selected?.dataset.kind === "preset") { + const name = selected.dataset.preset || selected.value; + const p = loadPresets()[name] || {}; + cfg.target = presetConnectionInfo(name, p); + cfg.targetDsn = p.dsn || ""; + cfg.password = p.password || ""; + } + activateTab("logs"); + resetPhases(); + document.getElementById("job-result").innerHTML = ""; + const res = await fetch("/api/clone-schema", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(cfg), + }); + const j = await res.json(); + if (!res.ok) { + appendLog("ERROR: " + (j.error || res.statusText)); + return; + } + streamJob(j.id, j.name, { + onEnd: (job) => { + const out = document.getElementById("job-result"); + if (out) renderJobResult(out, job.result || {}, "clone-schema"); + }, + }); + } + function tableRowPayload() { const effective = new Set([...ws.selected, ...ws.auto]); const out = {}; @@ -1656,6 +1880,7 @@ document.addEventListener("DOMContentLoaded", () => { setupConnectForm(); + setupConnectionMenuPresets(); setupRunForm(); setupWorkspace(); document.addEventListener("keydown", (ev) => { diff --git a/internal/web/static/style.css b/internal/web/static/style.css index 6eb9494..756bd76 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -103,9 +103,24 @@ body.modal-open { overflow: hidden; } text-align: left; } .conn-menu-btn:hover { background: var(--panel-2); border-color: var(--line); } +.conn-menu-btn:disabled { cursor: default; opacity: 0.78; } +.conn-menu-btn:disabled:hover { background: var(--panel-2); border-color: var(--accent); } .conn-menu-row.active .conn-menu-btn { background: var(--panel-2); border-color: var(--accent); } .conn-menu-text { display: flex; flex-direction: column; flex: 1; } .conn-menu-text strong { font-size: 13px; font-family: var(--mono); } +.conn-preset-list { + border-top: 1px solid var(--line); + margin-top: 6px; + padding-top: 6px; +} +.conn-menu-heading { + padding: 4px 8px 6px; + color: var(--muted); + font-family: var(--mono); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; +} .conn-menu-actions { display: flex; gap: 6px; border-top: 1px solid var(--line); @@ -1530,7 +1545,7 @@ textarea { font-family: var(--mono); font-size: 12px; } } .ws-mode { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 4px; flex: 1; min-width: 0; @@ -1576,6 +1591,12 @@ textarea { font-family: var(--mono); font-size: 12px; } .ws-risk { flex-wrap: wrap; } +.ws-config[hidden], +.ws-risk[hidden], +.ws-clone[hidden], +.ws-run-group[hidden] { + display: none !important; +} .field-tight { display: flex; flex-direction: column; gap: 2px; } .field-tight > span { color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; } .field-tight input[type="number"] { diff --git a/internal/web/templates/connect.html.tmpl b/internal/web/templates/connect.html.tmpl index 49a3fed..3d1ba1c 100644 --- a/internal/web/templates/connect.html.tmpl +++ b/internal/web/templates/connect.html.tmpl @@ -37,6 +37,7 @@
+ + +