diff --git a/transact-cli/src/main/java/dev/dbos/transact/cli/MigrateCommand.java b/transact-cli/src/main/java/dev/dbos/transact/cli/MigrateCommand.java index 631c2eef..7bbd4742 100644 --- a/transact-cli/src/main/java/dev/dbos/transact/cli/MigrateCommand.java +++ b/transact-cli/src/main/java/dev/dbos/transact/cli/MigrateCommand.java @@ -1,5 +1,6 @@ package dev.dbos.transact.cli; +import dev.dbos.transact.database.SystemDatabase; import dev.dbos.transact.migrations.MigrationManager; import java.io.PrintWriter; @@ -29,6 +30,19 @@ public class MigrateCommand implements Callable { "Use LISTEN/NOTIFY on the DBOS system database [default: ${DEFAULT-VALUE}]. Use --no-listen-notify to disable.") boolean useListenNotify; + @Option( + names = {"--print-migrations"}, + paramLabel = "[all|NUMBER]", + description = + "Print the SQL of all migrations ('--print-migrations all') or of migrations from a number onward ('--print-migrations 3') instead of running them") + String printMigrations; + + @Option( + names = {"--print-user-role"}, + description = + "Print the SQL granting the application role (--app-role) access to DBOS system tables instead of executing it") + boolean printUserRole; + @Mixin DatabaseOptions dbOptions; @Option( @@ -39,9 +53,26 @@ public class MigrateCommand implements Callable { @Spec CommandSpec spec; + static final String[] GRANT_QUERIES = { + "GRANT USAGE ON SCHEMA \"%1$s\" TO \"%2$s\"", + "GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA \"%1$s\" TO \"%2$s\"", + "GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA \"%1$s\" TO \"%2$s\"", + "GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA \"%1$s\" TO \"%2$s\"", + "ALTER DEFAULT PRIVILEGES IN SCHEMA \"%1$s\" GRANT ALL ON TABLES TO \"%2$s\"", + "ALTER DEFAULT PRIVILEGES IN SCHEMA \"%1$s\" GRANT ALL ON SEQUENCES TO \"%2$s\"", + "ALTER DEFAULT PRIVILEGES IN SCHEMA \"%1$s\" GRANT EXECUTE ON FUNCTIONS TO \"%2$s\"" + }; + @Override public Integer call() throws Exception { var out = spec.commandLine().getOut(); + + if (printMigrations != null || printUserRole) { + var exitCode = printSql(out, spec.commandLine().getErr()); + out.flush(); + return exitCode; + } + out.println("Starting DBOS migrations"); out.format(" System Database: %s\n", dbOptions.url()); out.format(" System Database User: %s\n", dbOptions.user()); @@ -56,29 +87,83 @@ public Integer call() throws Exception { return 0; } + // Stdout stays pure SQL and comments (pipeable to a .sql file); never connects. + int printSql(PrintWriter out, PrintWriter err) { + if (printMigrations != null && printUserRole) { + err.println("--print-user-role cannot be combined with --print-migrations"); + return 1; + } + var schema = SystemDatabase.sanitizeSchema(dbOptions.schema()); + if (schema.contains("'") || schema.contains("\"")) { + err.println("Schema names containing quotes are not supported"); + return 1; + } + + if (printUserRole) { + if (appRole == null || appRole.isEmpty()) { + err.println("--print-user-role requires --app-role"); + return 1; + } + if (appRole.contains("'") || appRole.contains("\"")) { + err.println("Role names containing quotes are not supported"); + return 1; + } + out.format("-- Permissions on DBOS schema %s for role %s%n", schema, appRole); + for (var query : GRANT_QUERIES) { + out.println(query.formatted(schema, appRole) + ";"); + } + return 0; + } + + var latest = MigrationManager.getMigrations(schema, useListenNotify, false).size(); + int start; + if (printMigrations.equals("all")) { + start = 1; + } else { + try { + start = Integer.parseInt(printMigrations); + } catch (NumberFormatException e) { + err.format( + "Invalid --print-migrations value '%s': expected 'all' or a migration number%n", + printMigrations); + return 1; + } + if (start < 1 || start > latest) { + err.format( + "Migration %d does not exist: valid migrations are 1 through %d%n", start, latest); + return 1; + } + } + + out.format("-- DBOS system database migrations for %s%n", maskPassword(dbOptions.url())); + out.println( + "-- Contains CREATE/DROP INDEX CONCURRENTLY: run outside a transaction block (e.g. plain psql, not psql --single-transaction)."); + out.print(MigrationManager.generateMigrationScript(schema, useListenNotify, start)); + return 0; + } + + static String maskPassword(String url) { + if (url == null) { + return "the system database"; + } + return url.replaceAll("(?i)(password=)[^&]*", "$1***"); + } + void grantDBOSSchemaPermissions(PrintWriter out, String schema) throws SQLException { if (appRole == null || appRole.isEmpty()) { return; } + schema = SystemDatabase.sanitizeSchema(schema); out.format( "Granting permissions for the %s schema to %s in database %s\n", schema, appRole, dbOptions.url()); - String[] queries = { - "GRANT USAGE ON SCHEMA %s TO %s", - "GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA %s TO %s", - "GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA %s TO %s", - "GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA %s TO %s", - "ALTER DEFAULT PRIVILEGES IN SCHEMA %s GRANT ALL ON TABLES TO %s", - "ALTER DEFAULT PRIVILEGES IN SCHEMA %s GRANT ALL ON SEQUENCES TO %s", - "ALTER DEFAULT PRIVILEGES IN SCHEMA %s GRANT EXECUTE ON FUNCTIONS TO %s" - }; try (var conn = DriverManager.getConnection(dbOptions.url(), dbOptions.user(), dbOptions.password()); var stmt = conn.createStatement()) { - for (var query : queries) { + for (var query : GRANT_QUERIES) { query = query.formatted(schema, appRole); stmt.execute(query); } diff --git a/transact-cli/src/test/java/dev/dbos/transact/cli/MigrateCommandTest.java b/transact-cli/src/test/java/dev/dbos/transact/cli/MigrateCommandTest.java index 13aa042c..7963c05c 100644 --- a/transact-cli/src/test/java/dev/dbos/transact/cli/MigrateCommandTest.java +++ b/transact-cli/src/test/java/dev/dbos/transact/cli/MigrateCommandTest.java @@ -1,13 +1,16 @@ package dev.dbos.transact.cli; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import dev.dbos.transact.Constants; import dev.dbos.transact.database.SystemDatabase; +import dev.dbos.transact.migrations.MigrationManager; import java.io.PrintWriter; import java.io.StringWriter; +import java.sql.DriverManager; import java.sql.SQLException; import java.util.Collection; import java.util.List; @@ -105,6 +108,134 @@ public void migrate_custom_schema(String schema) throws Exception { assertTrue(checkTable(schema, "workflow_status")); } + @Test + public void migrate_print_migrations_apply_funny_schema() throws Exception { + var schema = "F8nny_sCHem@-n@m3"; + + var script = runPrint("--schema", schema, "--print-migrations", "all"); + assertTrue(script.contains("-- Migration 10 skipped: not applicable on fresh databases")); + assertFalse(script.contains("ADD PRIMARY KEY (message_uuid)")); + assertFalse(script.contains("DO $$")); + + // Apply the printed script to a fresh database with psql ON_ERROR_STOP. + var applied = pgContainer.execPsql(script); + assertEquals(0, applied.getExitCode(), applied.getStderr()); + + assertTrue(checkTable(schema, "dbos_migrations")); + assertTrue(checkTable(schema, "workflow_status")); + assertTrue(checkTable(schema, "notifications")); + + var latest = MigrationManager.getMigrations(schema, true, false).size(); + assertEquals(latest, currentVersion(schema)); + + // A real migration run now considers the database up to date. + var cmd = new CommandLine(new DBOSCommand()); + cmd.setOut(new PrintWriter(new StringWriter())); + var args = + Stream.of(List.of("migrate", "--schema", schema), pgContainer.options()) + .flatMap(Collection::stream) + .toArray(String[]::new); + assertEquals(0, cmd.execute(args)); + assertEquals(latest, currentVersion(schema)); + } + + @Test + public void migrate_print_from_migration() throws Exception { + var schema = Constants.DB_SCHEMA; + var latest = MigrationManager.getMigrations(schema, true, false).size(); + + // Bring a fresh database to version latest-1 by truncating the full script. + var full = runPrint("--print-migrations", "all"); + var marker = "UPDATE \"%s\".dbos_migrations SET version = %d;".formatted(schema, latest - 1); + var idx = full.indexOf(marker); + assertTrue(idx > 0); + var appliedPartial = pgContainer.execPsql(full.substring(0, idx + marker.length()) + "\n"); + assertEquals(0, appliedPartial.getExitCode(), appliedPartial.getStderr()); + assertEquals(latest - 1, currentVersion(schema)); + + // The last migration printed alone applies on top of version latest-1. + var single = runPrint("--print-migrations", String.valueOf(latest)); + assertFalse(single.contains("CREATE SCHEMA")); + assertFalse(single.contains("DO $$")); + assertFalse(single.contains("INSERT INTO \"%s\".dbos_migrations".formatted(schema))); + var applied = pgContainer.execPsql(single); + assertEquals(0, applied.getExitCode(), applied.getStderr()); + assertEquals(latest, currentVersion(schema)); + } + + @Test + public void migrate_print_user_role_grants_access() throws Exception { + var schema = "F8nny_sCHem@-n@m3"; + var role = "my-app-role"; + + var script = runPrint("--schema", schema, "--print-migrations", "all"); + var roleScript = runPrint("--schema", schema, "--print-user-role", "--app-role", role); + assertTrue( + roleScript.contains("GRANT USAGE ON SCHEMA \"%s\" TO \"%s\";".formatted(schema, role))); + for (var line : roleScript.split("\n")) { + assertTrue( + line.startsWith("--") || line.startsWith("GRANT") || line.startsWith("ALTER"), + "unexpected output: " + line); + } + + try (var conn = pgContainer.connection(); + var stmt = conn.createStatement()) { + stmt.execute("DROP ROLE IF EXISTS \"%s\"".formatted(role)); + stmt.execute("CREATE ROLE \"%s\" LOGIN PASSWORD 'app_role_pwd'".formatted(role)); + } + try { + for (var s : List.of(script, roleScript)) { + var applied = pgContainer.execPsql(s); + assertEquals(0, applied.getExitCode(), applied.getStderr()); + } + + // The app role can query the DBOS schema. + var latest = MigrationManager.getMigrations(schema, true, false).size(); + try (var conn = DriverManager.getConnection(pgContainer.jdbcUrl(), role, "app_role_pwd"); + var stmt = conn.createStatement(); + var rs = + stmt.executeQuery("SELECT version FROM \"%s\".dbos_migrations".formatted(schema))) { + assertTrue(rs.next()); + assertEquals(latest, rs.getInt(1)); + } + } finally { + try (var conn = pgContainer.connection(); + var stmt = conn.createStatement()) { + stmt.execute("DROP OWNED BY \"%s\"".formatted(role)); + stmt.execute("DROP ROLE \"%s\"".formatted(role)); + } + } + } + + String runPrint(String... printArgs) { + var cmd = new CommandLine(new DBOSCommand()); + var sw = new StringWriter(); + var ew = new StringWriter(); + cmd.setOut(new PrintWriter(sw)); + cmd.setErr(new PrintWriter(ew)); + + var args = + Stream.of(List.of("migrate"), List.of(printArgs), pgContainer.options()) + .flatMap(Collection::stream) + .toArray(String[]::new); + + assertEquals(0, cmd.execute(args), ew.toString()); + assertEquals("", ew.toString()); + return sw.toString(); + } + + int currentVersion(String schema) throws SQLException { + try (var conn = pgContainer.connection(); + var stmt = conn.createStatement(); + var rs = + stmt.executeQuery("SELECT version FROM \"%s\".dbos_migrations".formatted(schema))) { + assertTrue(rs.next()); + var version = rs.getInt(1); + assertFalse(rs.next()); + return version; + } + } + boolean checkTable(String schema, String table) throws SQLException { var sql = "SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = ? AND table_name = ?)"; diff --git a/transact-cli/src/test/java/dev/dbos/transact/cli/MigratePrintTest.java b/transact-cli/src/test/java/dev/dbos/transact/cli/MigratePrintTest.java new file mode 100644 index 00000000..940b2eab --- /dev/null +++ b/transact-cli/src/test/java/dev/dbos/transact/cli/MigratePrintTest.java @@ -0,0 +1,196 @@ +package dev.dbos.transact.cli; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.dbos.transact.migrations.MigrationManager; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import picocli.CommandLine; + +// Runs without a database; the print flags must never connect. +public class MigratePrintTest { + + record Result(int exitCode, String out, String err) {} + + static Result run(String... args) { + var cmd = new CommandLine(new DBOSCommand()); + var sw = new StringWriter(); + var ew = new StringWriter(); + cmd.setOut(new PrintWriter(sw)); + cmd.setErr(new PrintWriter(ew)); + var exitCode = cmd.execute(args); + return new Result(exitCode, sw.toString(), ew.toString()); + } + + @Test + public void printMigrationsAll() { + var r = run("migrate", "--print-migrations", "all"); + assertEquals(0, r.exitCode()); + // Stdout is pure SQL and comments, pipeable to a .sql file. + assertEquals("", r.err()); + + var sql = r.out(); + var latest = MigrationManager.getMigrations("dbos", true, false).size(); + assertTrue(sql.startsWith("-- DBOS system database migrations for ")); + assertTrue( + sql.contains( + "-- Contains CREATE/DROP INDEX CONCURRENTLY: run outside a transaction block (e.g. plain psql, not psql --single-transaction).")); + assertTrue(sql.contains("-- This script is for FRESH databases only.")); + assertTrue(sql.contains("CREATE SCHEMA IF NOT EXISTS \"dbos\";")); + assertTrue( + sql.contains( + "CREATE TABLE IF NOT EXISTS \"dbos\".dbos_migrations (version BIGINT NOT NULL PRIMARY KEY);")); + assertTrue(sql.contains("CREATE TABLE \"dbos\".workflow_status")); + assertTrue(sql.contains("INSERT INTO \"dbos\".dbos_migrations (version) VALUES (1);")); + assertTrue(sql.contains("-- Migration 10 skipped: not applicable on fresh databases")); + assertFalse(sql.contains("ADD PRIMARY KEY (message_uuid)")); + assertTrue(sql.contains("UPDATE \"dbos\".dbos_migrations SET version = 10;")); + assertTrue(sql.contains("UPDATE \"dbos\".dbos_migrations SET version = %d;".formatted(latest))); + assertFalse(sql.contains("DO $$")); + // Role grants are only printed by --print-user-role + assertFalse(sql.contains("GRANT")); + for (var line : sql.split("\n")) { + assertFalse(line.startsWith("Starting"), "unexpected non-SQL output: " + line); + assertFalse(line.startsWith("Granting"), "unexpected non-SQL output: " + line); + } + } + + @Test + public void printMigrationsFromNumber() { + // Starting from 1 is identical to "all". + assertEquals( + run("migrate", "--print-migrations", "all").out(), + run("migrate", "--print-migrations", "1").out()); + + // Starting mid-way omits the prelude and earlier migrations. + var latest = MigrationManager.getMigrations("dbos", true, false).size(); + var r = run("migrate", "--print-migrations", "10"); + assertEquals(0, r.exitCode()); + assertEquals("", r.err()); + var out = r.out(); + assertFalse(out.contains("CREATE SCHEMA")); + assertFalse(out.contains("-- Migration 9\n")); + assertTrue(out.contains("-- Migration 10 skipped: not applicable on fresh databases")); + assertTrue(out.contains("UPDATE \"dbos\".dbos_migrations SET version = 10;")); + assertTrue(List.of(out.split("\n")).contains("-- Migration 11")); + assertTrue(out.contains("UPDATE \"dbos\".dbos_migrations SET version = %d;".formatted(latest))); + assertFalse(out.contains("INSERT INTO \"dbos\".dbos_migrations")); + assertFalse(out.contains("DO $$")); + } + + @Test + public void printMigrationsInvalidValues() { + var latest = MigrationManager.getMigrations("dbos", true, false).size(); + for (var bad : List.of("0", "-1", String.valueOf(latest + 1))) { + var r = run("migrate", "--print-migrations", bad); + assertEquals(1, r.exitCode()); + assertEquals("", r.out()); + assertTrue( + r.err().contains("does not exist: valid migrations are 1 through %d".formatted(latest)), + r.err()); + } + + var r = run("migrate", "--print-migrations", "nope"); + assertEquals(1, r.exitCode()); + assertEquals("", r.out()); + assertTrue( + r.err() + .contains( + "Invalid --print-migrations value 'nope': expected 'all' or a migration number"), + r.err()); + + assertThrows( + IllegalArgumentException.class, + () -> MigrationManager.generateMigrationScript("dbos", true, 0)); + assertThrows( + IllegalArgumentException.class, + () -> MigrationManager.generateMigrationScript("dbos", true, latest + 1)); + } + + @Test + public void printMigrationsFunnySchema() { + var schema = "F8nny_sCHem@-n@m3"; + var r = run("migrate", "--print-migrations", "all", "--schema", schema); + assertEquals(0, r.exitCode()); + assertEquals("", r.err()); + + var sql = r.out(); + assertTrue(sql.contains("CREATE SCHEMA IF NOT EXISTS \"%s\";".formatted(schema))); + assertTrue(sql.contains("CREATE TABLE \"%s\".workflow_status".formatted(schema))); + // The schema never appears unquoted in an identifier position (schema immediately + // followed by a dot only happens without the closing double quote). + assertFalse(sql.contains(schema + ".")); + + var latest = MigrationManager.getMigrations(schema, true, false).size(); + assertTrue( + sql.contains("UPDATE \"%s\".dbos_migrations SET version = %d;".formatted(schema, latest))); + } + + @Test + public void printUserRole() { + var r = run("migrate", "--print-user-role", "--schema", "custom", "--app-role", "app_user"); + assertEquals(0, r.exitCode()); + assertEquals("", r.err()); + + var sql = r.out(); + assertTrue(sql.startsWith("-- Permissions on DBOS schema custom for role app_user")); + assertTrue(sql.contains("GRANT USAGE ON SCHEMA \"custom\" TO \"app_user\";")); + assertTrue( + sql.contains( + "ALTER DEFAULT PRIVILEGES IN SCHEMA \"custom\" GRANT EXECUTE ON FUNCTIONS TO \"app_user\";")); + for (var line : sql.split("\n")) { + assertTrue( + line.startsWith("--") || line.startsWith("GRANT") || line.startsWith("ALTER"), + "unexpected output: " + line); + } + } + + @Test + public void printUserRoleRequiresAppRole() { + var r = run("migrate", "--print-user-role"); + assertEquals(1, r.exitCode()); + assertEquals("", r.out()); + assertTrue(r.err().contains("--print-user-role requires --app-role"), r.err()); + } + + @Test + public void printFlagsAreMutuallyExclusive() { + var r = run("migrate", "--print-migrations", "all", "--print-user-role", "-r", "app_user"); + assertEquals(1, r.exitCode()); + assertEquals("", r.out()); + assertTrue( + r.err().contains("--print-user-role cannot be combined with --print-migrations"), r.err()); + } + + @ParameterizedTest + @ValueSource(strings = {"bad\"schema", "bad'schema"}) + public void printInvalidSchemaFails(String schema) { + var r = run("migrate", "--print-migrations", "all", "--schema", schema); + assertEquals(1, r.exitCode()); + assertEquals("", r.out()); + assertTrue(r.err().contains("Schema names containing quotes are not supported"), r.err()); + + r = run("migrate", "--print-user-role", "-r", "app_user", "--schema", schema); + assertEquals(1, r.exitCode()); + assertEquals("", r.out()); + assertTrue(r.err().contains("Schema names containing quotes are not supported"), r.err()); + } + + @ParameterizedTest + @ValueSource(strings = {"bad\"role", "bad'role"}) + public void printInvalidRoleFails(String role) { + var r = run("migrate", "--print-user-role", "-r", role); + assertEquals(1, r.exitCode()); + assertEquals("", r.out()); + assertTrue(r.err().contains("Role names containing quotes are not supported"), r.err()); + } +} diff --git a/transact-cli/src/test/java/dev/dbos/transact/cli/PgContainer.java b/transact-cli/src/test/java/dev/dbos/transact/cli/PgContainer.java index 3db061be..0a016d99 100644 --- a/transact-cli/src/test/java/dev/dbos/transact/cli/PgContainer.java +++ b/transact-cli/src/test/java/dev/dbos/transact/cli/PgContainer.java @@ -10,6 +10,8 @@ import java.util.concurrent.BlockingQueue; import java.util.concurrent.Semaphore; +import org.testcontainers.containers.Container; +import org.testcontainers.images.builder.Transferable; import org.testcontainers.postgresql.PostgreSQLContainer; public class PgContainer implements AutoCloseable { @@ -96,6 +98,14 @@ public Connection connection() throws SQLException { return DriverManager.getConnection(jdbcUrl(), username(), password()); } + /** Applies a SQL script with psql -v ON_ERROR_STOP=1 inside the container. */ + public Container.ExecResult execPsql(String script) throws Exception { + var path = "/tmp/" + UUID.randomUUID() + ".sql"; + pgContainer.copyFileToContainer(Transferable.of(script), path); + return pgContainer.execInContainer( + "psql", "-v", "ON_ERROR_STOP=1", "-U", username(), "-d", dbName, "-f", path); + } + public List options() { return List.of(urlOption(), userOption(), passwordOption()); } diff --git a/transact/src/main/java/dev/dbos/transact/migrations/MigrationManager.java b/transact/src/main/java/dev/dbos/transact/migrations/MigrationManager.java index 10f7bb28..197983fb 100644 --- a/transact/src/main/java/dev/dbos/transact/migrations/MigrationManager.java +++ b/transact/src/main/java/dev/dbos/transact/migrations/MigrationManager.java @@ -54,6 +54,68 @@ public static void runMigrations( } } + /** Generates the SQL script of all migrations, for fresh Postgres databases only. */ + public static String generateMigrationScript(String schema, boolean useListenNotify) { + return generateMigrationScript(schema, useListenNotify, 1); + } + + /** + * Generates the SQL script of migrations {@code startMigration} (1-based, inclusive) through + * latest, with dbos_migrations version bookkeeping mirroring the runner. When {@code + * startMigration} is 1 the script includes the schema and dbos_migrations prelude and is for + * fresh databases only. Requires no connection. + */ + public static String generateMigrationScript( + String schema, boolean useListenNotify, int startMigration) { + schema = SystemDatabase.sanitizeSchema(schema); + if (schema.contains("'") || schema.contains("\"")) { + throw new IllegalArgumentException("Schema name must not contain single or double quotes"); + } + + var migrations = getMigrations(schema, useListenNotify, false); + var latest = migrations.size(); + if (startMigration < 1 || startMigration > latest) { + throw new IllegalArgumentException( + "startMigration must be between 1 and %d, got %d".formatted(latest, startMigration)); + } + + var sb = new StringBuilder(); + if (startMigration == 1) { + sb.append("-- This script is for FRESH databases only.\n"); + sb.append("CREATE SCHEMA IF NOT EXISTS \"%s\";\n".formatted(schema)); + sb.append( + "CREATE TABLE IF NOT EXISTS \"%s\".dbos_migrations (version BIGINT NOT NULL PRIMARY KEY);\n" + .formatted(schema)); + } + + var versionRowExists = startMigration > 1; + for (var i = startMigration; i <= latest; i++) { + var sql = migrations.get(i - 1).strip(); + if (i == 10) { + // Migration 10 backfills the notifications primary key, which + // migration 1 already creates on a fresh database. + sb.append("-- Migration 10 skipped: not applicable on fresh databases\n"); + } else if (!sql.isEmpty()) { + sb.append("-- Migration %d\n".formatted(i)); + sb.append(sql); + if (!sql.endsWith(";")) { + sb.append(';'); + } + sb.append('\n'); + } + // Per-migration version bookkeeping, mirroring the runner: an + // interrupted apply can be resumed from the next migration number. + if (versionRowExists) { + sb.append("UPDATE \"%s\".dbos_migrations SET version = %d;\n".formatted(schema, i)); + } else { + sb.append( + "INSERT INTO \"%s\".dbos_migrations (version) VALUES (%d);\n".formatted(schema, i)); + versionRowExists = true; + } + } + return sb.toString(); + } + private static boolean shouldMigrate( Connection conn, String schema, boolean useListenNotify, boolean isCockroach) throws SQLException {