From 71e258496cca1d74797b8793adb36e36535b9424 Mon Sep 17 00:00:00 2001 From: Mark Saward Date: Sat, 7 Mar 2026 13:18:19 +1100 Subject: [PATCH 1/7] fix spawn to actually use the spawn_database config value --- .../content/docs/guides/manage-databases.md | 2 +- docs/src/content/docs/reference/config.md | 4 +- mise.toml | 4 + src/commands/init.rs | 2 +- src/engine/mod.rs | 2 +- src/engine/postgres_psql.rs | 165 +++++++++--------- tests/integration_postgres.rs | 2 +- tests/migration_build.rs | 2 +- 8 files changed, 94 insertions(+), 89 deletions(-) diff --git a/docs/src/content/docs/guides/manage-databases.md b/docs/src/content/docs/guides/manage-databases.md index f841407..cd7aa27 100644 --- a/docs/src/content/docs/guides/manage-databases.md +++ b/docs/src/content/docs/guides/manage-databases.md @@ -10,7 +10,7 @@ Spawn requires a database connection to apply migrations and run tests. Database Each database configuration requires: - `engine`: The database engine type (currently only `"postgres-psql"`) -- `spawn_database`: The database name to connect to +- `spawn_database`: The database where spawn stores migration tracking (defaults to using whichever database your command connects to by default) - `spawn_schema`: The schema where spawn stores migration tracking (default: `"_spawn"`) - `environment`: Environment name (e.g., `"dev"`, `"prod"`) - `command`: How to execute SQL commands (see below) diff --git a/docs/src/content/docs/reference/config.md b/docs/src/content/docs/reference/config.md index 02d0ea5..59cf50d 100644 --- a/docs/src/content/docs/reference/config.md +++ b/docs/src/content/docs/reference/config.md @@ -111,9 +111,9 @@ engine = "postgres-psql" ### `spawn_database` **Type:** String -**Required:** Yes +**Required:** No -The database name where Spawn stores migration tracking tables (in the `spawn_schema`). +The database name where Spawn stores migration tracking tables (in the `spawn_schema`). If not provided, defaults to using the same database that your connection command uses. ```toml spawn_database = "spawn" diff --git a/mise.toml b/mise.toml index db8dcba..eac2aa0 100644 --- a/mise.toml +++ b/mise.toml @@ -9,6 +9,10 @@ pnpm = "10" # Pinned to major version # Ensure pnpm uses local node_modules _.path = ["./node_modules/.bin"] +[tasks.test] +description = "Run unit tests then integration tests" +run = ["cargo test", "cargo test -- --ignored"] + [tasks.docs] description = "Run the documentation dev server" dir = "docs" diff --git a/src/commands/init.rs b/src/commands/init.rs index 5197d9e..93b905d 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -56,7 +56,7 @@ impl Init { "postgres_psql".to_string(), DatabaseConfig { engine: EngineType::PostgresPSQL, - spawn_database: db_name.clone(), + spawn_database: Some(db_name.clone()), spawn_schema: "_spawn".to_string(), environment: "dev".to_string(), command: Some(CommandSpec::Direct { diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 4b4d0c3..6a967a8 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -233,7 +233,7 @@ pub enum CommandSpec { #[derive(Clone, Debug, Deserialize, Serialize)] pub struct DatabaseConfig { pub engine: EngineType, - pub spawn_database: String, + pub spawn_database: Option, #[serde(default = "default_schema")] pub spawn_schema: String, #[serde(default = "default_environment")] diff --git a/src/engine/postgres_psql.rs b/src/engine/postgres_psql.rs index e9c96b8..9c99c2d 100644 --- a/src/engine/postgres_psql.rs +++ b/src/engine/postgres_psql.rs @@ -35,8 +35,6 @@ pub fn migration_lock_key() -> i64 { #[derive(Debug)] pub struct PSQL { psql_command: Vec, - /// Schema name as an escaped identifier (for use as schema.table) - spawn_schema: String, db_config: DatabaseConfig, } @@ -54,7 +52,6 @@ impl PSQL { let eng = Box::new(Self { psql_command, - spawn_schema: config.spawn_schema.clone(), db_config: config.clone(), }); @@ -67,16 +64,95 @@ impl PSQL { } fn spawn_schema_literal(&self) -> EscapedLiteral { - EscapedLiteral::new(&self.spawn_schema) + EscapedLiteral::new(&self.db_config.spawn_schema) } fn spawn_schema_ident(&self) -> EscapedIdentifier { - EscapedIdentifier::new(&self.spawn_schema) + EscapedIdentifier::new(&self.db_config.spawn_schema) } fn safe_spawn_namespace(&self) -> EscapedLiteral { EscapedLiteral::new(SPAWN_NAMESPACE) } + + fn build_record_migration_sql( + &self, + migration_name: &str, + namespace: &EscapedLiteral, + status: MigrationStatus, + activity: MigrationActivity, + checksum: Option<&str>, + execution_time: Option, + pin_hash: Option<&str>, + description: Option<&str>, + ) -> EscapedQuery { + let safe_migration_name = EscapedLiteral::new(migration_name); + let safe_status = EscapedLiteral::new(status.as_str()); + let safe_activity = EscapedLiteral::new(activity.as_str()); + let safe_description = EscapedLiteral::new(description.unwrap_or("")); + // If no checksum provided, use empty bytea (decode returns empty bytea for empty string) + let checksum_expr = checksum + .map(|c| format!("decode('{}', 'hex')", c)) + .unwrap_or_else(|| "decode('', 'hex')".to_string()); + let checksum_raw = InsecureRawSql::new(&checksum_expr); + let safe_pin_hash = pin_hash.map(|h| EscapedLiteral::new(h)); + + let db_connect = if let Some(spawn_db) = &self.db_config.spawn_database { + InsecureRawSql::new(&format!("\\c {}", EscapedIdentifier::new(spawn_db))) + } else { + InsecureRawSql::new("") + }; + + let duration_interval = execution_time + .map(|d| InsecureRawSql::new(&format!("INTERVAL '{} second'", d))) + .unwrap_or_else(|| InsecureRawSql::new("INTERVAL '0 second'")); + + sql_query!( + r#" + BEGIN; + {} + WITH inserted_migration AS ( + INSERT INTO {}.migration (name, namespace) VALUES ({}, {}) + ON CONFLICT (name, namespace) DO UPDATE SET name = EXCLUDED.name + RETURNING migration_id + ) + INSERT INTO {}.migration_history ( + migration_id_migration, + activity_id_activity, + created_by, + description, + status_note, + status_id_status, + checksum, + execution_time, + pin_hash + ) + SELECT + migration_id, + {}, + 'unused', + {}, + '', + {}, + {}, + {}, + {} + FROM inserted_migration; + COMMIT; + "#, + db_connect, + self.spawn_schema_ident(), + safe_migration_name, + namespace, + self.spawn_schema_ident(), + safe_activity, + safe_description, + safe_status, + checksum_raw, + duration_interval, + safe_pin_hash, + ) + } } #[async_trait] @@ -420,80 +496,6 @@ impl Write for TeeWriter { } } -/// Free function to build the SQL query for recording a migration. -/// This can be used from both async and sync contexts. -fn build_record_migration_sql( - spawn_schema: &str, - migration_name: &str, - namespace: &EscapedLiteral, - status: MigrationStatus, - activity: MigrationActivity, - checksum: Option<&str>, - execution_time: Option, - pin_hash: Option<&str>, - description: Option<&str>, -) -> EscapedQuery { - let schema_ident = EscapedIdentifier::new(spawn_schema); - let safe_migration_name = EscapedLiteral::new(migration_name); - let safe_status = EscapedLiteral::new(status.as_str()); - let safe_activity = EscapedLiteral::new(activity.as_str()); - let safe_description = EscapedLiteral::new(description.unwrap_or("")); - // If no checksum provided, use empty bytea (decode returns empty bytea for empty string) - let checksum_expr = checksum - .map(|c| format!("decode('{}', 'hex')", c)) - .unwrap_or_else(|| "decode('', 'hex')".to_string()); - let checksum_raw = InsecureRawSql::new(&checksum_expr); - let safe_pin_hash = pin_hash.map(|h| EscapedLiteral::new(h)); - - let duration_interval = execution_time - .map(|d| InsecureRawSql::new(&format!("INTERVAL '{} second'", d))) - .unwrap_or_else(|| InsecureRawSql::new("INTERVAL '0 second'")); - - sql_query!( - r#" -BEGIN; -WITH inserted_migration AS ( - INSERT INTO {}.migration (name, namespace) VALUES ({}, {}) - ON CONFLICT (name, namespace) DO UPDATE SET name = EXCLUDED.name - RETURNING migration_id -) -INSERT INTO {}.migration_history ( - migration_id_migration, - activity_id_activity, - created_by, - description, - status_note, - status_id_status, - checksum, - execution_time, - pin_hash -) -SELECT - migration_id, - {}, - 'unused', - {}, - '', - {}, - {}, - {}, - {} -FROM inserted_migration; -COMMIT; -"#, - schema_ident, - safe_migration_name, - namespace, - schema_ident, - safe_activity, - safe_description, - safe_status, - checksum_raw, - duration_interval, - safe_pin_hash, - ) -} - impl PSQL { pub async fn update_schema(&self) -> Result<()> { // Create a memory operator from the included directory containing @@ -559,7 +561,7 @@ impl PSQL { // Load and render the migration let variables = crate::variables::Variables::from_str( "json", - &serde_json::json!({"schema": &self.spawn_schema}).to_string(), + &serde_json::json!({"schema": &self.db_config.spawn_schema}).to_string(), )?; let gen = migrator.generate_streaming(Some(variables)).await?; let mut buffer = Vec::new(); @@ -742,8 +744,7 @@ impl PSQL { pin_hash: Option<&str>, description: Option<&str>, ) -> MigrationResult<()> { - let record_query = build_record_migration_sql( - &self.spawn_schema, + let record_query = self.build_record_migration_sql( migration_name, namespace, status, diff --git a/tests/integration_postgres.rs b/tests/integration_postgres.rs index 45322ec..f2d9f73 100644 --- a/tests/integration_postgres.rs +++ b/tests/integration_postgres.rs @@ -225,7 +225,7 @@ impl IntegrationTestHelper { "postgres_psql".to_string(), DatabaseConfig { engine: EngineType::PostgresPSQL, - spawn_database: db_name.to_string(), + spawn_database: Some(db_name.to_string()), spawn_schema: "_spawn".to_string(), environment: "test".to_string(), command: Some(CommandSpec::Direct { diff --git a/tests/migration_build.rs b/tests/migration_build.rs index 7e07107..32dde88 100644 --- a/tests/migration_build.rs +++ b/tests/migration_build.rs @@ -76,7 +76,7 @@ impl MigrationTestHelper { "postgres_psql".to_string(), DatabaseConfig { engine: EngineType::PostgresPSQL, - spawn_database: "spawn".to_string(), + spawn_database: Some("spawn".to_string()), spawn_schema: "public".to_string(), environment: "dev".to_string(), command: Some(CommandSpec::Direct { From 8dacda76d3726515185078dcb5efa087f697652b Mon Sep 17 00:00:00 2001 From: Mark Saward Date: Sat, 7 Mar 2026 16:53:46 +1100 Subject: [PATCH 2/7] attempting test for spawn db field --- .../content/docs/guides/manage-databases.md | 4 +- docs/src/content/docs/reference/config.md | 4 +- src/engine/postgres_psql.rs | 33 ++- tests/integration_postgres.rs | 235 ++++++++++++++++++ 4 files changed, 261 insertions(+), 15 deletions(-) diff --git a/docs/src/content/docs/guides/manage-databases.md b/docs/src/content/docs/guides/manage-databases.md index cd7aa27..d30756b 100644 --- a/docs/src/content/docs/guides/manage-databases.md +++ b/docs/src/content/docs/guides/manage-databases.md @@ -10,8 +10,8 @@ Spawn requires a database connection to apply migrations and run tests. Database Each database configuration requires: - `engine`: The database engine type (currently only `"postgres-psql"`) -- `spawn_database`: The database where spawn stores migration tracking (defaults to using whichever database your command connects to by default) -- `spawn_schema`: The schema where spawn stores migration tracking (default: `"_spawn"`) +- `spawn_database`: The database where spawn stores migration tracking (defaults to using whichever database your command connects to by default). This database must already exist. +- `spawn_schema`: The schema where spawn stores migration tracking (default: `"_spawn"`). This schema will be created if it does not exist. - `environment`: Environment name (e.g., `"dev"`, `"prod"`) - `command`: How to execute SQL commands (see below) diff --git a/docs/src/content/docs/reference/config.md b/docs/src/content/docs/reference/config.md index 59cf50d..48140de 100644 --- a/docs/src/content/docs/reference/config.md +++ b/docs/src/content/docs/reference/config.md @@ -113,7 +113,7 @@ engine = "postgres-psql" **Type:** String **Required:** No -The database name where Spawn stores migration tracking tables (in the `spawn_schema`). If not provided, defaults to using the same database that your connection command uses. +The database name where Spawn stores migration tracking tables (in the `spawn_schema`). If not provided, defaults to using the same database that your connection command uses. This database must already exist. ```toml spawn_database = "spawn" @@ -125,7 +125,7 @@ spawn_database = "spawn" **Required:** No **Default:** `"_spawn"` -The schema where Spawn creates its internal tracking tables (`migration_history`, etc.). +The schema where Spawn creates its internal tracking tables (`migration_history`, etc.). This schema will be created if it does not yet exist. ```toml spawn_schema = "_spawn" diff --git a/src/engine/postgres_psql.rs b/src/engine/postgres_psql.rs index 9c99c2d..d25474e 100644 --- a/src/engine/postgres_psql.rs +++ b/src/engine/postgres_psql.rs @@ -75,6 +75,16 @@ impl PSQL { EscapedLiteral::new(SPAWN_NAMESPACE) } + /// Returns a psql `\c` command to switch to the spawn_database, if configured. + /// Used to ensure internal queries and schema migrations target the correct database. + fn spawn_db_connect_command(&self) -> InsecureRawSql { + if let Some(spawn_db) = &self.db_config.spawn_database { + InsecureRawSql::new(&format!("\\c {}\n", EscapedIdentifier::new(spawn_db))) + } else { + InsecureRawSql::new("") + } + } + fn build_record_migration_sql( &self, migration_name: &str, @@ -97,17 +107,11 @@ impl PSQL { let checksum_raw = InsecureRawSql::new(&checksum_expr); let safe_pin_hash = pin_hash.map(|h| EscapedLiteral::new(h)); - let db_connect = if let Some(spawn_db) = &self.db_config.spawn_database { - InsecureRawSql::new(&format!("\\c {}", EscapedIdentifier::new(spawn_db))) - } else { - InsecureRawSql::new("") - }; - let duration_interval = execution_time .map(|d| InsecureRawSql::new(&format!("INTERVAL '{} second'", d))) .unwrap_or_else(|| InsecureRawSql::new("INTERVAL '0 second'")); - sql_query!( + let qry = sql_query!( r#" BEGIN; {} @@ -140,7 +144,7 @@ impl PSQL { FROM inserted_migration; COMMIT; "#, - db_connect, + self.spawn_db_connect_command(), self.spawn_schema_ident(), safe_migration_name, namespace, @@ -151,7 +155,9 @@ impl PSQL { checksum_raw, duration_interval, safe_pin_hash, - ) + ); + println!("{}", qry); + qry } } @@ -572,8 +578,13 @@ impl PSQL { // Apply the migration and record it // Note: even for bootstrap, the first migration creates the tables, // so they exist by the time we record the migration. - let write_fn: WriterFn = - Box::new(move |writer: &mut dyn Write| writer.write_all(content.as_bytes())); + // Prefix with \c to spawn_database if configured, so the internal + // schema is created in the correct database. + let db_connect = self.spawn_db_connect_command(); + let write_fn: WriterFn = Box::new(move |writer: &mut dyn Write| { + writer.write_all(db_connect.as_str().as_bytes())?; + writer.write_all(content.as_bytes()) + }); match self .apply_and_record_migration_v1( migration_name, diff --git a/tests/integration_postgres.rs b/tests/integration_postgres.rs index f2d9f73..21d99cf 100644 --- a/tests/integration_postgres.rs +++ b/tests/integration_postgres.rs @@ -1583,3 +1583,238 @@ COMMIT;"#; Ok(()) } + +/// Tests that spawn_database config controls where migration tracking is recorded. +/// +/// Case 1: When spawn_database is not set (None), tracking tables are recorded +/// in the same database that psql connects to. +/// +/// Case 2: When spawn_database is set to a different database, the migration +/// SQL runs against the psql-connected database, but tracking tables are +/// recorded in the spawn_database via a \c switch. +#[tokio::test] +#[ignore] +async fn test_spawn_database_config() -> Result<()> { + require_postgres()?; + + let connection_mode = ConnectionMode::from_env(); + let keep_db = should_keep_db(); + + // We need two databases: + // - migration_db: where the migration SQL runs (psql connects here) + // - tracking_db: where spawn records migration history (spawn_database config) + let migration_db = format!("spawn_test_{}", Uuid::new_v4().simple()); + let tracking_db = format!("spawn_test_{}", Uuid::new_v4().simple()); + + println!(); + println!("======================================================="); + println!("Test: test_spawn_database_config"); + println!("Migration DB: {}", migration_db); + println!("Tracking DB: {}", tracking_db); + if keep_db { + println!("SPAWN_TEST_KEEP_DB is set - databases will be preserved"); + } + println!("======================================================="); + + IntegrationTestHelper::create_test_database(&migration_db, &connection_mode)?; + + // ========================================================================= + // Case 1: spawn_database is None — tracking goes to the psql-connected db + // ========================================================================= + { + let mem_service = Memory::default(); + let mem_op = Operator::new(mem_service)?.finish(); + + // Config with spawn_database = None; psql connects to migration_db + let mut databases = HashMap::new(); + databases.insert( + "postgres_psql".to_string(), + DatabaseConfig { + engine: EngineType::PostgresPSQL, + spawn_database: None, + spawn_schema: "_spawn".to_string(), + environment: "test".to_string(), + command: Some(CommandSpec::Direct { + direct: connection_mode.psql_command(&migration_db), + }), + }, + ); + + let config_loader = ConfigLoaderSaver { + spawn_folder: "/db".to_string(), + database: Some("postgres_psql".to_string()), + environment: None, + databases: Some(databases), + project_id: None, + telemetry: Some(false), + }; + + let migration_helper = + MigrationTestHelper::new_from_operator_with_config(mem_op, config_loader).await?; + + let migration_name = migration_helper + .create_migration_manual( + "default-db-test", + r#"BEGIN; + CREATE TABLE default_db_test (id SERIAL PRIMARY KEY); + COMMIT;"# + .to_string(), + ) + .await?; + + let config = migration_helper.load_config().await?; + let cmd = ApplyMigration { + migration: Some(migration_name.clone()), + pinned: false, + variables: None, + yes: true, + retry: false, + }; + cmd.execute(&config).await?; + + // Tracking tables should be in migration_db (since spawn_database is None, + // no \c switch happens, so tracking stays in the psql-connected database) + let tracking_check = connection_mode.execute_sql( + &migration_db, + "SELECT COUNT(*) FROM _spawn.migration WHERE namespace = 'default';", + )?; + let tracking_output = String::from_utf8_lossy(&tracking_check.stdout); + assert!( + tracking_output.contains('1'), + "Case 1: tracking should be in migration_db when spawn_database is None, got: {}", + tracking_output + ); + + // The migration table should also be created in migration_db + let table_check = connection_mode.execute_sql( + &migration_db, + "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'default_db_test');", + )?; + let table_output = String::from_utf8_lossy(&table_check.stdout); + assert!( + table_output.contains(" t"), + "Case 1: default_db_test table should exist in migration_db, got: {}", + table_output + ); + } + + // ========================================================================= + // Case 2: spawn_database points to tracking_db, psql connects to migration_db + // ========================================================================= + { + let mem_service = Memory::default(); + let mem_op = Operator::new(mem_service)?.finish(); + + // Config: psql connects to migration_db, but spawn_database = tracking_db + let mut databases = HashMap::new(); + databases.insert( + "postgres_psql".to_string(), + DatabaseConfig { + engine: EngineType::PostgresPSQL, + spawn_database: Some(tracking_db.to_string()), + spawn_schema: "_spawn".to_string(), + environment: "test".to_string(), + command: Some(CommandSpec::Direct { + direct: connection_mode.psql_command(&migration_db), + }), + }, + ); + + let config_loader = ConfigLoaderSaver { + spawn_folder: "/db".to_string(), + database: Some("postgres_psql".to_string()), + environment: None, + databases: Some(databases), + project_id: None, + telemetry: Some(false), + }; + + let migration_helper = + MigrationTestHelper::new_from_operator_with_config(mem_op, config_loader).await?; + + let _ = connection_mode.execute_sql( + &migration_db, + format!("CREATE DATABASE {}", tracking_db).as_str(), + )?; + + let migration_name = migration_helper + .create_migration_manual( + "split-db-test", + r#"BEGIN; +CREATE TABLE split_db_test (id SERIAL PRIMARY KEY); +COMMIT;"# + .to_string(), + ) + .await?; + + let config = migration_helper.load_config().await?; + let cmd = ApplyMigration { + migration: Some(migration_name.clone()), + pinned: false, + variables: None, + yes: true, + retry: false, + }; + cmd.execute(&config).await?; + + // The migration SQL should have run in migration_db (the psql-connected database) + let table_check = connection_mode.execute_sql( + &migration_db, + "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'split_db_test');", + )?; + let table_output = String::from_utf8_lossy(&table_check.stdout); + assert!( + table_output.contains(" t"), + "Case 2: split_db_test table should exist in migration_db, got: {}", + table_output + ); + + // The tracking record should be in tracking_db (spawn_database), NOT migration_db + let tracking_in_target = connection_mode.execute_sql( + &tracking_db, + "SELECT COUNT(*) FROM _spawn.migration WHERE namespace = 'default';", + )?; + let tracking_target_output = String::from_utf8_lossy(&tracking_in_target.stdout); + assert!( + tracking_target_output.contains('1'), + "Case 2: tracking should be in tracking_db when spawn_database is set, got: {}", + tracking_target_output + ); + + // Verify the table was NOT created in tracking_db (migration runs in migration_db) + let no_table_check = connection_mode.execute_sql( + &tracking_db, + "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'split_db_test');", + )?; + let no_table_output = String::from_utf8_lossy(&no_table_check.stdout); + assert!( + no_table_output.contains(" f"), + "Case 2: split_db_test table should NOT exist in tracking_db, got: {}", + no_table_output + ); + } + + // ========================================================================= + // Cleanup + // ========================================================================= + if keep_db { + println!("KEEPING databases: {} and {}", migration_db, tracking_db); + } else { + // Terminate connections and drop both databases + for db in [&migration_db, &tracking_db] { + let _ = connection_mode.execute_sql( + DEFAULT_NEUTRAL_DATABASE, + &format!( + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{}'", + db + ), + ); + let _ = connection_mode.execute_sql( + DEFAULT_NEUTRAL_DATABASE, + &format!("DROP DATABASE IF EXISTS \"{}\"", db), + ); + } + } + + Ok(()) +} From 1b8c12f303e7be1f2e6ab5a575762528cdf81323 Mon Sep 17 00:00:00 2001 From: Mark Saward Date: Sat, 7 Mar 2026 16:58:07 +1100 Subject: [PATCH 3/7] finish test --- tests/integration_postgres.rs | 238 +++++++++++----------------------- 1 file changed, 75 insertions(+), 163 deletions(-) diff --git a/tests/integration_postgres.rs b/tests/integration_postgres.rs index 21d99cf..77669b4 100644 --- a/tests/integration_postgres.rs +++ b/tests/integration_postgres.rs @@ -1586,10 +1586,7 @@ COMMIT;"#; /// Tests that spawn_database config controls where migration tracking is recorded. /// -/// Case 1: When spawn_database is not set (None), tracking tables are recorded -/// in the same database that psql connects to. -/// -/// Case 2: When spawn_database is set to a different database, the migration +/// When spawn_database is set to a different database, the migration /// SQL runs against the psql-connected database, but tracking tables are /// recorded in the spawn_database via a \c switch. #[tokio::test] @@ -1618,181 +1615,96 @@ async fn test_spawn_database_config() -> Result<()> { IntegrationTestHelper::create_test_database(&migration_db, &connection_mode)?; - // ========================================================================= - // Case 1: spawn_database is None — tracking goes to the psql-connected db - // ========================================================================= - { - let mem_service = Memory::default(); - let mem_op = Operator::new(mem_service)?.finish(); - - // Config with spawn_database = None; psql connects to migration_db - let mut databases = HashMap::new(); - databases.insert( - "postgres_psql".to_string(), - DatabaseConfig { - engine: EngineType::PostgresPSQL, - spawn_database: None, - spawn_schema: "_spawn".to_string(), - environment: "test".to_string(), - command: Some(CommandSpec::Direct { - direct: connection_mode.psql_command(&migration_db), - }), - }, - ); - - let config_loader = ConfigLoaderSaver { - spawn_folder: "/db".to_string(), - database: Some("postgres_psql".to_string()), - environment: None, - databases: Some(databases), - project_id: None, - telemetry: Some(false), - }; - - let migration_helper = - MigrationTestHelper::new_from_operator_with_config(mem_op, config_loader).await?; - - let migration_name = migration_helper - .create_migration_manual( - "default-db-test", - r#"BEGIN; - CREATE TABLE default_db_test (id SERIAL PRIMARY KEY); - COMMIT;"# - .to_string(), - ) - .await?; - - let config = migration_helper.load_config().await?; - let cmd = ApplyMigration { - migration: Some(migration_name.clone()), - pinned: false, - variables: None, - yes: true, - retry: false, - }; - cmd.execute(&config).await?; - - // Tracking tables should be in migration_db (since spawn_database is None, - // no \c switch happens, so tracking stays in the psql-connected database) - let tracking_check = connection_mode.execute_sql( - &migration_db, - "SELECT COUNT(*) FROM _spawn.migration WHERE namespace = 'default';", - )?; - let tracking_output = String::from_utf8_lossy(&tracking_check.stdout); - assert!( - tracking_output.contains('1'), - "Case 1: tracking should be in migration_db when spawn_database is None, got: {}", - tracking_output - ); - - // The migration table should also be created in migration_db - let table_check = connection_mode.execute_sql( - &migration_db, - "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'default_db_test');", - )?; - let table_output = String::from_utf8_lossy(&table_check.stdout); - assert!( - table_output.contains(" t"), - "Case 1: default_db_test table should exist in migration_db, got: {}", - table_output - ); - } - - // ========================================================================= - // Case 2: spawn_database points to tracking_db, psql connects to migration_db - // ========================================================================= - { - let mem_service = Memory::default(); - let mem_op = Operator::new(mem_service)?.finish(); - - // Config: psql connects to migration_db, but spawn_database = tracking_db - let mut databases = HashMap::new(); - databases.insert( - "postgres_psql".to_string(), - DatabaseConfig { - engine: EngineType::PostgresPSQL, - spawn_database: Some(tracking_db.to_string()), - spawn_schema: "_spawn".to_string(), - environment: "test".to_string(), - command: Some(CommandSpec::Direct { - direct: connection_mode.psql_command(&migration_db), - }), - }, - ); + let mem_service = Memory::default(); + let mem_op = Operator::new(mem_service)?.finish(); + + // Config: psql connects to migration_db, but spawn_database = tracking_db + let mut databases = HashMap::new(); + databases.insert( + "postgres_psql".to_string(), + DatabaseConfig { + engine: EngineType::PostgresPSQL, + spawn_database: Some(tracking_db.to_string()), + spawn_schema: "_spawn".to_string(), + environment: "test".to_string(), + command: Some(CommandSpec::Direct { + direct: connection_mode.psql_command(&migration_db), + }), + }, + ); - let config_loader = ConfigLoaderSaver { - spawn_folder: "/db".to_string(), - database: Some("postgres_psql".to_string()), - environment: None, - databases: Some(databases), - project_id: None, - telemetry: Some(false), - }; + let config_loader = ConfigLoaderSaver { + spawn_folder: "/db".to_string(), + database: Some("postgres_psql".to_string()), + environment: None, + databases: Some(databases), + project_id: None, + telemetry: Some(false), + }; - let migration_helper = - MigrationTestHelper::new_from_operator_with_config(mem_op, config_loader).await?; + let migration_helper = + MigrationTestHelper::new_from_operator_with_config(mem_op, config_loader).await?; - let _ = connection_mode.execute_sql( - &migration_db, - format!("CREATE DATABASE {}", tracking_db).as_str(), - )?; + let _ = connection_mode.execute_sql( + &migration_db, + format!("CREATE DATABASE {}", tracking_db).as_str(), + )?; - let migration_name = migration_helper - .create_migration_manual( - "split-db-test", - r#"BEGIN; + let migration_name = migration_helper + .create_migration_manual( + "split-db-test", + r#"BEGIN; CREATE TABLE split_db_test (id SERIAL PRIMARY KEY); COMMIT;"# - .to_string(), - ) - .await?; + .to_string(), + ) + .await?; - let config = migration_helper.load_config().await?; - let cmd = ApplyMigration { - migration: Some(migration_name.clone()), - pinned: false, - variables: None, - yes: true, - retry: false, - }; - cmd.execute(&config).await?; + let config = migration_helper.load_config().await?; + let cmd = ApplyMigration { + migration: Some(migration_name.clone()), + pinned: false, + variables: None, + yes: true, + retry: false, + }; + cmd.execute(&config).await?; - // The migration SQL should have run in migration_db (the psql-connected database) - let table_check = connection_mode.execute_sql( + // The migration SQL should have run in migration_db (the psql-connected database) + let table_check = connection_mode.execute_sql( &migration_db, "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'split_db_test');", )?; - let table_output = String::from_utf8_lossy(&table_check.stdout); - assert!( - table_output.contains(" t"), - "Case 2: split_db_test table should exist in migration_db, got: {}", - table_output - ); + let table_output = String::from_utf8_lossy(&table_check.stdout); + assert!( + table_output.contains(" t"), + "split_db_test table should exist in migration_db, got: {}", + table_output + ); - // The tracking record should be in tracking_db (spawn_database), NOT migration_db - let tracking_in_target = connection_mode.execute_sql( - &tracking_db, - "SELECT COUNT(*) FROM _spawn.migration WHERE namespace = 'default';", - )?; - let tracking_target_output = String::from_utf8_lossy(&tracking_in_target.stdout); - assert!( - tracking_target_output.contains('1'), - "Case 2: tracking should be in tracking_db when spawn_database is set, got: {}", - tracking_target_output - ); + // The tracking record should be in tracking_db (spawn_database), NOT migration_db + let tracking_in_target = connection_mode.execute_sql( + &tracking_db, + "SELECT COUNT(*) FROM _spawn.migration WHERE namespace = 'default';", + )?; + let tracking_target_output = String::from_utf8_lossy(&tracking_in_target.stdout); + assert!( + tracking_target_output.contains('1'), + "tracking should be in tracking_db when spawn_database is set, got: {}", + tracking_target_output + ); - // Verify the table was NOT created in tracking_db (migration runs in migration_db) - let no_table_check = connection_mode.execute_sql( + // Verify the table was NOT created in tracking_db (migration runs in migration_db) + let no_table_check = connection_mode.execute_sql( &tracking_db, "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'split_db_test');", )?; - let no_table_output = String::from_utf8_lossy(&no_table_check.stdout); - assert!( - no_table_output.contains(" f"), - "Case 2: split_db_test table should NOT exist in tracking_db, got: {}", - no_table_output - ); - } + let no_table_output = String::from_utf8_lossy(&no_table_check.stdout); + assert!( + no_table_output.contains(" f"), + "split_db_test table should NOT exist in tracking_db, got: {}", + no_table_output + ); // ========================================================================= // Cleanup From 096cae0040adb5b6fd16a28efc773a3518d841bf Mon Sep 17 00:00:00 2001 From: Mark Saward Date: Tue, 10 Mar 2026 16:37:59 +1100 Subject: [PATCH 4/7] rename one use of 'database' to 'target' for terminological clarity --- README.md | 4 +- docs/src/components/cli-options.ts | 10 ++--- docs/src/content/docs/cli/check.mdx | 4 +- docs/src/content/docs/cli/migration-adopt.mdx | 9 +++- docs/src/content/docs/cli/migration-apply.mdx | 4 +- docs/src/content/docs/cli/migration-build.mdx | 4 +- docs/src/content/docs/cli/migration-new.mdx | 8 +++- docs/src/content/docs/cli/migration-pin.mdx | 8 +++- .../src/content/docs/cli/migration-status.mdx | 8 +++- docs/src/content/docs/cli/test-build.mdx | 4 +- docs/src/content/docs/cli/test-compare.mdx | 4 +- docs/src/content/docs/cli/test-expect.mdx | 4 +- docs/src/content/docs/cli/test-new.mdx | 4 +- docs/src/content/docs/cli/test-run.mdx | 4 +- .../content/docs/getting-started/magic.mdx | 4 +- .../content/docs/guides/manage-databases.md | 42 +++++++++--------- docs/src/content/docs/reference/config.md | 34 +++++++-------- docs/src/content/docs/reference/roadmap.md | 2 +- docs/src/content/docs/reference/templating.md | 4 +- src/cli.rs | 5 +-- src/commands/check.rs | 6 +-- src/commands/init.rs | 14 +++--- src/commands/migration/mod.rs | 6 +-- src/config.rs | 43 +++++++++---------- src/engine/mod.rs | 2 +- src/engine/postgres_psql.rs | 16 +++---- src/template.rs | 10 ++--- tests/integration_postgres.rs | 26 +++++------ tests/migration_build.rs | 12 +++--- 29 files changed, 159 insertions(+), 146 deletions(-) diff --git a/README.md b/README.md index 491edc6..2e7109b 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ LLM disclaimer: I (Mark) estimate that 90% of the code/design/architecture has b [![License](https://img.shields.io/badge/license-AGPL-blue.svg)](LICENSE) [![Docs](https://img.shields.io/badge/docs-spawn.dev-green)](https://docs.spawn.dev) -**Stop treating your database like a script runner. Start treating it like a codebase.** +**Treat your database code like a codebase.** I like to lean heavily on the database. I don't like tools that abstract away the raw power of databases like PostgreSQL. Spawn is designed for developers who want to use the full breadth of modern database features: Functions, Views, Triggers, RLS – while keeping the maintenance nightmares to a minimum. @@ -243,7 +243,7 @@ Spawn supports **Provider Commands** – configure it to use `gcloud`, `aws`, or ```toml # spawn.toml -[databases.prod] +[targets.prod] command = { kind = "provider", provider = ["gcloud", "compute", "ssh", "--dry-run", ...], diff --git a/docs/src/components/cli-options.ts b/docs/src/components/cli-options.ts index ebf7f9d..98200ae 100644 --- a/docs/src/components/cli-options.ts +++ b/docs/src/components/cli-options.ts @@ -12,11 +12,11 @@ export const globalOptions: CLIOption[] = [ { flag: "-d, --debug", description: "Turn on debug output." }, ]; -/** The --database flag. Relevant to commands that read or validate the database config. */ -export const databaseOption: CLIOption[] = [ +/** The --target flag. Relevant to commands that read or validate the target config. */ +export const targetOption: CLIOption[] = [ { - flag: "--database ", - description: "Select which database from spawn.toml to use.", + flag: "--target ", + description: "Select which target from spawn.toml to use.", }, ]; @@ -24,7 +24,7 @@ export const databaseOption: CLIOption[] = [ export const environmentOption: CLIOption[] = [ { flag: "-e, --environment ", - description: "Override the environment for the database config.", + description: "Override the environment for the target config.", }, ]; diff --git a/docs/src/content/docs/cli/check.mdx b/docs/src/content/docs/cli/check.mdx index c57355a..6f161b1 100644 --- a/docs/src/content/docs/cli/check.mdx +++ b/docs/src/content/docs/cli/check.mdx @@ -4,11 +4,11 @@ description: Check your project for potential issues. --- import CLICommand from "../../../components/CLICommand.astro"; -import { globalOptions, databaseOption } from "../../../components/cli-options"; +import { globalOptions, targetOption } from "../../../components/cli-options"; ", description: "Reason for adoption (recorded in history)" }, ...environmentOption, - ...databaseOption, + ...targetOption, ...globalOptions ]} > @@ -32,6 +36,7 @@ Records a migration as applied in `_spawn.migration_history` without executing t ## Behavior Adoption creates a history entry with: + - Status: `success` - Activity: `ADOPT` - Optional description explaining why it was adopted diff --git a/docs/src/content/docs/cli/migration-apply.mdx b/docs/src/content/docs/cli/migration-apply.mdx index f9e3cb8..35d5f0e 100644 --- a/docs/src/content/docs/cli/migration-apply.mdx +++ b/docs/src/content/docs/cli/migration-apply.mdx @@ -6,7 +6,7 @@ description: Apply migrations to the database. import CLICommand from "../../../components/CLICommand.astro"; import { globalOptions, - databaseOption, + targetOption, environmentOption, variablesOption, } from "../../../components/cli-options"; @@ -19,7 +19,7 @@ import { { flag: "--yes", description: "Skip confirmation prompt" }, { flag: "--retry", description: "Retry a previous migration" }, ...environmentOption, - ...databaseOption, + ...targetOption, ...globalOptions ]} > diff --git a/docs/src/content/docs/cli/migration-build.mdx b/docs/src/content/docs/cli/migration-build.mdx index b256367..59cb6ca 100644 --- a/docs/src/content/docs/cli/migration-build.mdx +++ b/docs/src/content/docs/cli/migration-build.mdx @@ -6,7 +6,7 @@ description: Build a migration into final SQL. import CLICommand from "../../../components/CLICommand.astro"; import { globalOptions, - databaseOption, + targetOption, environmentOption, variablesOption, } from "../../../components/cli-options"; @@ -17,7 +17,7 @@ import { { flag: "--pinned", description: "Use pinned component versions from lock.toml" }, ...variablesOption, ...environmentOption, - ...databaseOption, + ...targetOption, ...globalOptions ]} > diff --git a/docs/src/content/docs/cli/migration-new.mdx b/docs/src/content/docs/cli/migration-new.mdx index 3cb2f2a..10714a0 100644 --- a/docs/src/content/docs/cli/migration-new.mdx +++ b/docs/src/content/docs/cli/migration-new.mdx @@ -4,11 +4,15 @@ description: Create a new migration. --- import CLICommand from "../../../components/CLICommand.astro"; -import { globalOptions, databaseOption, environmentOption } from "../../../components/cli-options"; +import { + globalOptions, + targetOption, + environmentOption, +} from "../../../components/cli-options"; Creates a new timestamped migration directory with a template `up.sql` file. diff --git a/docs/src/content/docs/cli/migration-pin.mdx b/docs/src/content/docs/cli/migration-pin.mdx index fdb92c0..eb46f97 100644 --- a/docs/src/content/docs/cli/migration-pin.mdx +++ b/docs/src/content/docs/cli/migration-pin.mdx @@ -4,11 +4,15 @@ description: Pin a migration to current component versions. --- import CLICommand from "../../../components/CLICommand.astro"; -import { globalOptions, databaseOption, environmentOption } from "../../../components/cli-options"; +import { + globalOptions, + targetOption, + environmentOption, +} from "../../../components/cli-options"; Creates a snapshot of all components referenced by the migration and stores them in the content-addressable `pinned/` directory. Writes a `lock.toml` file in the migration folder with the snapshot hash. diff --git a/docs/src/content/docs/cli/migration-status.mdx b/docs/src/content/docs/cli/migration-status.mdx index a5a3e28..9231c35 100644 --- a/docs/src/content/docs/cli/migration-status.mdx +++ b/docs/src/content/docs/cli/migration-status.mdx @@ -4,11 +4,15 @@ description: Show migration status across filesystem and database. --- import CLICommand from "../../../components/CLICommand.astro"; -import { globalOptions, databaseOption, environmentOption } from "../../../components/cli-options"; +import { + globalOptions, + targetOption, + environmentOption, +} from "../../../components/cli-options"; Displays a table showing the status of all migrations from both the filesystem and database. diff --git a/docs/src/content/docs/cli/test-build.mdx b/docs/src/content/docs/cli/test-build.mdx index d825ede..9edb689 100644 --- a/docs/src/content/docs/cli/test-build.mdx +++ b/docs/src/content/docs/cli/test-build.mdx @@ -4,11 +4,11 @@ description: Build a test into final SQL. --- import CLICommand from "../../../components/CLICommand.astro"; -import { globalOptions, databaseOption } from "../../../components/cli-options"; +import { globalOptions, targetOption } from "../../../components/cli-options"; Renders a test's `test.sql` template into final SQL, resolving component includes and template variables. Outputs to stdout. diff --git a/docs/src/content/docs/cli/test-compare.mdx b/docs/src/content/docs/cli/test-compare.mdx index 68c6023..4cfb9db 100644 --- a/docs/src/content/docs/cli/test-compare.mdx +++ b/docs/src/content/docs/cli/test-compare.mdx @@ -4,11 +4,11 @@ description: Run tests and compare output to expected results. --- import CLICommand from "../../../components/CLICommand.astro"; -import { globalOptions, databaseOption } from "../../../components/cli-options"; +import { globalOptions, targetOption } from "../../../components/cli-options"; Runs tests and compares their output against saved expected results, reporting any differences. diff --git a/docs/src/content/docs/cli/test-expect.mdx b/docs/src/content/docs/cli/test-expect.mdx index 61e57e4..82bc5cf 100644 --- a/docs/src/content/docs/cli/test-expect.mdx +++ b/docs/src/content/docs/cli/test-expect.mdx @@ -4,11 +4,11 @@ description: Update expected test output. --- import CLICommand from "../../../components/CLICommand.astro"; -import { globalOptions, databaseOption } from "../../../components/cli-options"; +import { globalOptions, targetOption } from "../../../components/cli-options"; Runs a test and saves its output as the new expected result for future comparisons. diff --git a/docs/src/content/docs/cli/test-new.mdx b/docs/src/content/docs/cli/test-new.mdx index 64466c7..568a193 100644 --- a/docs/src/content/docs/cli/test-new.mdx +++ b/docs/src/content/docs/cli/test-new.mdx @@ -4,11 +4,11 @@ description: Create a new test. --- import CLICommand from "../../../components/CLICommand.astro"; -import { globalOptions, databaseOption } from "../../../components/cli-options"; +import { globalOptions, targetOption } from "../../../components/cli-options"; Creates a new test directory with a template `test.sql` file. diff --git a/docs/src/content/docs/cli/test-run.mdx b/docs/src/content/docs/cli/test-run.mdx index da3c9e9..a3bd3c3 100644 --- a/docs/src/content/docs/cli/test-run.mdx +++ b/docs/src/content/docs/cli/test-run.mdx @@ -4,11 +4,11 @@ description: Run tests against the database. --- import CLICommand from "../../../components/CLICommand.astro"; -import { globalOptions, databaseOption } from "../../../components/cli-options"; +import { globalOptions, targetOption } from "../../../components/cli-options"; Executes one or all tests against the configured database and displays the results. diff --git a/docs/src/content/docs/getting-started/magic.mdx b/docs/src/content/docs/getting-started/magic.mdx index e147add..2432d6b 100644 --- a/docs/src/content/docs/getting-started/magic.mdx +++ b/docs/src/content/docs/getting-started/magic.mdx @@ -582,11 +582,11 @@ $$ LANGUAGE plpgsql; COMMIT; ``` -But if we edit the database in `spawn.toml` to specify that it is a dev environment, like so: +But if we edit the target in `spawn.toml` to specify that it is a dev environment, like so: ```toml {6} ... -[databases.postgres_psql] +[targets.postgres_psql] engine = "postgres-psql" spawn_database = "postgres" spawn_schema = "_spawn" diff --git a/docs/src/content/docs/guides/manage-databases.md b/docs/src/content/docs/guides/manage-databases.md index d30756b..0d9d7f1 100644 --- a/docs/src/content/docs/guides/manage-databases.md +++ b/docs/src/content/docs/guides/manage-databases.md @@ -3,11 +3,11 @@ title: Database Connections description: How to configure database connections in spawn.toml --- -Spawn requires a database connection to apply migrations and run tests. Database connections are configured in your `spawn.toml` file under the `[databases]` section. See the [configuration reference](/reference/config/#database-configurations) for the full list of database fields. +Spawn requires a database connection to apply migrations and run tests. Database connections are configured in your `spawn.toml` file under the `[targets]` section. See the [configuration reference](/reference/config/#target-configurations) for the full list of target fields. ## Basic Configuration -Each database configuration requires: +Each target configuration requires: - `engine`: The database engine type (currently only `"postgres-psql"`) - `spawn_database`: The database where spawn stores migration tracking (defaults to using whichever database your command connects to by default). This database must already exist. @@ -24,7 +24,7 @@ The `command` field specifies how spawn should execute SQL against your database Use a direct command when you have a straightforward way to connect to your database. ```toml -[databases.local] +[targets.local] engine = "postgres-psql" spawn_database = "myapp" spawn_schema = "_spawn" @@ -37,7 +37,7 @@ command = { kind = "direct", direct = ["psql", "-U", "postgres", "myapp"] } For databases running in Docker: ```toml -[databases.docker_local] +[targets.docker_local] engine = "postgres-psql" spawn_database = "myapp" spawn_schema = "_spawn" @@ -56,7 +56,7 @@ command = { Provider commands are useful when the connection details need to be resolved dynamically, such as with cloud providers where connection setup is slow but the underlying connection is fast. ```toml -[databases.staging] +[targets.staging] engine = "postgres-psql" spawn_database = "myapp" spawn_schema = "_spawn" @@ -93,7 +93,7 @@ The provider pattern resolves the SSH command once using `gcloud compute ssh --d You can use the gcloud command directly, but this will be called multiple times during a single `spawn migration apply` command. ```toml -[databases.staging_slow] +[targets.staging_slow] engine = "postgres-psql" spawn_database = "myapp" spawn_schema = "_spawn" @@ -118,7 +118,7 @@ command = { You can also use gcloud to provide the underlying ssh command needed to connect, which will be resolved just once, and then every connection to the database will use the provided ssh command directly. ```toml -[databases.staging] +[targets.staging] engine = "postgres-psql" spawn_database = "myapp" spawn_schema = "_spawn" @@ -178,10 +178,10 @@ ssh-add ~/.ssh/your_key ```toml # spawn.toml spawn_folder = "spawn" -database = "local" # Default database +target = "local" # Default target # Local development (Docker) -[databases.local] +[targets.local] engine = "postgres-psql" spawn_database = "myapp" spawn_schema = "_spawn" @@ -192,7 +192,7 @@ command = { } # Staging (Google Cloud via provider - fast!) -[databases.staging] +[targets.staging] engine = "postgres-psql" spawn_database = "myapp" spawn_schema = "_spawn" @@ -210,7 +210,7 @@ command = { } # Production (Google Cloud via provider - fast!) -[databases.production] +[targets.production] engine = "postgres-psql" spawn_database = "myapp" spawn_schema = "_spawn" @@ -231,28 +231,28 @@ command = { Usage: ```bash -# Use local database (default) +# Use local target (default) spawn migration apply -# Use staging database -spawn --database staging migration apply +# Use staging target +spawn --target staging migration apply -# Use production database -spawn --database production migration apply +# Use production target +spawn --target production migration apply ``` ## Advanced Configuration -### Multiple Databases +### Multiple Targets -You can configure multiple databases and switch between them: +You can configure multiple targets and switch between them: ```bash -# Use specific database -spawn --database staging migration build my-migration +# Use specific target +spawn --target staging migration build my-migration # Override in environment variable -export SPAWN_DATABASE=production +export SPAWN_TARGET=production spawn migration apply ``` diff --git a/docs/src/content/docs/reference/config.md b/docs/src/content/docs/reference/config.md index 48140de..f48f582 100644 --- a/docs/src/content/docs/reference/config.md +++ b/docs/src/content/docs/reference/config.md @@ -33,22 +33,22 @@ This would expect the following directory layout: - `./database/spawn/tests/` - `./database/spawn/pinned/` -### `database` +### `target` **Type:** String **Required:** No **Default:** None -The default database to use for commands. Must match a key in `[databases]`. +The default target to use for commands. Must match a key in `[targets]`. ```toml -database = "local" +target = "local" ``` -Override per-command with `--database`: +Override per-command with `--target`: ```bash -spawn --database production migration status +spawn --target production migration status ``` ### `environment` @@ -57,13 +57,13 @@ spawn --database production migration status **Required:** No **Default:** None -Global environment override. Overrides the `environment` field in database configs. +Global environment override. Overrides the `environment` field in target configs. ```toml environment = "dev" ``` -This is rarely set at the top level. Usually each database defines its own environment. +This is rarely set at the top level. Usually each target defines its own environment. ### `project_id` @@ -91,9 +91,9 @@ telemetry = false Set the `DO_NOT_TRACK` environment variable to disable telemetry globally. -## Database configurations +## Target configurations -The `[databases]` section defines one or more database connections. Each database is a table with the following fields. For practical setup examples including Docker and Google Cloud SQL, see the [Database Connections guide](/guides/manage-databases/). +The `[targets]` section defines one or more database connections. Each target is a table with the following fields. For practical setup examples including Docker and Google Cloud SQL, see the [Database Connections guide](/guides/manage-databases/). ### `engine` @@ -104,7 +104,7 @@ The `[databases]` section defines one or more database connections. Each databas The database engine type. Currently only PostgreSQL via psql is supported. ```toml -[databases.local] +[targets.local] engine = "postgres-psql" ``` @@ -156,7 +156,7 @@ environment = "dev" **Type:** Table (CommandSpec) **Required:** Yes -Specifies how to execute SQL against the database. Two modes: `direct` and `provider`. For now, only connection via PostgreSQL psql is supported, so this should be the command that allows piping changes to the database. See the [Database Connections guide](/guides/manage-databases/#command-configuration) for detailed examples of both modes. +Specifies how to execute SQL against the target. Two modes: `direct` and `provider`. For now, only connection via PostgreSQL psql is supported, so this should be the command that allows piping changes to the database. See the [Database Connections guide](/guides/manage-databases/#command-configuration) for detailed examples of both modes. #### Direct command @@ -204,10 +204,10 @@ The `--dry-run` flag makes `gcloud` output the SSH command as a string instead o ```toml spawn_folder = "./database/spawn" -database = "local" +target = "local" project_id = -[databases.local] +[targets.local] spawn_database = "spawn" spawn_schema = "_spawn" environment = "dev" @@ -217,7 +217,7 @@ command = { direct = ["docker", "exec", "-i", "mydb", "psql", "-U", "postgres", "postgres"] } -[databases.staging] +[targets.staging] spawn_database = "spawn" spawn_schema = "_spawn" engine = "postgres-psql" @@ -233,7 +233,7 @@ command = { append = ["-T", "sudo", "-u", "postgres", "psql", "mydb"] } -[databases.production] +[targets.production] spawn_database = "spawn" spawn_schema = "_spawn" engine = "postgres-psql" @@ -255,8 +255,8 @@ command = { Spawn supports environment variable overrides with the `SPAWN_` prefix: ```bash -export SPAWN_DATABASE=production +export SPAWN_TARGET=production spawn migration status ``` -This is equivalent to `spawn --database production migration status`. +This is equivalent to `spawn --target production migration status`. diff --git a/docs/src/content/docs/reference/roadmap.md b/docs/src/content/docs/reference/roadmap.md index 377c5c9..ff9a395 100644 --- a/docs/src/content/docs/reference/roadmap.md +++ b/docs/src/content/docs/reference/roadmap.md @@ -34,7 +34,7 @@ This is a high-level overview of where Spawn is headed. Items here are subject t - ✅ **TOML configuration** - `spawn.toml` for project settings - ✅ **Organized folder structure** - `/components`, `/migrations`, `/tests`, `/pinned` -- ✅ **Database targeting** - `--database` flag for multiple database configurations +- ✅ **Target selection** - `--target` flag for multiple target configurations - ✅ **PostgreSQL focus** - Optimized for PostgreSQL features and workflows ## Roadmap diff --git a/docs/src/content/docs/reference/templating.md b/docs/src/content/docs/reference/templating.md index bbba6a7..e066499 100644 --- a/docs/src/content/docs/reference/templating.md +++ b/docs/src/content/docs/reference/templating.md @@ -21,7 +21,7 @@ Templates are used in: ### `env` -The environment from the database config (e.g., `"dev"`, `"prod"`). +The environment from the target config (e.g., `"dev"`, `"prod"`). ```sql {% if env == "dev" %} @@ -223,7 +223,7 @@ SELECT 1; ``` :::note -These parse filters complement the `--variables` CLI flag. Use `--variables` to pass a single variables file into the `variables` context. This is intended for situations where you want to provide data that is specific to a particular database target, or contains information that should not be committed to your repo. Use `read_file` with a parse filter when you need to load additional structured data from `components/`, either for tests or data that is applicable to all database targets. +These parse filters complement the `--variables` CLI flag. Use `--variables` to pass a single variables file into the `variables` context. This is intended for situations where you want to provide data that is specific to a particular target, or contains information that should not be committed to your repo. Use `read_file` with a parse filter when you need to load additional structured data from `components/`, either for tests or data that is applicable to all targets. ::: ### `escape_identifier` diff --git a/src/cli.rs b/src/cli.rs index cb9c20b..c526402 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -20,7 +20,7 @@ pub struct Cli { pub config_file: String, #[arg(global = true, long)] - pub database: Option, + pub target: Option, /// Internal flag for telemetry child process (hidden) #[arg(long, hide = true)] @@ -256,8 +256,7 @@ pub async fn run_cli(cli: Cli, base_op: &Operator) -> CliResult { let config_exists = base_op.exists(&cli.config_file).await.unwrap_or(false); // Load config from file (required for all other commands) - let mut main_config = match Config::load(&cli.config_file, base_op, cli.database.clone()).await - { + let mut main_config = match Config::load(&cli.config_file, base_op, cli.target.clone()).await { Ok(cfg) => cfg, Err(e) => { // If config doesn't exist, show helpful message diff --git a/src/commands/check.rs b/src/commands/check.rs index 5212cd7..e3a12c8 100644 --- a/src/commands/check.rs +++ b/src/commands/check.rs @@ -14,9 +14,9 @@ impl TelemetryDescribe for Check { impl Command for Check { async fn execute(&self, config: &Config) -> Result { - // Validate the database reference if one was provided - if config.database.is_some() { - config.db_config()?; + // Validate the target reference if one was provided + if config.target.is_some() { + config.target_config()?; } let mut warnings: Vec = Vec::new(); diff --git a/src/commands/init.rs b/src/commands/init.rs index 93b905d..bee3131 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -1,6 +1,6 @@ use crate::commands::{Outcome, TelemetryDescribe, TelemetryInfo}; use crate::config::ConfigLoaderSaver; -use crate::engine::{CommandSpec, DatabaseConfig, EngineType}; +use crate::engine::{CommandSpec, EngineType, TargetConfig}; use anyhow::{anyhow, Result}; use opendal::Operator; use std::collections::HashMap; @@ -50,11 +50,11 @@ impl Init { "postgres-db".to_string() }; - // Create example database config - let mut databases = HashMap::new(); - databases.insert( + // Create example target config + let mut targets = HashMap::new(); + targets.insert( "postgres_psql".to_string(), - DatabaseConfig { + TargetConfig { engine: EngineType::PostgresPSQL, spawn_database: Some(db_name.clone()), spawn_schema: "_spawn".to_string(), @@ -77,9 +77,9 @@ impl Init { // Create default config let config = ConfigLoaderSaver { spawn_folder: "spawn".to_string(), - database: Some("postgres_psql".to_string()), + target: Some("postgres_psql".to_string()), environment: None, - databases: Some(databases), + targets: Some(targets), project_id: Some(project_id.clone()), telemetry: None, }; diff --git a/src/commands/migration/mod.rs b/src/commands/migration/mod.rs index 2ae2277..24dcf93 100644 --- a/src/commands/migration/mod.rs +++ b/src/commands/migration/mod.rs @@ -106,9 +106,9 @@ pub async fn get_pending_and_confirm( return Ok(None); } - let db_config = config.db_config()?; - let target = config.database.as_deref().unwrap_or("unknown"); - let env = &db_config.environment; + let target_config = config.target_config()?; + let target = config.target.as_deref().unwrap_or("unknown"); + let env = &target_config.environment; println!(); println!("TARGET: {}", target); diff --git a/src/config.rs b/src/config.rs index c6f9f2b..47bdff7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,4 @@ -use crate::engine::{postgres_psql::PSQL, DatabaseConfig, Engine, EngineType}; +use crate::engine::{postgres_psql::PSQL, Engine, EngineType, TargetConfig}; use crate::pinfile::LockData; use crate::variables::Variables; use anyhow::{anyhow, Context, Result}; @@ -13,9 +13,9 @@ static PINFILE_LOCK_NAME: &str = "lock.toml"; #[derive(Clone, Debug, Deserialize, Serialize)] pub struct ConfigLoaderSaver { pub spawn_folder: String, - pub database: Option, + pub target: Option, pub environment: Option, - pub databases: Option>, + pub targets: Option>, /// Unique project identifier for telemetry (UUID string) pub project_id: Option, /// Set to false to disable telemetry @@ -32,9 +32,9 @@ impl ConfigLoaderSaver { pub fn build(self, base_fs: Operator, spawn_fs: Option) -> Config { Config { spawn_folder: self.spawn_folder, - database: self.database, + target: self.target, environment: self.environment, - databases: self.databases.unwrap_or_default(), + targets: self.targets.unwrap_or_default(), project_id: self.project_id, telemetry: self.telemetry.unwrap_or(true), base_fs, @@ -45,7 +45,7 @@ impl ConfigLoaderSaver { pub async fn load( path: &str, op: &Operator, - database: Option, + target: Option, ) -> Result { let bytes = op .read(path) @@ -76,7 +76,7 @@ impl ConfigLoaderSaver { // Add in settings from the environment (with a prefix of APP) // Eg.. `APP_DEBUG=1 ./target/app` would set the `debug` key .add_source(config::Environment::with_prefix("SPAWN")) - .set_override_option("database", database)? + .set_override_option("target", target)? .set_default("environment", "prod") .context("could not set default environment")? .build()? @@ -165,9 +165,9 @@ impl FolderPather { #[derive(Debug, Clone)] pub struct Config { spawn_folder: String, - pub database: Option, - pub environment: Option, // Override the environment for the db config - pub databases: HashMap, + pub target: Option, + pub environment: Option, // Override the environment for the target config + pub targets: HashMap, /// Unique project identifier for telemetry (UUID string) pub project_id: Option, /// Whether telemetry is enabled in config @@ -190,22 +190,19 @@ impl Config { } pub async fn new_engine(&self) -> Result> { - let db_config = self.db_config()?; + let target_config = self.target_config()?; - match db_config.engine { - EngineType::PostgresPSQL => Ok(PSQL::new(&db_config).await?), + match target_config.engine { + EngineType::PostgresPSQL => Ok(PSQL::new(&target_config).await?), } } - pub fn db_config(&self) -> Result { - let db_name = self - .database - .as_ref() - .ok_or(anyhow!("no database selected"))?; + pub fn target_config(&self) -> Result { + let target_name = self.target.as_ref().ok_or(anyhow!("no target selected"))?; let mut conf = self - .databases - .get(db_name) - .ok_or(anyhow!("no database defined with name '{}'", db_name,))? + .targets + .get(target_name) + .ok_or(anyhow!("no target defined with name '{}'", target_name,))? .clone(); if let Some(env) = &self.environment { @@ -215,8 +212,8 @@ impl Config { Ok(conf) } - pub async fn load(path: &str, op: &Operator, database: Option) -> Result { - let config_loader = ConfigLoaderSaver::load(path, op, database).await?; + pub async fn load(path: &str, op: &Operator, target: Option) -> Result { + let config_loader = ConfigLoaderSaver::load(path, op, target).await?; Ok(config_loader.build(op.clone(), None)) } diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 6a967a8..17cb180 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -231,7 +231,7 @@ pub enum CommandSpec { } #[derive(Clone, Debug, Deserialize, Serialize)] -pub struct DatabaseConfig { +pub struct TargetConfig { pub engine: EngineType, pub spawn_database: Option, #[serde(default = "default_schema")] diff --git a/src/engine/postgres_psql.rs b/src/engine/postgres_psql.rs index d25474e..f6d846a 100644 --- a/src/engine/postgres_psql.rs +++ b/src/engine/postgres_psql.rs @@ -4,9 +4,9 @@ use crate::config::FolderPather; use crate::engine::{ - resolve_command_spec, DatabaseConfig, Engine, EngineError, ExistingMigrationInfo, - MigrationActivity, MigrationError, MigrationHistoryStatus, MigrationResult, MigrationStatus, - StdoutWriter, WriterFn, + resolve_command_spec, Engine, EngineError, ExistingMigrationInfo, MigrationActivity, + MigrationError, MigrationHistoryStatus, MigrationResult, MigrationStatus, StdoutWriter, + TargetConfig, WriterFn, }; use crate::escape::{EscapedIdentifier, EscapedLiteral, EscapedQuery, InsecureRawSql}; use crate::sql_query; @@ -35,18 +35,18 @@ pub fn migration_lock_key() -> i64 { #[derive(Debug)] pub struct PSQL { psql_command: Vec, - db_config: DatabaseConfig, + db_config: TargetConfig, } static PROJECT_DIR: Dir<'_> = include_dir!("./static/engine-migrations/postgres-psql"); static SPAWN_NAMESPACE: &str = "spawn"; impl PSQL { - pub async fn new(config: &DatabaseConfig) -> Result> { + pub async fn new(config: &TargetConfig) -> Result> { let command_spec = config .command .clone() - .ok_or(anyhow!("Command for database config must be defined"))?; + .ok_or(anyhow!("Command for target config must be defined"))?; let psql_command = resolve_command_spec(command_spec).await?; @@ -545,8 +545,8 @@ impl PSQL { .await .context("Failed to load config for postgres psql")?; let dbengtype = "psql".to_string(); - cfg.database = Some(dbengtype.clone()); - cfg.databases = HashMap::from([(dbengtype, self.db_config.clone())]); + cfg.target = Some(dbengtype.clone()); + cfg.targets = HashMap::from([(dbengtype, self.db_config.clone())]); // Apply each migration that hasn't been applied yet for migration_path in available_migrations { diff --git a/src/template.rs b/src/template.rs index d647555..968f16e 100644 --- a/src/template.rs +++ b/src/template.rs @@ -335,15 +335,15 @@ pub async fn generate_streaming( let store = Store::new(pinner, cfg.operator().clone(), cfg.pather()) .context("could not create new store for generate")?; - let db_config = cfg - .db_config() - .context("could not get db config for generate")?; + let target_config = cfg + .target_config() + .context("could not get target config for generate")?; generate_streaming_with_store( name, variables, - &db_config.environment, - &db_config.engine, + &target_config.environment, + &target_config.engine, store, ) .await diff --git a/tests/integration_postgres.rs b/tests/integration_postgres.rs index 77669b4..080e698 100644 --- a/tests/integration_postgres.rs +++ b/tests/integration_postgres.rs @@ -43,7 +43,7 @@ use opendal::Operator; use spawn_db::{ commands::{AdoptMigration, ApplyMigration, Command, CompareTests, ExpectTest, Outcome}, config::ConfigLoaderSaver, - engine::{CommandSpec, DatabaseConfig, EngineType}, + engine::{CommandSpec, EngineType, TargetConfig}, }; use std::collections::HashMap; use std::env; @@ -200,7 +200,7 @@ impl IntegrationTestHelper { } }; - // Create the database config for this test + // Create the target config for this test let config_loader = Self::create_config(&db_name, &connection_mode); // Use MigrationTestHelper for filesystem and config management @@ -218,12 +218,12 @@ impl IntegrationTestHelper { Ok(helper) } - /// Creates a database config that points to our isolated test database + /// Creates a target config that points to our isolated test database fn create_config(db_name: &str, connection_mode: &ConnectionMode) -> ConfigLoaderSaver { - let mut databases = HashMap::new(); - databases.insert( + let mut targets = HashMap::new(); + targets.insert( "postgres_psql".to_string(), - DatabaseConfig { + TargetConfig { engine: EngineType::PostgresPSQL, spawn_database: Some(db_name.to_string()), spawn_schema: "_spawn".to_string(), @@ -236,9 +236,9 @@ impl IntegrationTestHelper { ConfigLoaderSaver { spawn_folder: "/db".to_string(), - database: Some("postgres_psql".to_string()), + target: Some("postgres_psql".to_string()), environment: None, - databases: Some(databases), + targets: Some(targets), project_id: None, telemetry: Some(false), } @@ -1619,10 +1619,10 @@ async fn test_spawn_database_config() -> Result<()> { let mem_op = Operator::new(mem_service)?.finish(); // Config: psql connects to migration_db, but spawn_database = tracking_db - let mut databases = HashMap::new(); - databases.insert( + let mut targets = HashMap::new(); + targets.insert( "postgres_psql".to_string(), - DatabaseConfig { + TargetConfig { engine: EngineType::PostgresPSQL, spawn_database: Some(tracking_db.to_string()), spawn_schema: "_spawn".to_string(), @@ -1635,9 +1635,9 @@ async fn test_spawn_database_config() -> Result<()> { let config_loader = ConfigLoaderSaver { spawn_folder: "/db".to_string(), - database: Some("postgres_psql".to_string()), + target: Some("postgres_psql".to_string()), environment: None, - databases: Some(databases), + targets: Some(targets), project_id: None, telemetry: Some(false), }; diff --git a/tests/migration_build.rs b/tests/migration_build.rs index 32dde88..92d5e05 100644 --- a/tests/migration_build.rs +++ b/tests/migration_build.rs @@ -6,7 +6,7 @@ use pretty_assertions::assert_eq; use spawn_db::{ commands::{BuildMigration, Check, Command, NewMigration, Outcome, PinMigration}, config::{Config, ConfigLoaderSaver}, - engine::{CommandSpec, DatabaseConfig, EngineType}, + engine::{CommandSpec, EngineType, TargetConfig}, store, }; use std::collections::HashMap; @@ -71,10 +71,10 @@ impl MigrationTestHelper { } fn default_config_loadersaver() -> ConfigLoaderSaver { - let mut databases = HashMap::new(); - databases.insert( + let mut targets = HashMap::new(); + targets.insert( "postgres_psql".to_string(), - DatabaseConfig { + TargetConfig { engine: EngineType::PostgresPSQL, spawn_database: Some("spawn".to_string()), spawn_schema: "public".to_string(), @@ -96,9 +96,9 @@ impl MigrationTestHelper { ConfigLoaderSaver { spawn_folder: "/db".to_string(), - database: Some("postgres_psql".to_string()), + target: Some("postgres_psql".to_string()), environment: Some("dev".to_string()), - databases: Some(databases), + targets: Some(targets), project_id: None, telemetry: Some(false), } From 79a64d46ab504a9d4d13854d11a114531f4025c9 Mon Sep 17 00:00:00 2001 From: Mark Saward Date: Tue, 10 Mar 2026 17:34:17 +1100 Subject: [PATCH 5/7] rename db_config to target_config --- src/engine/postgres_psql.rs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/engine/postgres_psql.rs b/src/engine/postgres_psql.rs index f6d846a..ae15668 100644 --- a/src/engine/postgres_psql.rs +++ b/src/engine/postgres_psql.rs @@ -35,7 +35,7 @@ pub fn migration_lock_key() -> i64 { #[derive(Debug)] pub struct PSQL { psql_command: Vec, - db_config: TargetConfig, + target_config: TargetConfig, } static PROJECT_DIR: Dir<'_> = include_dir!("./static/engine-migrations/postgres-psql"); @@ -52,7 +52,7 @@ impl PSQL { let eng = Box::new(Self { psql_command, - db_config: config.clone(), + target_config: config.clone(), }); // Ensure we have latest schema: @@ -64,11 +64,11 @@ impl PSQL { } fn spawn_schema_literal(&self) -> EscapedLiteral { - EscapedLiteral::new(&self.db_config.spawn_schema) + EscapedLiteral::new(&self.target_config.spawn_schema) } fn spawn_schema_ident(&self) -> EscapedIdentifier { - EscapedIdentifier::new(&self.db_config.spawn_schema) + EscapedIdentifier::new(&self.target_config.spawn_schema) } fn safe_spawn_namespace(&self) -> EscapedLiteral { @@ -78,7 +78,7 @@ impl PSQL { /// Returns a psql `\c` command to switch to the spawn_database, if configured. /// Used to ensure internal queries and schema migrations target the correct database. fn spawn_db_connect_command(&self) -> InsecureRawSql { - if let Some(spawn_db) = &self.db_config.spawn_database { + if let Some(spawn_db) = &self.target_config.spawn_database { InsecureRawSql::new(&format!("\\c {}\n", EscapedIdentifier::new(spawn_db))) } else { InsecureRawSql::new("") @@ -156,7 +156,6 @@ impl PSQL { duration_interval, safe_pin_hash, ); - println!("{}", qry); qry } } @@ -546,7 +545,7 @@ impl PSQL { .context("Failed to load config for postgres psql")?; let dbengtype = "psql".to_string(); cfg.target = Some(dbengtype.clone()); - cfg.targets = HashMap::from([(dbengtype, self.db_config.clone())]); + cfg.targets = HashMap::from([(dbengtype, self.target_config.clone())]); // Apply each migration that hasn't been applied yet for migration_path in available_migrations { @@ -567,7 +566,7 @@ impl PSQL { // Load and render the migration let variables = crate::variables::Variables::from_str( "json", - &serde_json::json!({"schema": &self.db_config.spawn_schema}).to_string(), + &serde_json::json!({"schema": &self.target_config.spawn_schema}).to_string(), )?; let gen = migrator.generate_streaming(Some(variables)).await?; let mut buffer = Vec::new(); From 4abf94330da5e38bc1566e79cd9498ba8f99eefe Mon Sep 17 00:00:00 2001 From: Mark Saward Date: Mon, 16 Mar 2026 17:14:48 +1100 Subject: [PATCH 6/7] fix up which db we're connecting to for various things --- src/engine/postgres_psql.rs | 63 +++++++++++++++++++++++++++-------- tests/integration_postgres.rs | 20 +++++++++++ 2 files changed, 70 insertions(+), 13 deletions(-) diff --git a/src/engine/postgres_psql.rs b/src/engine/postgres_psql.rs index ae15668..3b4f010 100644 --- a/src/engine/postgres_psql.rs +++ b/src/engine/postgres_psql.rs @@ -75,16 +75,22 @@ impl PSQL { EscapedLiteral::new(SPAWN_NAMESPACE) } - /// Returns a psql `\c` command to switch to the spawn_database, if configured. - /// Used to ensure internal queries and schema migrations target the correct database. - fn spawn_db_connect_command(&self) -> InsecureRawSql { - if let Some(spawn_db) = &self.target_config.spawn_database { - InsecureRawSql::new(&format!("\\c {}\n", EscapedIdentifier::new(spawn_db))) + /// Returns a psql `\c` command to switch to the given database, or + /// an empty string if `database` is None. + fn db_connect_command(database: Option<&str>) -> InsecureRawSql { + if let Some(db) = database { + InsecureRawSql::new(&format!("\\c {}\n", EscapedIdentifier::new(db))) } else { InsecureRawSql::new("") } } + /// Returns a psql `\c` command to switch to the spawn_database, if configured. + /// Used to ensure internal queries and schema migrations target the correct database. + fn spawn_db_connect_command(&self) -> InsecureRawSql { + Self::db_connect_command(self.target_config.spawn_database.as_deref()) + } + fn build_record_migration_sql( &self, migration_name: &str, @@ -388,7 +394,11 @@ impl Engine for PSQL { ); let output = self - .execute_sql(&query, Some("unaligned")) + .execute_sql( + &query, + Some("unaligned"), + self.target_config.spawn_database.as_deref(), + ) .await .map_err(MigrationError::Database)?; @@ -607,9 +617,16 @@ impl PSQL { /// Execute SQL and return stdout as a String. /// Used for internal queries where we need to parse results. - async fn execute_sql(&self, query: &EscapedQuery, format: Option<&str>) -> Result { + /// If `database` is Some, a `\c` command is prepended to switch databases first. + async fn execute_sql( + &self, + query: &EscapedQuery, + format: Option<&str>, + database: Option<&str>, + ) -> Result { let query_str = query.as_str().to_string(); let format_owned = format.map(|s| s.to_string()); + let db_connect = Self::db_connect_command(database); // Create a shared buffer to capture stdout let stdout_buf = Arc::new(Mutex::new(Vec::new())); @@ -617,6 +634,8 @@ impl PSQL { self.execute_with_writer( Box::new(move |writer| { + // Switch database if requested + writer.write_all(db_connect.as_str().as_bytes())?; // Format settings if requested (QUIET is already set globally) if let Some(fmt) = format_owned { writer.write_all(b"\\pset tuples_only on\n")?; @@ -636,14 +655,14 @@ impl PSQL { } async fn migration_table_exists(&self) -> Result { - self.table_exists("migration").await + self.spawn_table_exists("migration").await } async fn migration_history_table_exists(&self) -> Result { - self.table_exists("migration_history").await + self.spawn_table_exists("migration_history").await } - async fn table_exists(&self, table_name: &str) -> Result { + async fn spawn_table_exists(&self, table_name: &str) -> Result { let safe_table_name = EscapedLiteral::new(table_name); // Use type-safe escaped types - escaping happens at construction time let query = sql_query!( @@ -658,7 +677,13 @@ impl PSQL { safe_table_name ); - let output = self.execute_sql(&query, Some("csv")).await?; + let output = self + .execute_sql( + &query, + Some("csv"), + self.target_config.spawn_database.as_deref(), + ) + .await?; // With tuples_only mode, output is just "t" or "f" Ok(output.trim() == "t") } @@ -673,7 +698,13 @@ impl PSQL { namespace, ); - let output = self.execute_sql(&query, Some("csv")).await?; + let output = self + .execute_sql( + &query, + Some("csv"), + self.target_config.spawn_database.as_deref(), + ) + .await?; let mut migrations = HashSet::new(); // With tuples_only mode, we get just the data rows (no headers) @@ -710,7 +741,13 @@ impl PSQL { namespace ); - let output = self.execute_sql(&query, Some("csv")).await?; + let output = self + .execute_sql( + &query, + Some("csv"), + self.target_config.spawn_database.as_deref(), + ) + .await?; // With tuples_only mode, we get just the data row (no headers). // Parse CSV: name,namespace,status_id_status,activity_id_activity,checksum diff --git a/tests/integration_postgres.rs b/tests/integration_postgres.rs index 080e698..254995e 100644 --- a/tests/integration_postgres.rs +++ b/tests/integration_postgres.rs @@ -1706,6 +1706,26 @@ COMMIT;"# no_table_output ); + // Re-apply the same migration. Because tracking is in tracking_db, + // the engine must read from tracking_db to detect it's already applied. + // If execute_sql doesn't prepend \c to the tracking database, the read + // queries hit migration_db (where _spawn tables don't exist), the + // engine won't find the prior apply, and it will re-run the SQL — + // which fails because split_db_test already exists. + let config2 = migration_helper.load_config().await?; + let cmd2 = ApplyMigration { + migration: Some(migration_name.clone()), + pinned: false, + variables: None, + yes: true, + retry: false, + }; + cmd2.execute(&config2).await.expect( + "Re-applying the same migration should succeed (detected as already applied), \ + but it failed — likely because execute_sql reads from the wrong database \ + when spawn_database is configured", + ); + // ========================================================================= // Cleanup // ========================================================================= From 64411b69a6daf6509038bd1c2d0de2cc1d5b261c Mon Sep 17 00:00:00 2001 From: Mark Saward Date: Mon, 16 Mar 2026 17:28:48 +1100 Subject: [PATCH 7/7] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/engine/postgres_psql.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/engine/postgres_psql.rs b/src/engine/postgres_psql.rs index 3b4f010..823159f 100644 --- a/src/engine/postgres_psql.rs +++ b/src/engine/postgres_psql.rs @@ -119,8 +119,8 @@ impl PSQL { let qry = sql_query!( r#" - BEGIN; {} + BEGIN; WITH inserted_migration AS ( INSERT INTO {}.migration (name, namespace) VALUES ({}, {}) ON CONFLICT (name, namespace) DO UPDATE SET name = EXCLUDED.name