Skip to content

Commit 2f02cb4

Browse files
authored
feat(databases): add fork subcommand (#227)
* feat(databases): add fork subcommand Add `hotdata databases fork [DATABASE]` wrapping the `POST /v1/databases/{id}/fork` API: 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. * docs(skills): document databases fork command * feat(databases): use typed SDK fork handle; bump hotdata to 0.9.1 The fork endpoint is now public and generated into the SDK, so the CLI calls it through the same ergonomic `databases()` handle as create/get/list/delete instead of the raw `post_raw` seam: api.client().databases().fork(&db.id, ForkDatabaseRequest { .. }) - fork_database_request now builds the typed ForkDatabaseRequest (double -option fields) rather than raw JSON; behaviour (default name to `<source>-fork`, omit fields to inherit) is unchanged. - fork_error_exit preserves the verbatim API error plus the parquet -backed-source recovery hint, now driven off the mapped ApiError. - Bump hotdata 0.8.1 -> 0.9.1 (adds fork + ergonomic wrapper). The bump also pulls in the `default_schema` field now required on the database detail/summary/create SDK models, so the affected test fixtures in databases.rs and indexes.rs gain it. --------- Co-authored-by: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com>
1 parent 9513293 commit 2f02cb4

6 files changed

Lines changed: 283 additions & 12 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 2 deletions
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
@@ -16,7 +16,7 @@ path = "src/main.rs"
1616
# behind a shared multi-thread tokio runtime. The `arrow` feature backs result
1717
# decode; `upload_file` runs the presigned direct-to-storage upload flow
1818
# (see src/sdk.rs::Api::upload).
19-
hotdata = { version = "0.8.1", features = ["arrow"] }
19+
hotdata = { version = "0.9.1", features = ["arrow"] }
2020
# Shared multi-thread runtime for the sync wrapper; block_on is called
2121
# concurrently from rayon worker threads (see src/indexes.rs). The presigned
2222
# upload (src/sdk.rs::Api::upload) drives the SDK's async `upload_file` on this

skills/hotdata/SKILL.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ Returns workspaces with `public_id`, `name`, `active`, `favorite`, `provision_st
9393
```
9494
hotdata databases list [--workspace-id <workspace_id>] [--output table|json|yaml]
9595
hotdata databases create [--name <display_name>] [--catalog <alias>] [--table <table> ...] [--schema public] [--expires-at <duration|timestamp>] [--workspace-id <workspace_id>] [--output table|json|yaml]
96+
hotdata databases fork [<id_or_name>] [--name <display_name>] [--expires-at <duration|timestamp>] [--workspace-id <workspace_id>] [--output table|json|yaml]
9697
hotdata databases set <id_or_name>
9798
hotdata databases unset
9899
hotdata databases <id_or_name> [--workspace-id <workspace_id>] [--output table|json|yaml]
@@ -115,6 +116,7 @@ hotdata databases tables delete <table> [--database <id_or_name>] [--schema publ
115116

116117
- `list` — all managed databases in the workspace. Active database is marked with `*` under the DEFAULT column; CREATED shows when each database was made.
117118
- `create` — creates a new managed database. `--name` is an optional human-readable display name. `--catalog` sets the SQL alias used in queries (`SELECT … FROM <catalog>.schema.table`); must be `[a-z_][a-z0-9_]*`. `--expires-at` accepts relative durations (`24h`, `7d`, `90m`) or an RFC 3339 timestamp; omitting means no expiry. Repeat `--table` to declare tables up front.
119+
- `fork` — creates a new managed database that is an independent deep copy of an existing one (same schemas, tables, and data); the source is left unchanged and the two diverge freely afterwards. The source defaults to the active database; pass `<id_or_name>` (id, catalog, or name) to fork another. `--name` defaults to `<source>-fork` (so the two stay distinguishable in `list`); `--expires-at` accepts a relative duration or RFC 3339 timestamp, and when omitted a still-future source expiry is carried over. The fork becomes the active database on success. The fork answers to the **same catalog alias** as its source inside its own scope; indexes are **not** carried over. Only databases created with the current (DuckLake) storage engine can be forked — older parquet-backed databases return an error.
118120
- `set` — saves `<id_or_name>` as the active database. Subsequent `databases tables` and `context` commands use it automatically.
119121
- `unset` — clears the active database from config.
120122
- `<id_or_name>` — inspect one database (id, catalog, name, expires_at).

src/commands/databases.rs

Lines changed: 262 additions & 5 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,31 @@ 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+
) -> hotdata::models::ForkDatabaseRequest {
578+
let name = name
579+
.map(str::to_string)
580+
.or_else(|| source_label.map(|l| format!("{l}-fork")));
581+
582+
// The SDK models both fields as double-option: `None` omits the field
583+
// (server applies its default — inherit the source's name/expiry), while
584+
// `Some(Some(v))` sends the value. We never send an explicit null.
585+
hotdata::models::ForkDatabaseRequest {
586+
name: name.map(Some),
587+
expires_at: expires_at.map(|e| Some(e.to_string())),
588+
}
589+
}
590+
539591
/// Build the typed `CreateDatabaseRequest` for the SDK's `databases().create`
540592
/// handle, reusing [`create_database_request`] as the single source of truth for
541593
/// the body shape. The delete+recreate path still consumes the raw JSON form
@@ -1289,6 +1341,121 @@ pub fn create(
12891341
}
12901342
}
12911343

1344+
/// Print a fork failure and exit. Preserves the server's message verbatim and,
1345+
/// for the parquet-backed-source 400, appends a recovery hint. Non-status
1346+
/// (transport) errors fall through to the shared [`ApiError::exit`] path.
1347+
fn fork_error_exit(e: ApiError) -> ! {
1348+
use crossterm::style::Stylize;
1349+
if let ApiError::Status { ref body, .. } = e {
1350+
let msg = crate::util::api_error(body.clone());
1351+
eprintln!("{}", msg.clone().red());
1352+
// Pre-DuckLake (parquet-backed) databases can't be forked; recreating
1353+
// the database and reloading its data is the migration path.
1354+
if msg.contains("storage backend") || msg.contains("DuckLake") {
1355+
eprintln!(
1356+
"{}",
1357+
"hint: databases created before DuckLake storage can't be forked — \
1358+
recreate the database and reload its data to get a forkable one."
1359+
.dark_grey()
1360+
);
1361+
}
1362+
std::process::exit(1);
1363+
}
1364+
e.exit()
1365+
}
1366+
1367+
pub fn fork(
1368+
workspace_id: &str,
1369+
database: Option<&str>,
1370+
name: Option<&str>,
1371+
expires_at: Option<&str>,
1372+
format: &str,
1373+
) {
1374+
use crossterm::style::Stylize;
1375+
1376+
let source = resolve_current_database(database, workspace_id);
1377+
let api = Api::new(Some(workspace_id));
1378+
let db = resolve_database(&api, &source);
1379+
1380+
let request = fork_database_request(
1381+
name,
1382+
db.name.as_deref().or(db.default_catalog.as_deref()),
1383+
expires_at,
1384+
);
1385+
1386+
// The copy is synchronous but bucket-internal (server-side object copy, no
1387+
// bytes through the pod), so it rides the default request timeout. Routed
1388+
// through the same typed `databases()` handle as create/get/list/delete.
1389+
let resp = if format == "table" {
1390+
block_with_wakeup(
1391+
&api,
1392+
"Forking database...",
1393+
api.client().databases().fork(&db.id, request),
1394+
)
1395+
} else {
1396+
block(api.client().databases().fork(&db.id, request))
1397+
}
1398+
.unwrap_or_else(|e| fork_error_exit(e));
1399+
1400+
let result = CreateDatabaseResponse {
1401+
id: resp.id,
1402+
name: resp.name.flatten(),
1403+
default_catalog: Some(resp.default_catalog),
1404+
default_connection_id: resp.default_connection_id,
1405+
expires_at: resp.expires_at.flatten(),
1406+
};
1407+
1408+
if let Err(e) = crate::config::save_current_database("default", workspace_id, &result.id) {
1409+
eprintln!(
1410+
"{}",
1411+
format!("warning: database forked but could not set as current: {e}").yellow()
1412+
);
1413+
}
1414+
1415+
match format {
1416+
"json" => println!("{}", serde_json::to_string_pretty(&result).unwrap()),
1417+
"yaml" => print!("{}", serde_yaml::to_string(&result).unwrap()),
1418+
"table" => {
1419+
println!("{}", "Database forked".green());
1420+
if let Some(n) = &result.name {
1421+
println!("name: {}", n.clone().cyan());
1422+
}
1423+
if let Some(c) = &result.default_catalog {
1424+
println!("catalog: {}", c.clone().cyan());
1425+
}
1426+
println!("id: {}", result.id);
1427+
println!("forked_from: {}", db.id);
1428+
// Always printed, "never" included: with --expires-at omitted a
1429+
// still-future expiry on the source is silently inherited.
1430+
println!(
1431+
"expires_at: {}",
1432+
result.expires_at.as_deref().unwrap_or("never")
1433+
);
1434+
println!();
1435+
let catalog = result
1436+
.default_catalog
1437+
.as_deref()
1438+
.or(result.name.as_deref())
1439+
.unwrap_or("default");
1440+
println!(
1441+
"{}",
1442+
format!(
1443+
concat!(
1444+
"The fork is now the current database; the source is unchanged.\n",
1445+
"It answers to the same catalog alias as its source inside its own scope.\n",
1446+
"Indexes are not carried over — recreate them on the fork if needed.\n",
1447+
"\nQuery it now:\n",
1448+
" hotdata query \"SELECT * FROM {0}.public.<table> LIMIT 10\"",
1449+
),
1450+
catalog
1451+
)
1452+
.dark_grey()
1453+
);
1454+
}
1455+
_ => unreachable!(),
1456+
}
1457+
}
1458+
12921459
pub fn unset(workspace_id: &str) {
12931460
use crossterm::style::Stylize;
12941461
if let Err(e) = crate::config::clear_current_database("default", workspace_id) {
@@ -1980,7 +2147,7 @@ mod tests {
19802147

19812148
fn full_detail(id: &str, name: &str, conn_id: &str) -> String {
19822149
format!(
1983-
r#"{{"id":"{id}","name":"{name}","default_catalog":"default","default_connection_id":"{conn_id}","attachments":[]}}"#
2150+
r#"{{"id":"{id}","name":"{name}","default_catalog":"default","default_schema":"main","default_connection_id":"{conn_id}","attachments":[]}}"#
19842151
)
19852152
}
19862153

@@ -2005,7 +2172,7 @@ mod tests {
20052172
.with_status(200)
20062173
.with_header("content-type", "application/json")
20072174
.with_body(
2008-
r#"{"databases":[{"id":"db_abc","name":"sales","default_catalog":"db_abc"},{"id":"db_xyz","name":"warehouse","default_catalog":"db_xyz"}]}"#,
2175+
r#"{"databases":[{"id":"db_abc","name":"sales","default_catalog":"db_abc","default_schema":"main"},{"id":"db_xyz","name":"warehouse","default_catalog":"db_xyz","default_schema":"main"}]}"#,
20092176
)
20102177
.create();
20112178
let detail = server
@@ -2063,7 +2230,7 @@ mod tests {
20632230
.with_status(200)
20642231
.with_header("content-type", "application/json")
20652232
.with_body(
2066-
r#"{"databases":[{"id":"db_1","name":"sales","default_catalog":"db_1"},{"id":"db_2","name":"sales","default_catalog":"db_2"}]}"#,
2233+
r#"{"databases":[{"id":"db_1","name":"sales","default_catalog":"db_1","default_schema":"main"},{"id":"db_2","name":"sales","default_catalog":"db_2","default_schema":"main"}]}"#,
20672234
)
20682235
.create();
20692236

@@ -2220,7 +2387,7 @@ mod tests {
22202387
.mock("POST", "/v1/databases")
22212388
.match_header("X-Workspace-Id", "ws-test")
22222389
.with_status(201)
2223-
.with_body(r#"{"id":"db_new","name":"mydb","default_connection_id":"conn_abc"}"#)
2390+
.with_body(r#"{"id":"db_new","name":"mydb","default_catalog":"default","default_schema":"main","default_connection_id":"conn_abc"}"#)
22242391
.match_body(mockito::Matcher::JsonString(
22252392
serde_json::to_string(&create_database_request(
22262393
Some("mydb"),
@@ -2244,6 +2411,96 @@ mod tests {
22442411
mock.assert();
22452412
}
22462413

2414+
#[test]
2415+
fn fork_database_request_defaults_name_to_source_label_fork() {
2416+
let to_json = |r| serde_json::to_value(&r).unwrap();
2417+
// Explicit --name wins over the derived default.
2418+
assert_eq!(
2419+
to_json(fork_database_request(Some("my-fork"), Some("sales"), None)),
2420+
serde_json::json!({"name": "my-fork"})
2421+
);
2422+
// Omitted --name derives "<source-label>-fork".
2423+
assert_eq!(
2424+
to_json(fork_database_request(None, Some("sales"), Some("24h"))),
2425+
serde_json::json!({"name": "sales-fork", "expires_at": "24h"})
2426+
);
2427+
// No name anywhere: send nothing and let the server default.
2428+
assert_eq!(
2429+
to_json(fork_database_request(None, None, None)),
2430+
serde_json::json!({})
2431+
);
2432+
}
2433+
2434+
#[test]
2435+
fn fork_posts_to_fork_endpoint_and_parses_create_response() {
2436+
let mut server = mockito::Server::new();
2437+
// resolve_database resolves by id directly
2438+
let resolve = server
2439+
.mock("GET", "/v1/databases/db_src")
2440+
.with_status(200)
2441+
.with_header("content-type", "application/json")
2442+
.with_body(full_detail("db_src", "sales", "conn_src"))
2443+
.create();
2444+
let fork = server
2445+
.mock("POST", "/v1/databases/db_src/fork")
2446+
.match_header("X-Workspace-Id", "ws1")
2447+
.match_body(mockito::Matcher::JsonString(
2448+
serde_json::to_string(&fork_database_request(None, Some("sales"), Some("1h")))
2449+
.unwrap(),
2450+
))
2451+
.with_status(201)
2452+
.with_header("content-type", "application/json")
2453+
.with_body(
2454+
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"}"#,
2455+
)
2456+
.create();
2457+
2458+
let api = Api::test_new(&server.url(), "k", Some("ws1"));
2459+
let db = resolve_database(&api, "db_src");
2460+
let request = fork_database_request(None, db.name.as_deref(), Some("1h"));
2461+
let resp = block(api.client().databases().fork(&db.id, request)).unwrap();
2462+
assert_eq!(resp.id, "db_fork");
2463+
assert_eq!(resp.name.flatten().as_deref(), Some("sales-fork"));
2464+
assert_eq!(resp.default_connection_id, "conn_fork");
2465+
assert_eq!(
2466+
resp.expires_at.flatten().as_deref(),
2467+
Some("2026-07-15T19:00:00Z")
2468+
);
2469+
resolve.assert();
2470+
fork.assert();
2471+
}
2472+
2473+
#[test]
2474+
fn fork_unforkable_source_surfaces_api_error_body() {
2475+
let mut server = mockito::Server::new();
2476+
let fork = server
2477+
.mock("POST", "/v1/databases/db_old/fork")
2478+
.with_status(400)
2479+
.with_body(
2480+
r#"{"error":{"code":"BAD_REQUEST","message":"forking a database is only supported for DuckLake-backed catalogs; source database 'db_old' uses storage backend 'parquet'"}}"#,
2481+
)
2482+
.create();
2483+
2484+
let api = Api::test_new(&server.url(), "k", Some("ws1"));
2485+
let err = block(
2486+
api.client()
2487+
.databases()
2488+
.fork("db_old", fork_database_request(None, None, None)),
2489+
)
2490+
.expect_err("unforkable source should return an error");
2491+
match err {
2492+
ApiError::Status { status, body } => {
2493+
assert_eq!(status.as_u16(), 400);
2494+
assert!(
2495+
crate::util::api_error(body).contains("DuckLake-backed"),
2496+
"expected DuckLake message"
2497+
);
2498+
}
2499+
other => panic!("expected Status error, got {other:?}"),
2500+
}
2501+
fork.assert();
2502+
}
2503+
22472504
#[test]
22482505
fn tables_load_uses_default_connection_id() {
22492506
let mut server = mockito::Server::new();
@@ -2404,7 +2661,7 @@ mod tests {
24042661
.with_status(201)
24052662
.with_header("content-type", "application/json")
24062663
.with_body(
2407-
r#"{"id":"dbid_new","name":"scratch","default_catalog":"default","default_connection_id":"conn_1"}"#,
2664+
r#"{"id":"dbid_new","name":"scratch","default_catalog":"default","default_schema":"main","default_connection_id":"conn_1"}"#,
24082665
)
24092666
.create();
24102667
let api = Api::test_new(&server.url(), "k", Some("ws"));

0 commit comments

Comments
 (0)