Skip to content

Commit 9e3ffeb

Browse files
cloutiertylerbfopsjdetter
authored
Adds flag to publish to allow clearing on migration conflict. (#3601)
# Description of Changes This PR modifies the `--delete-data` flag on `spacetime publish` and adds the `--delete-data` flag on `spacetime dev`. In particular instead of `--delete-data` being a boolean, it is now a an enum: - `always` -> corresponds to the old value of `true` - `never` -> corresponds to the old value of `false` - `on-conflict` -> clears the database, but only if publishing would have required a manual migration This flag does NOT change any behavior about prompting users to confirm if they want to delete the data. Users will still be prompted to confirm UNLESS they pass the separate `--yes` flag. `spacetime dev` gets the same `--delete-data` flag. The default value of `never` is equivalent to the existing behavior. `spacetime dev` continues to publish with `--yes` just as before. This behavior is unchanged. # API and ABI breaking changes Adds the flags specified above. This is NOT a breaking change to the CLI. Passing `--delete-data` is the equivalent of `--delete-data=always`. This IS technically a breaking change to the `pre_publish` route. As far as I'm aware this is *only* used by our CLI however. > IMPORTANT SIDE NOTE: I would argue that `--break-clients` should really be renamed to `--yes-break-clients` because it actually behaves like the `--yes` force flag, but only for a subset of the user prompts. I have not made this change because it would be a breaking change, but if the reviewers agree, I will make this change. # Expected complexity level and risk 2, Very small change, but if we get it wrong users could accidentally lose data. I would ask reviewers to think about ways that users might accidentally pass `--delete-data --yes`. # Testing - [ ] I have not yet tested manually. --------- Signed-off-by: Tyler Cloutier <[email protected]> Co-authored-by: Zeke Foppa <[email protected]> Co-authored-by: Zeke Foppa <[email protected]> Co-authored-by: John Detter <[email protected]>
1 parent 47ac2b9 commit 9e3ffeb

File tree

10 files changed

+138
-48
lines changed

10 files changed

+138
-48
lines changed

crates/bindings/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -662,7 +662,7 @@ The following changes are forbidden without a manual migration:
662662
-**Changing whether a table is used for [scheduling](#scheduled-reducers).** <!-- TODO: update this if we ever actually implement it... -->
663663
-**Adding `#[unique]` or `#[primary_key]` constraints.** This could result in existing tables being in an invalid state.
664664

665-
Currently, manual migration support is limited. The `spacetime publish --clear-database <DATABASE_IDENTITY>` command can be used to **COMPLETELY DELETE** and reinitialize your database, but naturally it should be used with EXTREME CAUTION.
665+
Currently, manual migration support is limited. The `spacetime publish --delete-data <DATABASE_IDENTITY>` command can be used to **COMPLETELY DELETE** and reinitialize your database, but naturally it should be used with EXTREME CAUTION.
666666

667667
[macro library]: https://github.com/clockworklabs/SpacetimeDB/tree/master/crates/bindings-macro
668668
[module library]: https://github.com/clockworklabs/SpacetimeDB/tree/master/crates/lib

crates/cli/src/common_args.rs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
1-
use clap::Arg;
21
use clap::ArgAction::SetTrue;
2+
use clap::{value_parser, Arg, ValueEnum};
3+
4+
#[derive(Copy, Clone, Debug, ValueEnum, PartialEq)]
5+
pub enum ClearMode {
6+
Always, // parses as "always"
7+
OnConflict, // parses as "on-conflict"
8+
Never, // parses as "never"
9+
}
310

411
pub fn server() -> Arg {
512
Arg::new("server")
@@ -37,3 +44,20 @@ pub fn confirmed() -> Arg {
3744
.action(SetTrue)
3845
.help("Instruct the server to deliver only updates of confirmed transactions")
3946
}
47+
48+
pub fn clear_database() -> Arg {
49+
Arg::new("clear-database")
50+
.long("delete-data")
51+
.alias("clear-database")
52+
.short('c')
53+
.num_args(0..=1)
54+
.value_parser(value_parser!(ClearMode))
55+
// Because we have a default value for this flag, invocations can be ambiguous between
56+
//passing a value to this flag, vs using the default value and passing an anonymous arg
57+
// to the rest of the command. Adding `require_equals` resolves this ambiguity.
58+
.require_equals(true)
59+
.default_missing_value("always")
60+
.help(
61+
"When publishing to an existing database identity, first DESTROY all data associated with the module. With 'on-conflict': only when breaking schema changes occur."
62+
)
63+
}

crates/cli/src/subcommands/dev.rs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use crate::common_args::ClearMode;
12
use crate::config::Config;
23
use crate::generate::Language;
34
use crate::subcommands::init;
@@ -71,6 +72,7 @@ pub fn cli() -> Command {
7172
)
7273
.arg(common_args::server().help("The nickname, host name or URL of the server to publish to"))
7374
.arg(common_args::yes())
75+
.arg(common_args::clear_database())
7476
}
7577

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

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

@@ -284,6 +291,7 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E
284291
&database_name,
285292
client_language,
286293
resolved_server,
294+
clear_database,
287295
)
288296
.await
289297
{
@@ -339,6 +347,7 @@ fn upsert_env_db_names_and_hosts(env_path: &Path, server_host_url: &str, databas
339347
Ok(())
340348
}
341349

350+
#[allow(clippy::too_many_arguments)]
342351
async fn generate_build_and_publish(
343352
config: &Config,
344353
project_dir: &Path,
@@ -347,6 +356,7 @@ async fn generate_build_and_publish(
347356
database_name: &str,
348357
client_language: Option<&Language>,
349358
server: &str,
359+
clear_database: ClearMode,
350360
) -> Result<(), anyhow::Error> {
351361
let module_language = detect_module_language(spacetimedb_dir)?;
352362
let client_language = client_language.unwrap_or(match module_language {
@@ -394,7 +404,20 @@ async fn generate_build_and_publish(
394404

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

397-
let mut publish_args = vec!["publish", database_name, "--project-path", project_path_str, "--yes"];
407+
let clear_flag = match clear_database {
408+
ClearMode::Always => "always",
409+
ClearMode::Never => "never",
410+
ClearMode::OnConflict => "on-conflict",
411+
};
412+
let mut publish_args = vec![
413+
"publish",
414+
database_name,
415+
"--project-path",
416+
project_path_str,
417+
"--yes",
418+
"--delete-data",
419+
clear_flag,
420+
];
398421
publish_args.extend_from_slice(&["--server", server]);
399422

400423
let publish_cmd = publish::cli();

crates/cli/src/subcommands/publish.rs

Lines changed: 61 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use spacetimedb_client_api_messages::name::{DatabaseNameError, PrePublishResult,
88
use std::path::PathBuf;
99
use std::{env, fs};
1010

11+
use crate::common_args::ClearMode;
1112
use crate::config::Config;
1213
use crate::util::{add_auth_header_opt, get_auth_header, AuthHeader, ResponseExt};
1314
use crate::util::{decode_identity, y_or_n};
@@ -17,12 +18,8 @@ pub fn cli() -> clap::Command {
1718
clap::Command::new("publish")
1819
.about("Create and update a SpacetimeDB database")
1920
.arg(
20-
Arg::new("clear_database")
21-
.long("delete-data")
22-
.short('c')
23-
.action(SetTrue)
21+
common_args::clear_database()
2422
.requires("name|identity")
25-
.help("When publishing to an existing database identity, first DESTROY all data associated with the module"),
2623
)
2724
.arg(
2825
Arg::new("build_options")
@@ -71,7 +68,7 @@ pub fn cli() -> clap::Command {
7168
Arg::new("break_clients")
7269
.long("break-clients")
7370
.action(SetTrue)
74-
.help("Allow breaking changes when publishing to an existing database identity. This will break existing clients.")
71+
.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.")
7572
)
7673
.arg(
7774
common_args::anonymous()
@@ -109,15 +106,18 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E
109106
let server = args.get_one::<String>("server").map(|s| s.as_str());
110107
let name_or_identity = args.get_one::<String>("name|identity");
111108
let path_to_project = args.get_one::<PathBuf>("project_path").unwrap();
112-
let clear_database = args.get_flag("clear_database");
109+
let clear_database = args
110+
.get_one::<ClearMode>("clear-database")
111+
.copied()
112+
.unwrap_or(ClearMode::Never);
113113
let force = args.get_flag("force");
114114
let anon_identity = args.get_flag("anon_identity");
115115
let wasm_file = args.get_one::<PathBuf>("wasm_file");
116116
let js_file = args.get_one::<PathBuf>("js_file");
117117
let database_host = config.get_host_url(server)?;
118118
let build_options = args.get_one::<String>("build_options").unwrap();
119119
let num_replicas = args.get_one::<u8>("num_replicas");
120-
let break_clients_flag = args.get_flag("break_clients");
120+
let force_break_clients = args.get_flag("break_clients");
121121
let parent = args.get_one::<String>("parent");
122122

123123
// If the user didn't specify an identity and we didn't specify an anonymous identity, then
@@ -175,7 +175,7 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E
175175
let domain = percent_encoding::percent_encode(name_or_identity.as_bytes(), encode_set);
176176
let mut builder = client.put(format!("{database_host}/v1/database/{domain}"));
177177

178-
if !clear_database {
178+
if clear_database != ClearMode::Always {
179179
builder = apply_pre_publish_if_needed(
180180
builder,
181181
&client,
@@ -184,7 +184,9 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E
184184
host_type,
185185
&program_bytes,
186186
&auth_header,
187-
break_clients_flag,
187+
clear_database,
188+
force_break_clients,
189+
force,
188190
)
189191
.await?;
190192
}
@@ -194,7 +196,7 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E
194196
client.post(format!("{database_host}/v1/database"))
195197
};
196198

197-
if clear_database {
199+
if clear_database == ClearMode::Always || clear_database == ClearMode::OnConflict {
198200
// Note: `name_or_identity` should be set, because it is `required` in the CLI arg config.
199201
println!(
200202
"This will DESTROY the current {} module, and ALL corresponding data.",
@@ -336,25 +338,55 @@ async fn apply_pre_publish_if_needed(
336338
host_type: &str,
337339
program_bytes: &[u8],
338340
auth_header: &AuthHeader,
339-
break_clients_flag: bool,
341+
clear_database: ClearMode,
342+
force_break_clients: bool,
343+
force: bool,
340344
) -> Result<reqwest::RequestBuilder, anyhow::Error> {
341-
if let Some(pre) = call_pre_publish(client, base_url, domain, host_type, program_bytes, auth_header).await? {
342-
println!("{}", pre.migrate_plan);
343-
344-
if pre.break_clients
345-
&& !y_or_n(
346-
break_clients_flag,
347-
"The above changes will BREAK existing clients. Do you want to proceed?",
348-
)?
349-
{
350-
println!("Aborting");
351-
// Early exit: return an error or a special signal. Here we bail out by returning Err.
352-
anyhow::bail!("Publishing aborted by user");
345+
if let Some(pre) = call_pre_publish(
346+
client,
347+
base_url,
348+
&domain.to_string(),
349+
host_type,
350+
program_bytes,
351+
auth_header,
352+
)
353+
.await?
354+
{
355+
match pre {
356+
PrePublishResult::ManualMigrate(manual) => {
357+
if clear_database == ClearMode::OnConflict {
358+
println!("{}", manual.reason);
359+
println!("Proceeding with database clear due to --delete-data=on-conflict.");
360+
}
361+
if clear_database == ClearMode::Never {
362+
println!("{}", manual.reason);
363+
println!("Aborting publish due to required manual migration.");
364+
anyhow::bail!("Aborting because publishing would require manual migration or deletion of data and --delete-data was not specified.");
365+
}
366+
if clear_database == ClearMode::Always {
367+
println!("{}", manual.reason);
368+
println!("Proceeding with database clear due to --delete-data=always.");
369+
}
370+
}
371+
PrePublishResult::AutoMigrate(auto) => {
372+
println!("{}", auto.migrate_plan);
373+
// We only arrive here if you have not specified ClearMode::Always AND there was no
374+
// conflict that required manual migration.
375+
if auto.break_clients
376+
&& !y_or_n(
377+
force_break_clients || force,
378+
"The above changes will BREAK existing clients. Do you want to proceed?",
379+
)?
380+
{
381+
println!("Aborting");
382+
// Early exit: return an error or a special signal. Here we bail out by returning Err.
383+
anyhow::bail!("Publishing aborted by user");
384+
}
385+
builder = builder
386+
.query(&[("token", auto.token)])
387+
.query(&[("policy", "BreakClients")]);
388+
}
353389
}
354-
355-
builder = builder
356-
.query(&[("token", pre.token)])
357-
.query(&[("policy", "BreakClients")]);
358390
}
359391

360392
Ok(builder)
@@ -369,7 +401,7 @@ async fn call_pre_publish(
369401
auth_header: &AuthHeader,
370402
) -> Result<Option<PrePublishResult>, anyhow::Error> {
371403
let mut builder = client.post(format!("{database_host}/v1/database/{domain}/pre_publish"));
372-
let style = pretty_print_style_from_env();
404+
let style: PrettyPrintStyle = pretty_print_style_from_env();
373405
builder = builder
374406
.query(&[("pretty_print_style", style)])
375407
.query(&[("host_type", host_type)]);

crates/client-api-messages/src/name.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,12 +121,23 @@ pub enum PrettyPrintStyle {
121121
}
122122

123123
#[derive(serde::Serialize, serde::Deserialize, Debug)]
124-
pub struct PrePublishResult {
124+
pub enum PrePublishResult {
125+
AutoMigrate(PrePublishAutoMigrateResult),
126+
ManualMigrate(PrePublishManualMigrateResult),
127+
}
128+
129+
#[derive(serde::Serialize, serde::Deserialize, Debug)]
130+
pub struct PrePublishAutoMigrateResult {
125131
pub migrate_plan: Box<str>,
126132
pub break_clients: bool,
127133
pub token: spacetimedb_lib::Hash,
128134
}
129135

136+
#[derive(serde::Serialize, serde::Deserialize, Debug)]
137+
pub struct PrePublishManualMigrateResult {
138+
pub reason: String,
139+
}
140+
130141
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
131142
pub enum DnsLookupResponse {
132143
/// The lookup was successful and the domain and identity are returned.

crates/client-api/src/routes/database.rs

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ use spacetimedb::identity::Identity;
3333
use spacetimedb::messages::control_db::{Database, HostType};
3434
use spacetimedb_client_api_messages::http::SqlStmtResult;
3535
use spacetimedb_client_api_messages::name::{
36-
self, DatabaseName, DomainName, MigrationPolicy, PrePublishResult, PrettyPrintStyle, PublishOp, PublishResult,
36+
self, DatabaseName, DomainName, MigrationPolicy, PrePublishAutoMigrateResult, PrePublishManualMigrateResult,
37+
PrePublishResult, PrettyPrintStyle, PublishOp, PublishResult,
3738
};
3839
use spacetimedb_lib::db::raw_def::v9::RawModuleDefV9;
3940
use spacetimedb_lib::{sats, AlgebraicValue, Hash, ProductValue, Timestamp};
@@ -959,17 +960,17 @@ pub async fn pre_publish<S: NodeDelegate + ControlStateDelegate + Authorization>
959960
}
960961
.hash();
961962

962-
Ok(PrePublishResult {
963+
Ok(PrePublishResult::AutoMigrate(PrePublishAutoMigrateResult {
963964
token,
964965
migrate_plan: plan,
965966
break_clients: breaks_client,
966-
})
967+
}))
968+
}
969+
MigratePlanResult::AutoMigrationError(e) => {
970+
Ok(PrePublishResult::ManualMigrate(PrePublishManualMigrateResult {
971+
reason: e.to_string(),
972+
}))
967973
}
968-
MigratePlanResult::AutoMigrationError(e) => Err((
969-
StatusCode::BAD_REQUEST,
970-
format!("Automatic migration is not possible: {e}"),
971-
)
972-
.into()),
973974
}
974975
.map(axum::Json)
975976
}

docs/docs/06-Server Module Languages/04-csharp-reference.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1079,7 +1079,7 @@ The following changes are forbidden without a manual migration:
10791079
-**Changing whether a table is used for [scheduling](#scheduled-reducers).** <!-- TODO: update this if we ever actually implement it... -->
10801080
-**Adding `[Unique]` or `[PrimaryKey]` constraints.** This could result in existing tables being in an invalid state.
10811081

1082-
Currently, manual migration support is limited. The `spacetime publish --clear-database <DATABASE_IDENTITY>` command can be used to **COMPLETELY DELETE** and reinitialize your database, but naturally it should be used with EXTREME CAUTION.
1082+
Currently, manual migration support is limited. The `spacetime publish --delete-data <DATABASE_IDENTITY>` command can be used to **COMPLETELY DELETE** and reinitialize your database, but naturally it should be used with EXTREME CAUTION.
10831083

10841084
## Other infrastructure
10851085

docs/docs/06-Server Module Languages/06-Typescript-reference.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -644,7 +644,7 @@ The following deletes all data stored in the database.
644644
To fully reset your database and clear all data, run:
645645

646646
```bash
647-
spacetime publish --clear-database <DATABASE_NAME>
647+
spacetime publish --delete-data <DATABASE_NAME>
648648
# or
649649
spacetime publish -c <DATABASE_NAME>
650650
```

smoketests/tests/modules.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ class UpdateModule(Smoketest):
6565

6666

6767
def test_module_update(self):
68-
"""Test publishing a module without the --clear-database option"""
68+
"""Test publishing a module without the --delete-data option"""
6969

7070
name = random_string()
7171

@@ -88,7 +88,7 @@ def test_module_update(self):
8888
self.write_module_code(self.MODULE_CODE_B)
8989
with self.assertRaises(CalledProcessError) as cm:
9090
self.publish_module(name, clear=False)
91-
self.assertIn("Error: Pre-publish check failed", cm.exception.stderr)
91+
self.assertIn("Error: Aborting because publishing would require manual migration", cm.exception.stderr)
9292

9393
# Check that the old module is still running by calling say_hello
9494
self.call("say_hello")

smoketests/tests/permissions.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,9 @@ def test_publish(self):
5454
self.new_identity()
5555

5656
with self.assertRaises(Exception):
57-
# TODO: This raises for the wrong reason - `--clear-database` doesn't exist anymore!
58-
self.spacetime("publish", self.database_identity, "--project-path", self.project_path, "--clear-database", "--yes")
57+
self.spacetime("publish", self.database_identity, "--project-path", self.project_path, "--delete-data", "--yes")
5958

60-
# Check that this holds without `--clear-database`, too.
59+
# Check that this holds without `--delete-data`, too.
6160
with self.assertRaises(Exception):
6261
self.spacetime("publish", self.database_identity, "--project-path", self.project_path, "--yes")
6362

0 commit comments

Comments
 (0)