Skip to content

Commit 077a85c

Browse files
committed
feat(databases): add fork subcommand
Add `hotdata databases fork [DATABASE]` wrapping the new internal `POST /v1/databases/{id}/fork` endpoint (runtimedb #919): create an independent deep copy of a managed database — same schemas, tables, and data, diverging freely afterwards. - Source positional defaults to the current database; resolves by id, catalog, or name like `attach`/`detach`. - `--name` defaults to `<source>-fork` so the fork and source stay distinguishable in `databases list` (an unnamed fork otherwise inherits both the source's display name and catalog alias). - `--expires-at` accepts a relative duration or RFC 3339 timestamp; when omitted a still-future source expiry is inherited, so the resolved expiry is always printed ("never" when none). - Sets the fork as the current database on success; source is unchanged. - Routes through the raw `post_raw` seam (the endpoint is excluded from the public SDK); surfaces the API error body, with a recovery hint on the pre-DuckLake (parquet-backed) 400. Validated end-to-end against production and fuzzed (malformed expiry, hostile names, hostile source ids); no crashes or orphaned databases.
1 parent 9513293 commit 077a85c

2 files changed

Lines changed: 260 additions & 0 deletions

File tree

‎src/commands/databases.rs‎

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,33 @@ pub enum DatabasesCommands {
6262
output: String,
6363
},
6464

65+
/// Fork a managed database into a new, independent database.
66+
///
67+
/// The fork contains the same schemas, tables, and data as the source, and
68+
/// answers to the same SQL catalog alias inside its own query scope; the
69+
/// two databases diverge freely afterwards (writes to one never affect the
70+
/// other). Connection catalogs attached to the source are re-attached to
71+
/// the fork; indexes are not carried over.
72+
Fork {
73+
/// Source database id, catalog, or name (defaults to the current database)
74+
database: Option<String>,
75+
76+
/// Display name for the fork. Defaults to "<source-name>-fork" so the
77+
/// two databases stay distinguishable in `databases list`.
78+
#[arg(long)]
79+
name: Option<String>,
80+
81+
/// When the fork expires. Accepts a relative duration (e.g. 24h, 7d, 90m)
82+
/// or an RFC 3339 timestamp. When omitted, a still-future expiry on the
83+
/// source is carried over; otherwise the fork never expires.
84+
#[arg(long)]
85+
expires_at: Option<String>,
86+
87+
/// Output format
88+
#[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])]
89+
output: String,
90+
},
91+
6592
/// Attach a connection as a queryable catalog on a managed database.
6693
///
6794
/// A `query` runs inside one managed database; attaching a connection makes
@@ -536,6 +563,37 @@ pub fn create_database_request(
536563
serde_json::Value::Object(req)
537564
}
538565

566+
/// Build the request body for `POST /v1/databases/{id}/fork`.
567+
///
568+
/// When `--name` is omitted the CLI defaults it to `<source-label>-fork`
569+
/// rather than letting the server carry the source's name over: an unnamed
570+
/// fork inherits the source's display name AND catalog alias, which would
571+
/// leave two rows in `databases list` identical in every user-facing column.
572+
/// `source_label` is the source's name (or catalog as a fallback).
573+
pub fn fork_database_request(
574+
name: Option<&str>,
575+
source_label: Option<&str>,
576+
expires_at: Option<&str>,
577+
) -> serde_json::Value {
578+
let mut req = serde_json::Map::new();
579+
580+
let name = name
581+
.map(str::to_string)
582+
.or_else(|| source_label.map(|l| format!("{l}-fork")));
583+
if let Some(n) = name {
584+
req.insert("name".to_string(), serde_json::Value::String(n));
585+
}
586+
587+
if let Some(exp) = expires_at {
588+
req.insert(
589+
"expires_at".to_string(),
590+
serde_json::Value::String(exp.to_string()),
591+
);
592+
}
593+
594+
serde_json::Value::Object(req)
595+
}
596+
539597
/// Build the typed `CreateDatabaseRequest` for the SDK's `databases().create`
540598
/// handle, reusing [`create_database_request`] as the single source of truth for
541599
/// the body shape. The delete+recreate path still consumes the raw JSON form
@@ -1289,6 +1347,114 @@ pub fn create(
12891347
}
12901348
}
12911349

1350+
pub fn fork(
1351+
workspace_id: &str,
1352+
database: Option<&str>,
1353+
name: Option<&str>,
1354+
expires_at: Option<&str>,
1355+
format: &str,
1356+
) {
1357+
use crossterm::style::Stylize;
1358+
1359+
let source = resolve_current_database(database, workspace_id);
1360+
let api = Api::new(Some(workspace_id));
1361+
let db = resolve_database(&api, &source);
1362+
1363+
let body = fork_database_request(
1364+
name,
1365+
db.name.as_deref().or(db.default_catalog.as_deref()),
1366+
expires_at,
1367+
);
1368+
1369+
// The fork endpoint is internal (excluded from the public spec/SDKs), so
1370+
// it goes through the raw seam instead of a typed handle. The copy is
1371+
// synchronous but bucket-internal (S3 server-side copy, no bytes through
1372+
// the pod); if a very large database ever outgrows the client's 300s
1373+
// timeout, the fix is runtimedb's planned async-job fork variant.
1374+
let spinner = crate::util::spinner("Forking database...");
1375+
let (status, resp_body) = api
1376+
.post_raw(&format!("/databases/{}/fork", db.id), &body)
1377+
.unwrap_or_else(|e| {
1378+
spinner.finish_and_clear();
1379+
e.exit()
1380+
});
1381+
spinner.finish_and_clear();
1382+
1383+
if !status.is_success() {
1384+
let msg = crate::util::api_error(resp_body);
1385+
eprintln!("{}", msg.clone().red());
1386+
// Pre-DuckLake (parquet-backed) databases can't be forked; recreating
1387+
// the database and reloading its data is the migration path.
1388+
if msg.contains("storage backend") || msg.contains("DuckLake") {
1389+
eprintln!(
1390+
"{}",
1391+
"hint: databases created before DuckLake storage can't be forked — \
1392+
recreate the database and reload its data to get a forkable one."
1393+
.dark_grey()
1394+
);
1395+
}
1396+
std::process::exit(1);
1397+
}
1398+
1399+
let result: CreateDatabaseResponse = match serde_json::from_str(&resp_body) {
1400+
Ok(v) => v,
1401+
Err(e) => {
1402+
eprintln!("error parsing fork response: {e}");
1403+
std::process::exit(1);
1404+
}
1405+
};
1406+
1407+
if let Err(e) = crate::config::save_current_database("default", workspace_id, &result.id) {
1408+
eprintln!(
1409+
"{}",
1410+
format!("warning: database forked but could not set as current: {e}").yellow()
1411+
);
1412+
}
1413+
1414+
match format {
1415+
"json" => println!("{}", serde_json::to_string_pretty(&result).unwrap()),
1416+
"yaml" => print!("{}", serde_yaml::to_string(&result).unwrap()),
1417+
"table" => {
1418+
println!("{}", "Database forked".green());
1419+
if let Some(n) = &result.name {
1420+
println!("name: {}", n.clone().cyan());
1421+
}
1422+
if let Some(c) = &result.default_catalog {
1423+
println!("catalog: {}", c.clone().cyan());
1424+
}
1425+
println!("id: {}", result.id);
1426+
println!("forked_from: {}", db.id);
1427+
// Always printed, "never" included: with --expires-at omitted a
1428+
// still-future expiry on the source is silently inherited.
1429+
println!(
1430+
"expires_at: {}",
1431+
result.expires_at.as_deref().unwrap_or("never")
1432+
);
1433+
println!();
1434+
let catalog = result
1435+
.default_catalog
1436+
.as_deref()
1437+
.or(result.name.as_deref())
1438+
.unwrap_or("default");
1439+
println!(
1440+
"{}",
1441+
format!(
1442+
concat!(
1443+
"The fork is now the current database; the source is unchanged.\n",
1444+
"It answers to the same catalog alias as its source inside its own scope.\n",
1445+
"Indexes are not carried over — recreate them on the fork if needed.\n",
1446+
"\nQuery it now:\n",
1447+
" hotdata query \"SELECT * FROM {0}.public.<table> LIMIT 10\"",
1448+
),
1449+
catalog
1450+
)
1451+
.dark_grey()
1452+
);
1453+
}
1454+
_ => unreachable!(),
1455+
}
1456+
}
1457+
12921458
pub fn unset(workspace_id: &str) {
12931459
use crossterm::style::Stylize;
12941460
if let Err(e) = crate::config::clear_current_database("default", workspace_id) {
@@ -2244,6 +2410,88 @@ mod tests {
22442410
mock.assert();
22452411
}
22462412

2413+
#[test]
2414+
fn fork_database_request_defaults_name_to_source_label_fork() {
2415+
// Explicit --name wins over the derived default.
2416+
assert_eq!(
2417+
fork_database_request(Some("my-fork"), Some("sales"), None),
2418+
serde_json::json!({"name": "my-fork"})
2419+
);
2420+
// Omitted --name derives "<source-label>-fork".
2421+
assert_eq!(
2422+
fork_database_request(None, Some("sales"), Some("24h")),
2423+
serde_json::json!({"name": "sales-fork", "expires_at": "24h"})
2424+
);
2425+
// No name anywhere: send nothing and let the server default.
2426+
assert_eq!(
2427+
fork_database_request(None, None, None),
2428+
serde_json::json!({})
2429+
);
2430+
}
2431+
2432+
#[test]
2433+
fn fork_posts_to_fork_endpoint_and_parses_create_response() {
2434+
let mut server = mockito::Server::new();
2435+
// resolve_database resolves by id directly
2436+
let resolve = server
2437+
.mock("GET", "/v1/databases/db_src")
2438+
.with_status(200)
2439+
.with_header("content-type", "application/json")
2440+
.with_body(full_detail("db_src", "sales", "conn_src"))
2441+
.create();
2442+
let fork = server
2443+
.mock("POST", "/v1/databases/db_src/fork")
2444+
.match_header("X-Workspace-Id", "ws1")
2445+
.match_body(mockito::Matcher::JsonString(
2446+
serde_json::to_string(&fork_database_request(None, Some("sales"), Some("1h")))
2447+
.unwrap(),
2448+
))
2449+
.with_status(201)
2450+
.with_body(
2451+
r#"{"id":"db_fork","name":"sales-fork","default_catalog":"default","default_connection_id":"conn_fork","default_schema":"main","expires_at":"2026-07-15T19:00:00Z"}"#,
2452+
)
2453+
.create();
2454+
2455+
let api = Api::test_new(&server.url(), "k", Some("ws1"));
2456+
let db = resolve_database(&api, "db_src");
2457+
let body = fork_database_request(None, db.name.as_deref(), Some("1h"));
2458+
let (status, resp_body) = api
2459+
.post_raw(&format!("/databases/{}/fork", db.id), &body)
2460+
.unwrap();
2461+
assert_eq!(status.as_u16(), 201);
2462+
let parsed: CreateDatabaseResponse = serde_json::from_str(&resp_body).unwrap();
2463+
assert_eq!(parsed.id, "db_fork");
2464+
assert_eq!(parsed.name.as_deref(), Some("sales-fork"));
2465+
assert_eq!(parsed.default_connection_id, "conn_fork");
2466+
assert_eq!(parsed.expires_at.as_deref(), Some("2026-07-15T19:00:00Z"));
2467+
resolve.assert();
2468+
fork.assert();
2469+
}
2470+
2471+
#[test]
2472+
fn fork_unforkable_source_surfaces_api_error_body() {
2473+
let mut server = mockito::Server::new();
2474+
let fork = server
2475+
.mock("POST", "/v1/databases/db_old/fork")
2476+
.with_status(400)
2477+
.with_body(
2478+
r#"{"error":{"code":"BAD_REQUEST","message":"forking a database is only supported for DuckLake-backed catalogs; source database 'db_old' uses storage backend 'parquet'"}}"#,
2479+
)
2480+
.create();
2481+
2482+
let api = Api::test_new(&server.url(), "k", Some("ws1"));
2483+
let (status, resp_body) = api
2484+
.post_raw(
2485+
"/databases/db_old/fork",
2486+
&fork_database_request(None, None, None),
2487+
)
2488+
.unwrap();
2489+
assert_eq!(status.as_u16(), 400);
2490+
let msg = crate::util::api_error(resp_body);
2491+
assert!(msg.contains("DuckLake-backed"), "got: {msg}");
2492+
fork.assert();
2493+
}
2494+
22472495
#[test]
22482496
fn tables_load_uses_default_connection_id() {
22492497
let mut server = mockito::Server::new();

‎src/main.rs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,18 @@ fn main() {
281281
&attach,
282282
&output,
283283
),
284+
Some(DatabasesCommands::Fork {
285+
database,
286+
name,
287+
expires_at,
288+
output,
289+
}) => databases::fork(
290+
&workspace_id,
291+
database.as_deref(),
292+
name.as_deref(),
293+
expires_at.as_deref(),
294+
&output,
295+
),
284296
Some(DatabasesCommands::Attach {
285297
connection,
286298
database,

0 commit comments

Comments
 (0)