@@ -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+ "\n Query 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+
12921458pub 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 ( ) ;
0 commit comments