Skip to content

Commit e182524

Browse files
committed
fix: Auto-discover SerenDB project from target URL (fixes #54)
- Add find_project_by_hostname function to ConsoleClient - Sync command now discovers project_id by matching target hostname - Prompt for API key interactively if not provided via env/CLI - Verify wal_level=logical after enabling via SerenDB API - Poll for up to 60 seconds with helpful timeout instructions
1 parent 5f41ce6 commit e182524

5 files changed

Lines changed: 208 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [6.0.7] - 2025-12-08
6+
7+
### Fixed
8+
9+
- **Auto-discover SerenDB project from target URL**: Fixed bug where `sync` command couldn't auto-enable logical replication when using explicit `--target` SerenDB URL without saved state. The tool now discovers the project by matching the target hostname against SerenDB project connection strings. Also prompts for API key interactively if not provided. (Fixes #54)
10+
11+
- **Verify wal_level after enabling logical replication**: After enabling logical replication via SerenDB API, the tool now polls the database to verify `wal_level=logical` is actually applied (up to 60 seconds), with helpful instructions if the endpoint needs manual restart.
12+
513
## [6.0.6] - 2025-12-08
614

715
### Fixed

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "database-replicator"
3-
version = "6.0.6"
3+
version = "6.0.7"
44
edition = "2021"
55
license = "Apache-2.0"
66
description = "Universal database-to-PostgreSQL replication CLI. Supports PostgreSQL, SQLite, MongoDB, and MySQL."

src/main.rs

Lines changed: 146 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -462,18 +462,72 @@ async fn main() -> anyhow::Result<()> {
462462
filter.with_table_rules(table_rule_data)
463463
};
464464

465-
// Get project_id from CLI or saved target state
466-
let effective_project_id = project_id.or_else(|| {
465+
// Get project_id from CLI, saved target state, or discover from target URL
466+
let mut effective_project_id = project_id.or_else(|| {
467467
database_replicator::serendb::load_target_state()
468468
.ok()
469469
.flatten()
470470
.map(|state| state.project_id)
471471
});
472472

473+
// If project_id is still None and target is SerenDB, try to discover it by hostname
474+
if effective_project_id.is_none()
475+
&& database_replicator::utils::is_serendb_target(&resolved_target)
476+
{
477+
// Get API key from CLI/env, or prompt user interactively
478+
let api_key = global_api_key
479+
.clone()
480+
.or_else(|| database_replicator::interactive::get_api_key().ok());
481+
482+
if let Some(api_key) = api_key {
483+
// Extract hostname from target URL
484+
if let Ok(parts) =
485+
database_replicator::utils::parse_postgres_url(&resolved_target)
486+
{
487+
tracing::info!(
488+
"Discovering SerenDB project for hostname {}...",
489+
parts.host
490+
);
491+
let client = database_replicator::serendb::ConsoleClient::new(
492+
Some(&console_api),
493+
api_key,
494+
);
495+
match client.find_project_by_hostname(&parts.host).await {
496+
Ok(Some(project_id)) => {
497+
effective_project_id = Some(project_id);
498+
}
499+
Ok(None) => {
500+
tracing::warn!(
501+
"Could not find SerenDB project matching hostname {}. \
502+
Logical replication auto-enable will be skipped.",
503+
parts.host
504+
);
505+
}
506+
Err(e) => {
507+
tracing::warn!(
508+
"Failed to discover project from hostname: {}. \
509+
Logical replication auto-enable will be skipped.",
510+
e
511+
);
512+
}
513+
}
514+
}
515+
} else {
516+
tracing::debug!(
517+
"No API key available, skipping project discovery from target hostname"
518+
);
519+
}
520+
}
521+
473522
// If project_id is available and target is SerenDB, check/enable logical replication
474523
if let Some(ref project_id) = effective_project_id {
475524
if database_replicator::utils::is_serendb_target(&resolved_target) {
476-
check_and_enable_logical_replication(project_id, &console_api).await?;
525+
check_and_enable_logical_replication(
526+
project_id,
527+
&console_api,
528+
&resolved_target,
529+
)
530+
.await?;
477531
}
478532
}
479533

@@ -536,6 +590,7 @@ async fn main() -> anyhow::Result<()> {
536590
async fn check_and_enable_logical_replication(
537591
project_id: &str,
538592
console_api: &str,
593+
target_url: &str,
539594
) -> anyhow::Result<()> {
540595
use database_replicator::serendb::ConsoleClient;
541596
use dialoguer::{theme::ColorfulTheme, Confirm};
@@ -556,6 +611,29 @@ async fn check_and_enable_logical_replication(
556611
"✓ Logical replication is already enabled for project '{}'",
557612
project.name
558613
);
614+
// Verify the actual wal_level on the database (endpoint may still be restarting)
615+
match database_replicator::postgres::connect_with_retry(target_url).await {
616+
Ok(client) => {
617+
if let Ok(row) = client.query_one("SHOW wal_level", &[]).await {
618+
let level: String = row.get(0);
619+
if level == "logical" {
620+
return Ok(());
621+
}
622+
// wal_level not yet 'logical', need to wait for endpoint restart
623+
tracing::info!(
624+
"Endpoint has wal_level='{}', waiting for restart to apply 'logical'...",
625+
level
626+
);
627+
}
628+
}
629+
Err(_) => {
630+
tracing::info!("Endpoint may be restarting, will poll for readiness...");
631+
}
632+
}
633+
// Fall through to wait for wal_level to become 'logical'
634+
println!();
635+
println!("⏳ Waiting for endpoint to restart with wal_level=logical...");
636+
wait_for_wal_level_logical(target_url).await?;
559637
return Ok(());
560638
}
561639

@@ -601,12 +679,9 @@ async fn check_and_enable_logical_replication(
601679
println!();
602680
println!("✓ Logical replication enabled successfully!");
603681
println!();
604-
println!("⏳ Waiting for endpoints to restart (this may take up to 30 seconds)...");
682+
println!("⏳ Waiting for endpoint to restart with wal_level=logical...");
605683

606-
// Wait for endpoints to restart - the wal_level change requires endpoint restart
607-
tokio::time::sleep(tokio::time::Duration::from_secs(15)).await;
608-
609-
tracing::info!("✓ Endpoints should now be ready with wal_level=logical");
684+
wait_for_wal_level_logical(target_url).await?;
610685
} else {
611686
anyhow::bail!(
612687
"Failed to enable logical replication. The API call succeeded but the setting was not updated.\n\
@@ -619,6 +694,69 @@ async fn check_and_enable_logical_replication(
619694
Ok(())
620695
}
621696

697+
/// Poll the database until wal_level becomes 'logical' (up to 60 seconds)
698+
async fn wait_for_wal_level_logical(target_url: &str) -> anyhow::Result<()> {
699+
let max_attempts = 12;
700+
let poll_interval = tokio::time::Duration::from_secs(5);
701+
702+
for attempt in 1..=max_attempts {
703+
tokio::time::sleep(poll_interval).await;
704+
705+
match database_replicator::postgres::connect_with_retry(target_url).await {
706+
Ok(client) => {
707+
match client
708+
.query_one("SHOW wal_level", &[])
709+
.await
710+
.map(|row| row.get::<_, String>(0))
711+
{
712+
Ok(level) if level == "logical" => {
713+
println!();
714+
tracing::info!("✓ Endpoint is ready with wal_level=logical");
715+
return Ok(());
716+
}
717+
Ok(level) => {
718+
print!(
719+
"\r⏳ Attempt {}/{}: wal_level={}, waiting...",
720+
attempt, max_attempts, level
721+
);
722+
std::io::Write::flush(&mut std::io::stdout()).ok();
723+
}
724+
Err(_) => {
725+
print!(
726+
"\r⏳ Attempt {}/{}: checking wal_level...",
727+
attempt, max_attempts
728+
);
729+
std::io::Write::flush(&mut std::io::stdout()).ok();
730+
}
731+
}
732+
}
733+
Err(_) => {
734+
print!(
735+
"\r⏳ Attempt {}/{}: endpoint restarting...",
736+
attempt, max_attempts
737+
);
738+
std::io::Write::flush(&mut std::io::stdout()).ok();
739+
}
740+
}
741+
}
742+
743+
println!();
744+
println!();
745+
println!("⚠️ Timed out waiting for wal_level to become 'logical'.");
746+
println!();
747+
println!("The SerenDB endpoint may need to be manually restarted:");
748+
println!(" 1. Go to https://console.serendb.com");
749+
println!(" 2. Navigate to your project's Compute endpoints");
750+
println!(" 3. Click 'Restart' on the endpoint");
751+
println!(" 4. Wait for the endpoint to become available");
752+
println!(" 5. Re-run this command");
753+
println!();
754+
anyhow::bail!(
755+
"Endpoint wal_level is still 'replica' after enabling logical replication. \
756+
The endpoint may need to be manually restarted via the SerenDB console."
757+
)
758+
}
759+
622760
#[allow(clippy::too_many_arguments)]
623761
async fn init_remote(
624762
source: String,

src/serendb/client.rs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,58 @@ impl ConsoleClient {
429429
Ok(project.enable_logical_replication)
430430
}
431431

432+
/// Find a project by matching the target hostname against project connection strings
433+
///
434+
/// This is useful when the user provides a direct connection string (e.g., from
435+
/// `init` output) but doesn't have saved target state with the project_id.
436+
///
437+
/// # Arguments
438+
///
439+
/// * `target_hostname` - The hostname to search for (e.g., "ep-xyz.serendb.com")
440+
///
441+
/// # Returns
442+
///
443+
/// The project_id if a matching project is found, None otherwise
444+
pub async fn find_project_by_hostname(&self, target_hostname: &str) -> Result<Option<String>> {
445+
let target_host_lower = target_hostname.to_lowercase();
446+
447+
// List all projects accessible to this API key
448+
let projects = self.list_projects().await?;
449+
450+
for project in projects {
451+
// Get the default branch for each project
452+
let branch = match self.get_default_branch(&project.id).await {
453+
Ok(b) => b,
454+
Err(_) => continue, // Skip projects without branches
455+
};
456+
457+
// Get connection string for this branch (use serendb as dummy database)
458+
let conn_str = match self
459+
.get_connection_string(&project.id, &branch.id, "serendb", false)
460+
.await
461+
{
462+
Ok(s) => s,
463+
Err(_) => continue, // Skip branches without endpoints
464+
};
465+
466+
// Extract hostname from the connection string
467+
if let Ok(parts) = crate::utils::parse_postgres_url(&conn_str) {
468+
if parts.host.to_lowercase() == target_host_lower {
469+
tracing::info!(
470+
"Found matching project '{}' ({}) for hostname {}",
471+
project.name,
472+
project.id,
473+
target_hostname
474+
);
475+
return Ok(Some(project.id));
476+
}
477+
}
478+
}
479+
480+
tracing::debug!("No project found matching hostname {}", target_hostname);
481+
Ok(None)
482+
}
483+
432484
async fn handle_common_errors(&self, response: &reqwest::Response) -> Result<()> {
433485
self.handle_common_errors_with_context(response, None).await
434486
}

0 commit comments

Comments
 (0)