Skip to content

Commit 54b07d0

Browse files
Shubham8287gefjonbfops
authored
cli: pre-publish endpoint call. (#3278)
# Description of Changes PR contains: * CLI changes for the `pre_publish` endpoint when publishing a module. * The regular `--yes` flag will not bypasses the *break clients* warning prompt — an extra confirmation is now required. For CI, a hidden flag `--break-clients` is added. * Added smoketest. * Some trivial naming changes in `client-api-*` crates for consistency reasons. * `pre_publish` route to accept similar Body size limit as `publish` route. # API and ABI breaking changes an additive API change, does not break anything. # Expected complexity level and risk 2 # Testing - Existing smoketests passing for backward compatibility. - New smoketest for add columns --------- Signed-off-by: Shubham Mishra <[email protected]> Co-authored-by: Phoebe Goldman <[email protected]> Co-authored-by: Zeke Foppa <[email protected]>
1 parent 6bf4cc2 commit 54b07d0

File tree

9 files changed

+254
-25
lines changed

9 files changed

+254
-25
lines changed

crates/bindings-macro/src/table.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -703,7 +703,7 @@ pub(crate) fn table_impl(mut args: TableArgs, item: &syn::DeriveInput) -> syn::R
703703
spacetimedb::table::ColumnDefault {
704704
col_id: #col_id,
705705
value: #val.serialize(spacetimedb::sats::algebraic_value::ser::ValueSerializer).expect("default value serialization failed"),
706-
}
706+
},
707707
})
708708
} else {
709709
None

crates/cli/src/subcommands/publish.rs

Lines changed: 115 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@ use clap::Arg;
22
use clap::ArgAction::{Set, SetTrue};
33
use clap::ArgMatches;
44
use reqwest::{StatusCode, Url};
5-
use spacetimedb_client_api_messages::name::PublishOp;
65
use spacetimedb_client_api_messages::name::{is_identity, parse_database_name, PublishResult};
7-
use std::fs;
6+
use spacetimedb_client_api_messages::name::{PrePublishResult, PrettyPrintStyle, PublishOp};
87
use std::path::PathBuf;
8+
use std::{env, fs};
99

1010
use crate::config::Config;
11-
use crate::util::{add_auth_header_opt, get_auth_header, ResponseExt};
11+
use crate::util::{add_auth_header_opt, get_auth_header, AuthHeader, ResponseExt};
1212
use crate::util::{decode_identity, unauth_error_context, y_or_n};
1313
use crate::{build, common_args};
1414

@@ -55,6 +55,12 @@ pub fn cli() -> clap::Command {
5555
.hide(true)
5656
.help("UNSTABLE: The number of replicas the database should have")
5757
)
58+
.arg(
59+
Arg::new("break_clients")
60+
.long("break-clients")
61+
.action(SetTrue)
62+
.help("Allow breaking changes when publishing to an existing database identity. This will break existing clients.")
63+
)
5864
.arg(
5965
common_args::anonymous()
6066
)
@@ -87,27 +93,14 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E
8793
let database_host = config.get_host_url(server)?;
8894
let build_options = args.get_one::<String>("build_options").unwrap();
8995
let num_replicas = args.get_one::<u8>("num_replicas");
96+
let break_clients_flag = args.get_flag("break_clients");
9097

9198
// If the user didn't specify an identity and we didn't specify an anonymous identity, then
9299
// we want to use the default identity
93100
// TODO(jdetter): We should maybe have some sort of user prompt here for them to be able to
94101
// easily create a new identity with an email
95102
let auth_header = get_auth_header(&mut config, anon_identity, server, !force).await?;
96103

97-
let client = reqwest::Client::new();
98-
99-
// If a domain or identity was provided, we should locally make sure it looks correct and
100-
let mut builder = if let Some(name_or_identity) = name_or_identity {
101-
if !is_identity(name_or_identity) {
102-
parse_database_name(name_or_identity)?;
103-
}
104-
let encode_set = const { &percent_encoding::NON_ALPHANUMERIC.remove(b'_').remove(b'-') };
105-
let domain = percent_encoding::percent_encode(name_or_identity.as_bytes(), encode_set);
106-
client.put(format!("{database_host}/v1/database/{domain}"))
107-
} else {
108-
client.post(format!("{database_host}/v1/database"))
109-
};
110-
111104
if !path_to_project.exists() {
112105
return Err(anyhow::anyhow!(
113106
"Project path does not exist: {}",
@@ -141,6 +134,35 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E
141134
database_host
142135
);
143136

137+
let client = reqwest::Client::new();
138+
// If a domain or identity was provided, we should locally make sure it looks correct and
139+
let mut builder = if let Some(name_or_identity) = name_or_identity {
140+
if !is_identity(name_or_identity) {
141+
parse_database_name(name_or_identity)?;
142+
}
143+
let encode_set = const { &percent_encoding::NON_ALPHANUMERIC.remove(b'_').remove(b'-') };
144+
let domain = percent_encoding::percent_encode(name_or_identity.as_bytes(), encode_set);
145+
146+
let mut builder = client.put(format!("{database_host}/v1/database/{domain}"));
147+
148+
if !clear_database {
149+
builder = apply_pre_publish_if_needed(
150+
builder,
151+
&client,
152+
&database_host,
153+
&domain.to_string(),
154+
&program_bytes,
155+
&auth_header,
156+
break_clients_flag,
157+
)
158+
.await?;
159+
};
160+
161+
builder
162+
} else {
163+
client.post(format!("{database_host}/v1/database"))
164+
};
165+
144166
if clear_database {
145167
// Note: `name_or_identity` should be set, because it is `required` in the CLI arg config.
146168
println!(
@@ -219,3 +241,79 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E
219241

220242
Ok(())
221243
}
244+
245+
/// Determine the pretty print style based on the NO_COLOR environment variable.
246+
///
247+
/// See: https://no-color.org
248+
pub fn pretty_print_style_from_env() -> PrettyPrintStyle {
249+
match env::var("NO_COLOR") {
250+
Ok(_) => PrettyPrintStyle::NoColor,
251+
Err(_) => PrettyPrintStyle::AnsiColor,
252+
}
253+
}
254+
255+
/// Applies pre-publish logic: checking for migration plan, prompting user, and
256+
/// modifying the request builder accordingly.
257+
async fn apply_pre_publish_if_needed(
258+
mut builder: reqwest::RequestBuilder,
259+
client: &reqwest::Client,
260+
base_url: &str,
261+
domain: &String,
262+
program_bytes: &[u8],
263+
auth_header: &AuthHeader,
264+
break_clients_flag: bool,
265+
) -> Result<reqwest::RequestBuilder, anyhow::Error> {
266+
if let Some(pre) = call_pre_publish(client, base_url, &domain.to_string(), program_bytes, auth_header).await? {
267+
println!("{}", pre.migrate_plan);
268+
269+
if pre.break_clients
270+
&& !y_or_n(
271+
break_clients_flag,
272+
"The above changes will BREAK existing clients. Do you want to proceed?",
273+
)?
274+
{
275+
println!("Aborting");
276+
// Early exit: return an error or a special signal. Here we bail out by returning Err.
277+
anyhow::bail!("Publishing aborted by user");
278+
}
279+
280+
builder = builder
281+
.query(&[("token", pre.token)])
282+
.query(&[("policy", "BreakClients")]);
283+
}
284+
285+
Ok(builder)
286+
}
287+
288+
async fn call_pre_publish(
289+
client: &reqwest::Client,
290+
database_host: &str,
291+
domain: &String,
292+
program_bytes: &[u8],
293+
auth_header: &AuthHeader,
294+
) -> Result<Option<PrePublishResult>, anyhow::Error> {
295+
let mut builder = client.post(format!("{database_host}/v1/database/{domain}/pre_publish"));
296+
let style = pretty_print_style_from_env();
297+
builder = builder.query(&[("pretty_print_style", style)]);
298+
299+
builder = add_auth_header_opt(builder, auth_header);
300+
301+
println!("Checking for breaking changes...");
302+
let res = builder.body(program_bytes.to_vec()).send().await?;
303+
304+
if res.status() == StatusCode::NOT_FOUND {
305+
// This is a new database, so there are no breaking changes
306+
return Ok(None);
307+
}
308+
309+
if !res.status().is_success() {
310+
anyhow::bail!(
311+
"Pre-publish check failed with status {}: {}",
312+
res.status(),
313+
res.text().await?
314+
);
315+
}
316+
317+
let pre_publish_result: PrePublishResult = res.json_or_error().await?;
318+
Ok(Some(pre_publish_result))
319+
}

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

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

123123
#[derive(serde::Serialize, serde::Deserialize, Debug)]
124-
pub struct PrintPlanResult {
124+
pub struct PrePublishResult {
125125
pub migrate_plan: Box<str>,
126126
pub break_clients: bool,
127127
pub token: spacetimedb_lib::Hash,

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ use spacetimedb::host::{MigratePlanResult, ReducerArgs};
2727
use spacetimedb::identity::Identity;
2828
use spacetimedb::messages::control_db::{Database, HostType};
2929
use spacetimedb_client_api_messages::name::{
30-
self, DatabaseName, DomainName, MigrationPolicy, PrettyPrintStyle, PrintPlanResult, PublishOp, PublishResult,
30+
self, DatabaseName, DomainName, MigrationPolicy, PrePublishResult, PrettyPrintStyle, PublishOp, PublishResult,
3131
};
3232
use spacetimedb_lib::db::raw_def::v9::RawModuleDefV9;
3333
use spacetimedb_lib::identity::AuthCtx;
@@ -694,7 +694,7 @@ pub async fn pre_publish<S: NodeDelegate + ControlStateDelegate>(
694694
Query(PrePublishQueryParams { style }): Query<PrePublishQueryParams>,
695695
Extension(auth): Extension<SpacetimeAuth>,
696696
body: Bytes,
697-
) -> axum::response::Result<axum::Json<PrintPlanResult>> {
697+
) -> axum::response::Result<axum::Json<PrePublishResult>> {
698698
// User should not be able to print migration plans for a database that they do not own
699699
let database_identity = resolve_and_authenticate(&ctx, &name_or_identity, &auth).await?;
700700
let style = match style {
@@ -729,7 +729,7 @@ pub async fn pre_publish<S: NodeDelegate + ControlStateDelegate>(
729729
}
730730
.hash();
731731

732-
Ok(PrintPlanResult {
732+
Ok(PrePublishResult {
733733
token,
734734
migrate_plan: plan,
735735
break_clients: breaks_client,
@@ -992,7 +992,7 @@ where
992992
.route("/logs", self.logs_get)
993993
.route("/sql", self.sql_post)
994994
.route("/unstable/timestamp", self.timestamp_get)
995-
.route("/pre-publish", self.pre_publish);
995+
.route("/pre_publish", self.pre_publish);
996996

997997
axum::Router::new()
998998
.route("/", self.root_post)

crates/standalone/src/subcommands/start.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ pub async fn exec(args: &ArgMatches, db_cores: JobCores) -> anyhow::Result<()> {
183183
let mut db_routes = DatabaseRoutes::default();
184184
db_routes.root_post = db_routes.root_post.layer(DefaultBodyLimit::disable());
185185
db_routes.db_put = db_routes.db_put.layer(DefaultBodyLimit::disable());
186+
db_routes.pre_publish = db_routes.pre_publish.layer(DefaultBodyLimit::disable());
186187
let extra = axum::Router::new().nest("/health", spacetimedb_client_api::routes::health::router());
187188
let service = router(&ctx, db_routes, extra).with_state(ctx.clone());
188189

docs/docs/cli-reference.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ Run `spacetime help publish` for more detailed information.
8989

9090
Default value: `.`
9191
* `-b`, `--bin-path <WASM_FILE>` — The system path (absolute or relative) to the compiled wasm binary we should publish, instead of building the project.
92+
* `--break-clients` — Allow breaking changes when publishing to an existing database identity. This will break existing clients.
9293
* `--anonymous` — Perform this action with an anonymous identity
9394
* `-s`, `--server <SERVER>` — The nickname, domain name or URL of the server to host the database.
9495
* `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com).

smoketests/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ def log_records(self, n):
216216
logs = self.spacetime("logs", "--format=json", "-n", str(n), "--", self.database_identity)
217217
return list(map(json.loads, logs.splitlines()))
218218

219-
def publish_module(self, domain=None, *, clear=True, capture_stderr=True, num_replicas=None):
219+
def publish_module(self, domain=None, *, clear=True, capture_stderr=True, num_replicas=None, break_clients=False):
220220
print("publishing module", self.publish_module)
221221
publish_output = self.spacetime(
222222
"publish",
@@ -228,6 +228,7 @@ def publish_module(self, domain=None, *, clear=True, capture_stderr=True, num_re
228228
# and so the publish step prompts for confirmation.
229229
"--yes",
230230
*["--num-replicas", f"{num_replicas}"] if num_replicas is not None else [],
231+
*["--break-clients"] if break_clients else [],
231232
capture_stderr=capture_stderr,
232233
)
233234
self.resolved_identity = re.search(r"identity: ([0-9a-fA-F]+)", publish_output)[1]

smoketests/tests/auto_migration.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,3 +227,131 @@ def test_reject_schema_changes(self):
227227
self.publish_module(self.database_identity, clear=False)
228228

229229
logging.info("Rejected as expected.")
230+
231+
class AddTableColumns(Smoketest):
232+
MODULE_CODE = """
233+
use spacetimedb::{log, ReducerContext, Table};
234+
235+
#[derive(Debug)]
236+
#[spacetimedb::table(name = person)]
237+
pub struct Person {
238+
name: String,
239+
}
240+
241+
#[spacetimedb::reducer]
242+
pub fn add_person(ctx: &ReducerContext, name: String) {
243+
ctx.db.person().insert(Person { name });
244+
}
245+
246+
#[spacetimedb::reducer]
247+
pub fn print_persons(ctx: &ReducerContext, prefix: String) {
248+
for person in ctx.db.person().iter() {
249+
log::info!("{}: {}", prefix, person.name);
250+
}
251+
}
252+
"""
253+
254+
MODULE_UPDATED = """
255+
use spacetimedb::{log, ReducerContext, Table};
256+
257+
#[derive(Debug)]
258+
#[spacetimedb::table(name = person)]
259+
pub struct Person {
260+
name: String,
261+
#[default(0)]
262+
age: u16,
263+
#[default(19)]
264+
mass: u16,
265+
}
266+
267+
#[spacetimedb::reducer]
268+
pub fn add_person(ctx: &ReducerContext, name: String) {
269+
ctx.db.person().insert(Person { name, age: 70, mass: 180 });
270+
}
271+
272+
#[spacetimedb::reducer]
273+
pub fn print_persons(ctx: &ReducerContext, prefix: String) {
274+
for person in ctx.db.person().iter() {
275+
log::info!("{}: {:?}", prefix, person);
276+
}
277+
}
278+
279+
#[spacetimedb::reducer(client_disconnected)]
280+
pub fn identity_disconnected(ctx: &ReducerContext) {
281+
log::info!("FIRST_UPDATE: client disconnected");
282+
}
283+
"""
284+
285+
MODULE_UPDATED_AGAIN = """
286+
use spacetimedb::{log, ReducerContext, Table};
287+
288+
#[derive(Debug)]
289+
#[spacetimedb::table(name = person)]
290+
pub struct Person {
291+
name: String,
292+
age: u16,
293+
#[default(19)]
294+
mass: u16,
295+
#[default(160)]
296+
height: u32,
297+
}
298+
299+
#[spacetimedb::reducer]
300+
pub fn add_person(ctx: &ReducerContext, name: String) {
301+
ctx.db.person().insert(Person { name, age: 70, mass: 180, height: 72 });
302+
}
303+
304+
#[spacetimedb::reducer]
305+
pub fn print_persons(ctx: &ReducerContext, prefix: String) {
306+
for person in ctx.db.person().iter() {
307+
log::info!("{}: {:?}", prefix, person);
308+
}
309+
}
310+
"""
311+
312+
def test_add_table_columns(self):
313+
"""Verify schema upgrades that add columns with defaults (twice)."""
314+
315+
# Subscribe to person table changes multiple times to simulate active clients
316+
for _ in range(20):
317+
self.subscribe("select * from person", n=5)
318+
319+
# Insert under initial schema
320+
self.call("add_person", "Robert")
321+
322+
# First upgrade: add age & mass columns
323+
self.write_module_code(self.MODULE_UPDATED)
324+
self.publish_module(self.database_identity, clear=False, break_clients=True)
325+
self.call("print_persons", "FIRST_UPDATE")
326+
327+
logs1 = self.logs(100)
328+
329+
# Validate disconnect + schema migration logs
330+
self.assertIn("Disconnecting all users", logs1)
331+
self.assertIn(
332+
'FIRST_UPDATE: Person { name: "Robert", age: 0, mass: 19 }',
333+
logs1,
334+
)
335+
disconnect_count = logs1.count("FIRST_UPDATE: client disconnected")
336+
self.assertEqual(
337+
disconnect_count,
338+
# this is always +1, I believe its due to test setup's own connection
339+
21,
340+
msg=f"Unexpected disconnect counts: {disconnect_count}",
341+
)
342+
343+
# Insert new data under upgraded schema
344+
self.call("add_person", "Robert2")
345+
346+
# Second upgrade
347+
self.write_module_code(self.MODULE_UPDATED_AGAIN)
348+
self.publish_module(self.database_identity, clear=False, break_clients=True)
349+
self.call("print_persons", "UPDATE_2")
350+
351+
logs2 = self.logs(100)
352+
353+
# Validate new schema with height
354+
self.assertIn(
355+
'UPDATE_2: Person { name: "Robert2", age: 70, mass: 180, height: 160 }',
356+
logs2,
357+
)

0 commit comments

Comments
 (0)