Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion crates/cli/src/common_args.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
use clap::Arg;
use clap::ArgAction::SetTrue;
use clap::{value_parser, Arg, ValueEnum};

#[derive(Copy, Clone, Debug, ValueEnum)]
pub enum ClearMode {
Always, // parses as "always"
OnConflict, // parses as "on-conflict"
Never, // parses as "never"
}

pub fn server() -> Arg {
Arg::new("server")
Expand Down Expand Up @@ -37,3 +44,16 @@ pub fn confirmed() -> Arg {
.action(SetTrue)
.help("Instruct the server to deliver only updates of confirmed transactions")
}

pub fn clear_database() -> Arg {
Arg::new("clear-database")
.long("delete-data")
.short('c')
.num_args(0..=1)
.value_parser(value_parser!(ClearMode))
.require_equals(true)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious about the UX choice behind require_equals for this arg but not others

Copy link
Contributor Author

@cloutiertyler cloutiertyler Nov 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because it's ambiguous and a little confusing otherwise:

spacetime publish --delete-data always foobar # Does this delete a database called "always" or does it always delete data?

If we didn't do this you couldn't use the tool with a database named always on-conflict or never. In practice that's probably not a problem, but since requires_equals can always be relaxed, I thought we'd start by requiring it.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
.require_equals(true)
// Because we have a default value for this flag, invocations can be ambiguous between
//passing a value to this flag, vs using the default value and passing an anonymous arg
// to the rest of the command. Adding `require_equals` resolves this ambiguity.
.require_equals(true)

.default_missing_value("always")
.help(
"When publishing to an existing database identity, first DESTROY all data associated with the module. With 'on-conflict': only when breaking schema changes occur."
)
}
25 changes: 24 additions & 1 deletion crates/cli/src/subcommands/dev.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::common_args::ClearMode;
use crate::config::Config;
use crate::generate::Language;
use crate::subcommands::init;
Expand Down Expand Up @@ -71,6 +72,7 @@ pub fn cli() -> Command {
)
.arg(common_args::server().help("The nickname, host name or URL of the server to publish to"))
.arg(common_args::yes())
.arg(common_args::clear_database())
}

#[derive(Deserialize)]
Expand All @@ -89,6 +91,10 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E
let spacetimedb_project_path = args.get_one::<PathBuf>("module-project-path").unwrap();
let module_bindings_path = args.get_one::<PathBuf>("module-bindings-path").unwrap();
let client_language = args.get_one::<Language>("client-lang");
let clear_database = args
.get_one::<ClearMode>("clear-database")
.copied()
.unwrap_or(ClearMode::Never);
let force = args.get_flag("force");

// If you don't specify a server, we default to your default server
Expand Down Expand Up @@ -236,6 +242,7 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E
&database_name,
client_language,
resolved_server,
clear_database,
)
.await?;

Expand Down Expand Up @@ -284,6 +291,7 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E
&database_name,
client_language,
resolved_server,
clear_database,
)
.await
{
Expand Down Expand Up @@ -339,6 +347,7 @@ fn upsert_env_db_names_and_hosts(env_path: &Path, server_host_url: &str, databas
Ok(())
}

#[allow(clippy::too_many_arguments)]
async fn generate_build_and_publish(
config: &Config,
project_dir: &Path,
Expand All @@ -347,6 +356,7 @@ async fn generate_build_and_publish(
database_name: &str,
client_language: Option<&Language>,
server: &str,
clear_database: ClearMode,
) -> Result<(), anyhow::Error> {
let module_language = detect_module_language(spacetimedb_dir)?;
let client_language = client_language.unwrap_or(match module_language {
Expand Down Expand Up @@ -394,7 +404,20 @@ async fn generate_build_and_publish(

let project_path_str = spacetimedb_dir.to_str().unwrap();

let mut publish_args = vec!["publish", database_name, "--project-path", project_path_str, "--yes"];
let clear_flag = match clear_database {
ClearMode::Always => "always",
ClearMode::Never => "never",
ClearMode::OnConflict => "on-conflict",
};
let mut publish_args = vec![
"publish",
database_name,
"--project-path",
project_path_str,
"--yes",
"--clear-database",
clear_flag,
];
publish_args.extend_from_slice(&["--server", server]);

let publish_cmd = publish::cli();
Expand Down
75 changes: 47 additions & 28 deletions crates/cli/src/subcommands/publish.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use spacetimedb_client_api_messages::name::{PrePublishResult, PrettyPrintStyle,
use std::path::PathBuf;
use std::{env, fs};

use crate::common_args::ClearMode;
use crate::config::Config;
use crate::util::{add_auth_header_opt, get_auth_header, AuthHeader, ResponseExt};
use crate::util::{decode_identity, unauth_error_context, y_or_n};
Expand All @@ -16,12 +17,8 @@ pub fn cli() -> clap::Command {
clap::Command::new("publish")
.about("Create and update a SpacetimeDB database")
.arg(
Arg::new("clear_database")
.long("delete-data")
.short('c')
.action(SetTrue)
common_args::clear_database()
.requires("name|identity")
.help("When publishing to an existing database identity, first DESTROY all data associated with the module"),
)
.arg(
Arg::new("build_options")
Expand Down Expand Up @@ -70,7 +67,7 @@ pub fn cli() -> clap::Command {
Arg::new("break_clients")
.long("break-clients")
.action(SetTrue)
.help("Allow breaking changes when publishing to an existing database identity. This will break existing clients.")
.help("Allow breaking changes when publishing to an existing database identity. This will force publish even if it will break existing clients, but will NOT force publish if it would cause deletion of any data in the database. See --yes and --delete-data for details.")
)
.arg(
common_args::anonymous()
Expand All @@ -97,15 +94,18 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E
let server = args.get_one::<String>("server").map(|s| s.as_str());
let name_or_identity = args.get_one::<String>("name|identity");
let path_to_project = args.get_one::<PathBuf>("project_path").unwrap();
let clear_database = args.get_flag("clear_database");
let clear_database = args
.get_one::<ClearMode>("clear-database")
.copied()
.unwrap_or(ClearMode::Never);
let force = args.get_flag("force");
let anon_identity = args.get_flag("anon_identity");
let wasm_file = args.get_one::<PathBuf>("wasm_file");
let js_file = args.get_one::<PathBuf>("js_file");
let database_host = config.get_host_url(server)?;
let build_options = args.get_one::<String>("build_options").unwrap();
let num_replicas = args.get_one::<u8>("num_replicas");
let break_clients_flag = args.get_flag("break_clients");
let force_break_clients = args.get_flag("break_clients");

// If the user didn't specify an identity and we didn't specify an anonymous identity, then
// we want to use the default identity
Expand Down Expand Up @@ -162,7 +162,7 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E

let mut builder = client.put(format!("{database_host}/v1/database/{domain}"));

if !clear_database {
if !(matches!(clear_database, ClearMode::Always)) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I don't know that matches! is more readable than == for simple enum values 🤷

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh can you do that? I think I didn't realize that.

builder = apply_pre_publish_if_needed(
builder,
&client,
Expand All @@ -171,7 +171,9 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E
host_type,
&program_bytes,
&auth_header,
break_clients_flag,
clear_database,
force_break_clients,
force,
)
.await?;
};
Expand All @@ -181,7 +183,7 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E
client.post(format!("{database_host}/v1/database"))
};

if clear_database {
if matches!(clear_database, ClearMode::Always) || matches!(clear_database, ClearMode::OnConflict) {
// Note: `name_or_identity` should be set, because it is `required` in the CLI arg config.
println!(
"This will DESTROY the current {} module, and ALL corresponding data.",
Expand Down Expand Up @@ -291,7 +293,9 @@ async fn apply_pre_publish_if_needed(
host_type: &str,
program_bytes: &[u8],
auth_header: &AuthHeader,
break_clients_flag: bool,
clear_database: ClearMode,
force_break_clients: bool,
force: bool,
) -> Result<reqwest::RequestBuilder, anyhow::Error> {
if let Some(pre) = call_pre_publish(
client,
Expand All @@ -303,22 +307,37 @@ async fn apply_pre_publish_if_needed(
)
.await?
{
println!("{}", pre.migrate_plan);

if pre.break_clients
&& !y_or_n(
break_clients_flag,
"The above changes will BREAK existing clients. Do you want to proceed?",
)?
{
println!("Aborting");
// Early exit: return an error or a special signal. Here we bail out by returning Err.
anyhow::bail!("Publishing aborted by user");
match pre {
PrePublishResult::ManualMigrate(manual) => {
if matches!(clear_database, ClearMode::OnConflict) {
println!("{}", manual.reason);
println!("Proceeding with database clear due to --delete-data=on-conflict.");
}
if matches!(clear_database, ClearMode::Never) {
println!("{}", manual.reason);
println!("Aborting publish due to required manual migration.");
anyhow::bail!("Publishing aborted by user");
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this a correct bail message? Sure the user is ultimately responsible, but isn't it really just that they didn't choose to pass --clear-data? that feels like the user not doing something.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, yes it is. Good catch. Will fix.

}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: should we maybe assert that clear_database isn't ClearMode::Always, or something similar? I feel like it takes me an extra pass or two reading this code to remember that the caller has chosen not to call this in that case.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, good point. Will fix.

}
PrePublishResult::AutoMigrate(auto) => {
println!("{}", auto.migrate_plan);
// We only arrive here if you have not specified ClearMode::Always AND there was no
// conflict that required manual migration.
if auto.break_clients
&& !y_or_n(
force_break_clients || force,
"The above changes will BREAK existing clients. Do you want to proceed?",
)?
{
println!("Aborting");
// Early exit: return an error or a special signal. Here we bail out by returning Err.
anyhow::bail!("Publishing aborted by user");
}
builder = builder
.query(&[("token", auto.token)])
.query(&[("policy", "BreakClients")]);
}
}

builder = builder
.query(&[("token", pre.token)])
.query(&[("policy", "BreakClients")]);
}

Ok(builder)
Expand All @@ -333,7 +352,7 @@ async fn call_pre_publish(
auth_header: &AuthHeader,
) -> Result<Option<PrePublishResult>, anyhow::Error> {
let mut builder = client.post(format!("{database_host}/v1/database/{domain}/pre_publish"));
let style = pretty_print_style_from_env();
let style: PrettyPrintStyle = pretty_print_style_from_env();
builder = builder
.query(&[("pretty_print_style", style)])
.query(&[("host_type", host_type)]);
Expand Down
13 changes: 12 additions & 1 deletion crates/client-api-messages/src/name.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,12 +121,23 @@ pub enum PrettyPrintStyle {
}

#[derive(serde::Serialize, serde::Deserialize, Debug)]
pub struct PrePublishResult {
pub enum PrePublishResult {
AutoMigrate(PrePublishAutoMigrateResult),
ManualMigrate(PrePublishManualMigrateResult),
}

#[derive(serde::Serialize, serde::Deserialize, Debug)]
pub struct PrePublishAutoMigrateResult {
pub migrate_plan: Box<str>,
pub break_clients: bool,
pub token: spacetimedb_lib::Hash,
}

#[derive(serde::Serialize, serde::Deserialize, Debug)]
pub struct PrePublishManualMigrateResult {
pub reason: String,
}

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum DnsLookupResponse {
/// The lookup was successful and the domain and identity are returned.
Expand Down
17 changes: 9 additions & 8 deletions crates/client-api/src/routes/database.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ use spacetimedb::host::{ProcedureCallError, ReducerCallError};
use spacetimedb::identity::Identity;
use spacetimedb::messages::control_db::{Database, HostType};
use spacetimedb_client_api_messages::name::{
self, DatabaseName, DomainName, MigrationPolicy, PrePublishResult, PrettyPrintStyle, PublishOp, PublishResult,
self, DatabaseName, DomainName, MigrationPolicy, PrePublishAutoMigrateResult, PrePublishManualMigrateResult,
PrePublishResult, PrettyPrintStyle, PublishOp, PublishResult,
};
use spacetimedb_lib::db::raw_def::v9::RawModuleDefV9;
use spacetimedb_lib::identity::AuthCtx;
Expand Down Expand Up @@ -833,17 +834,17 @@ pub async fn pre_publish<S: NodeDelegate + ControlStateDelegate>(
}
.hash();

Ok(PrePublishResult {
Ok(PrePublishResult::AutoMigrate(PrePublishAutoMigrateResult {
token,
migrate_plan: plan,
break_clients: breaks_client,
})
}))
}
MigratePlanResult::AutoMigrationError(e) => {
Ok(PrePublishResult::ManualMigrate(PrePublishManualMigrateResult {
reason: e.to_string(),
}))
}
MigratePlanResult::AutoMigrationError(e) => Err((
StatusCode::BAD_REQUEST,
format!("Automatic migration is not possible: {e}"),
)
.into()),
}
.map(axum::Json)
}
Expand Down
Loading