Skip to content

Commit ccf30a3

Browse files
coolreader18joshua-spacetime
authored andcommitted
Noa/ts module host changes (#3388)
# Description of Changes Host-side changes extracted from #3327 I added AUTO_INC_OVERFLOW even though we don't currently ever return it, in order to future-proof so it's already there when we start emitting it. Prepublish was failing because it was expecting a wasm module unconditionally, so now it takes ?host_type. I tweaked JS deser to accept null/undefined when the unit type or an option type is expected. I switched to bsatn, because the native sats->js translator wasn't matching what js was expecting. I renamed the sys module: my thinking is that `spacetime:` as a scheme will help disambiguate it, and maybe it could also be used for IMC in the future or something? And I believe we had discussed wanting this to be versioned, similar to wasm imports. Trying to get a borrowed str from deserialize_js doesn't work, because v8 strings don't store utf8. # Testing <!-- Describe any testing you've done, and any testing you'd like your reviewers to do, so that you're confident that all the changes work as expected! --> - [x] All this was done in the course of getting an actual typescript module to successfully publish.
1 parent 85435e6 commit ccf30a3

11 files changed

Lines changed: 219 additions & 36 deletions

File tree

crates/bindings-csharp/Runtime/Exceptions.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@ public class NoSpaceException : StdbException
7272
public override string Message => "The provided bytes sink has no more room left";
7373
}
7474

75+
public class AutoIncOverflowException : StdbException
76+
{
77+
public override string Message => "The auto-increment sequence overflowed";
78+
}
79+
7580
public class UnknownException : StdbException
7681
{
7782
private readonly Errno code;

crates/bindings-csharp/Runtime/Internal/FFI.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ public enum Errno : short
3636
SCHEDULE_AT_DELAY_TOO_LONG = 13,
3737
INDEX_NOT_UNIQUE = 14,
3838
NO_SUCH_ROW = 15,
39+
AUTO_INC_OVERFLOW = 16,
3940
}
4041

4142
#pragma warning disable IDE1006 // Naming Styles - Not applicable to FFI stuff.
@@ -96,6 +97,7 @@ public static CheckedStatus ConvertToManaged(Errno status)
9697
Errno.SCHEDULE_AT_DELAY_TOO_LONG => new ScheduleAtDelayTooLongException(),
9798
Errno.INDEX_NOT_UNIQUE => new IndexNotUniqueException(),
9899
Errno.NO_SUCH_ROW => new NoSuchRowException(),
100+
Errno.AUTO_INC_OVERFLOW => new AutoIncOverflowException(),
99101
_ => new UnknownException(status),
100102
};
101103
}

crates/bindings/src/table.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1009,7 +1009,7 @@ fn insert<T: Table>(mut row: T::Row, mut buf: IterBuf) -> Result<T::Row, TryInse
10091009
sys::Errno::UNIQUE_ALREADY_EXISTS => {
10101010
T::UniqueConstraintViolation::get().map(TryInsertError::UniqueConstraintViolation)
10111011
}
1012-
// sys::Errno::AUTO_INC_OVERFLOW => Tbl::AutoIncOverflow::get().map(TryInsertError::AutoIncOverflow),
1012+
sys::Errno::AUTO_INC_OVERFLOW => T::AutoIncOverflow::get().map(TryInsertError::AutoIncOverflow),
10131013
_ => None,
10141014
};
10151015
err.unwrap_or_else(|| panic!("unexpected insertion error: {e}"))

crates/cli/src/subcommands/publish.rs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E
169169
&client,
170170
&database_host,
171171
&domain.to_string(),
172+
host_type,
172173
&program_bytes,
173174
&auth_header,
174175
break_clients_flag,
@@ -275,16 +276,27 @@ pub fn pretty_print_style_from_env() -> PrettyPrintStyle {
275276

276277
/// Applies pre-publish logic: checking for migration plan, prompting user, and
277278
/// modifying the request builder accordingly.
279+
#[allow(clippy::too_many_arguments)]
278280
async fn apply_pre_publish_if_needed(
279281
mut builder: reqwest::RequestBuilder,
280282
client: &reqwest::Client,
281283
base_url: &str,
282284
domain: &String,
285+
host_type: &str,
283286
program_bytes: &[u8],
284287
auth_header: &AuthHeader,
285288
break_clients_flag: bool,
286289
) -> Result<reqwest::RequestBuilder, anyhow::Error> {
287-
if let Some(pre) = call_pre_publish(client, base_url, &domain.to_string(), program_bytes, auth_header).await? {
290+
if let Some(pre) = call_pre_publish(
291+
client,
292+
base_url,
293+
&domain.to_string(),
294+
host_type,
295+
program_bytes,
296+
auth_header,
297+
)
298+
.await?
299+
{
288300
println!("{}", pre.migrate_plan);
289301

290302
if pre.break_clients
@@ -310,12 +322,15 @@ async fn call_pre_publish(
310322
client: &reqwest::Client,
311323
database_host: &str,
312324
domain: &String,
325+
host_type: &str,
313326
program_bytes: &[u8],
314327
auth_header: &AuthHeader,
315328
) -> Result<Option<PrePublishResult>, anyhow::Error> {
316329
let mut builder = client.post(format!("{database_host}/v1/database/{domain}/pre_publish"));
317330
let style = pretty_print_style_from_env();
318-
builder = builder.query(&[("pretty_print_style", style)]);
331+
builder = builder
332+
.query(&[("pretty_print_style", style)])
333+
.query(&[("host_type", host_type)]);
319334

320335
builder = add_auth_header_opt(builder, auth_header);
321336

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

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -701,15 +701,29 @@ pub struct PrePublishParams {
701701
pub struct PrePublishQueryParams {
702702
#[serde(default)]
703703
style: PrettyPrintStyle,
704+
#[serde(default)]
705+
host_type: HostType,
704706
}
705707

706708
pub async fn pre_publish<S: NodeDelegate + ControlStateDelegate>(
707709
State(ctx): State<S>,
708710
Path(PrePublishParams { name_or_identity }): Path<PrePublishParams>,
709-
Query(PrePublishQueryParams { style }): Query<PrePublishQueryParams>,
711+
Query(PrePublishQueryParams { style, host_type }): Query<PrePublishQueryParams>,
710712
Extension(auth): Extension<SpacetimeAuth>,
711713
body: Bytes,
712714
) -> axum::response::Result<axum::Json<PrePublishResult>> {
715+
// Feature gate V8 modules.
716+
// The host must've been compiled with the `unstable` feature.
717+
// TODO(v8): ungate this when V8 is ready to ship.
718+
#[cfg(not(feature = "unstable"))]
719+
if host_type == HostType::Js {
720+
return Err((
721+
StatusCode::BAD_REQUEST,
722+
"JS host type requires a host with unstable features",
723+
)
724+
.into());
725+
}
726+
713727
// User should not be able to print migration plans for a database that they do not own
714728
let database_identity = resolve_and_authenticate(&ctx, &name_or_identity, &auth).await?;
715729
let style = match style {
@@ -723,7 +737,7 @@ pub async fn pre_publish<S: NodeDelegate + ControlStateDelegate>(
723737
database_identity,
724738
program_bytes: body.into(),
725739
num_replicas: None,
726-
host_type: HostType::Wasm,
740+
host_type,
727741
},
728742
style,
729743
)

crates/core/src/host/v8/de.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,11 @@ impl<'de, 'this, 'scope: 'de> de::Deserializer<'de> for Deserializer<'this, 'sco
131131
deserialize_primitive!(deserialize_f32, f32);
132132

133133
fn deserialize_product<V: ProductVisitor<'de>>(self, visitor: V) -> Result<V::Output, Self::Error> {
134+
// In `ProductType.serializeValue()` in the TS SDK, null/undefined is accepted for the unit type.
135+
if visitor.product_len() == 0 && self.input.is_null_or_undefined() {
136+
return visitor.visit_seq_product(de::UnitAccess::new());
137+
}
138+
134139
let object = cast!(
135140
self.common.scope,
136141
self.input,
@@ -149,6 +154,17 @@ impl<'de, 'this, 'scope: 'de> de::Deserializer<'de> for Deserializer<'this, 'sco
149154

150155
fn deserialize_sum<V: SumVisitor<'de>>(self, visitor: V) -> Result<V::Output, Self::Error> {
151156
let scope = &*self.common.scope;
157+
158+
// In `SumType.serializeValue()` in the TS SDK, option is treated specially -
159+
// null/undefined marks none, any other value `x` is `some(x)`.
160+
if visitor.is_option() {
161+
return if self.input.is_null_or_undefined() {
162+
visitor.visit_sum(de::NoneAccess::new())
163+
} else {
164+
visitor.visit_sum(de::SomeAccess::new(self))
165+
};
166+
}
167+
152168
let sum_name = visitor.sum_name().unwrap_or("<unknown>");
153169

154170
// We expect a canonical representation of a sum value in JS to be

crates/core/src/host/v8/error.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ pub(super) type ValueResult<'scope, T> = Result<T, ExceptionValue<'scope>>;
1515
///
1616
/// Newtyped for additional type safety and to track JS exceptions in the type system.
1717
#[derive(Debug)]
18-
pub(super) struct ExceptionValue<'scope>(Local<'scope, Value>);
18+
pub(super) struct ExceptionValue<'scope>(pub(super) Local<'scope, Value>);
1919

2020
/// Error types that can convert into JS exception values.
2121
pub(super) trait IntoException<'scope> {

crates/core/src/host/v8/mod.rs

Lines changed: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ use core::{ptr, str};
2626
use spacetimedb_client_api_messages::energy::ReducerBudget;
2727
use spacetimedb_datastore::locking_tx_datastore::MutTxId;
2828
use spacetimedb_datastore::traits::Program;
29-
use spacetimedb_lib::{ConnectionId, Identity, RawModuleDef, Timestamp};
29+
use spacetimedb_lib::{bsatn, ConnectionId, Identity, RawModuleDef, Timestamp};
3030
use spacetimedb_schema::auto_migrate::MigrationPolicy;
3131
use std::sync::{Arc, LazyLock};
3232
use std::time::Instant;
@@ -415,7 +415,7 @@ fn eval_module<'scope>(
415415
script_id: i32,
416416
code: &str,
417417
resolve_deps: impl MapFnTo<ResolveModuleCallback<'scope>>,
418-
) -> ExcResult<(Local<'scope, v8::Module>, Local<'scope, Value>)> {
418+
) -> ExcResult<(Local<'scope, v8::Module>, Local<'scope, v8::Promise>)> {
419419
// Get the source map, if any.
420420
let source_map_url = find_source_map(code)
421421
.map(|sm| sm.into_string(scope))
@@ -454,14 +454,28 @@ fn eval_module<'scope>(
454454
// Evaluate the module.
455455
let value = module.evaluate(scope).ok_or_else(exception_already_thrown)?;
456456

457+
if module.get_status() == v8::ModuleStatus::Errored {
458+
// If there's an exception while evaluating the code of the module, `evaluate()` won't
459+
// throw, but instead the status will be `Errored` and the exception can be obtained from
460+
// `get_exception()`.
461+
return Err(error::ExceptionValue(module.get_exception()).throw(scope));
462+
}
463+
464+
let value = value.cast::<v8::Promise>();
465+
if value.state() == v8::PromiseState::Pending {
466+
// If the user were to put top-level `await new Promise((resolve) => { /* do nothing */ })`
467+
// the module value would never actually resolve. For now, reject this entirely.
468+
return Err(error::TypeError("module has top-level await and is pending").throw(scope));
469+
}
470+
457471
Ok((module, value))
458472
}
459473

460474
/// Compile, instantiate, and evaluate the user module with `code`.
461475
fn eval_user_module<'scope>(
462476
scope: &PinScope<'scope, '_>,
463477
code: &str,
464-
) -> ExcResult<(Local<'scope, v8::Module>, Local<'scope, Value>)> {
478+
) -> ExcResult<(Local<'scope, v8::Module>, Local<'scope, v8::Promise>)> {
465479
let name = str_from_ident!(spacetimedb_module).string(scope).into();
466480
eval_module(scope, name, 0, code, resolve_sys_module)
467481
}
@@ -614,7 +628,7 @@ fn call_call_reducer<'scope>(
614628
let sender = serialize_to_js(scope, &sender.to_u256())?;
615629
let conn_id: v8::Local<'_, v8::Value> = serialize_to_js(scope, &conn_id.to_u128())?;
616630
let timestamp = serialize_to_js(scope, &timestamp)?;
617-
let reducer_args = serialize_to_js(scope, &reducer_args.tuple.elements)?;
631+
let reducer_args = serialize_to_js(scope, reducer_args.get_bsatn())?;
618632
let args = &[reducer_id, sender, conn_id, timestamp, reducer_args];
619633

620634
// Get the function on the global proxy object and convert to a function.
@@ -677,8 +691,16 @@ fn call_describe_module<'scope>(
677691
let raw_mod_js = call_free_fun(scope, fun, &[])?;
678692

679693
// Deserialize the raw module.
680-
let raw_mod: RawModuleDef = deserialize_js(scope, raw_mod_js)?;
681-
Ok(raw_mod)
694+
let raw_mod = cast!(
695+
scope,
696+
raw_mod_js,
697+
v8::Uint8Array,
698+
"bytes return from __describe_module__"
699+
)
700+
.map_err(|e| e.throw(scope))?;
701+
702+
let bytes = raw_mod.get_contents(&mut []);
703+
bsatn::from_slice::<RawModuleDef>(bytes).map_err(|_e| error::TypeError("invalid bsatn module def").throw(scope))
682704
}
683705

684706
#[cfg(test)]
@@ -779,19 +801,7 @@ js error Uncaught Error: foobar
779801
fn call_describe_module_works() {
780802
let code = r#"
781803
function __describe_module__() {
782-
return {
783-
"tag": "V9",
784-
"value": {
785-
"typespace": {
786-
"types": [],
787-
},
788-
"tables": [],
789-
"reducers": [],
790-
"types": [],
791-
"misc_exports": [],
792-
"row_level_security": [],
793-
},
794-
};
804+
return new Uint8Array([1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
795805
}
796806
"#;
797807
let raw_mod = with_script_catch(code, call_describe_module).map_err(|e| e.to_string());

crates/core/src/host/v8/syscall.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ fn register_sys_module<'scope>(scope: &mut PinScope<'scope, '_>) -> Local<'scope
104104
)
105105
}
106106

107-
const SYS_MODULE_NAME: &StringConst = str_from_ident!(spacetimedb_sys);
107+
const SYS_MODULE_NAME: &StringConst = &StringConst::new("spacetime:sys@1.0");
108108

109109
/// The return type of a module -> host syscall.
110110
pub(super) type FnRet<'scope> = ExcResult<Local<'scope, Value>>;
@@ -258,8 +258,8 @@ fn with_span<'scope, R>(
258258
/// Throws a `TypeError` if:
259259
/// - `name` is not `string`.
260260
fn table_id_from_name(scope: &mut PinScope<'_, '_>, args: FunctionCallbackArguments<'_>) -> SysCallResult<TableId> {
261-
let name: &str = deserialize_js(scope, args.get(0))?;
262-
Ok(env_on_isolate(scope).instance_env.table_id_from_name(name)?)
261+
let name: String = deserialize_js(scope, args.get(0))?;
262+
Ok(env_on_isolate(scope).instance_env.table_id_from_name(&name)?)
263263
}
264264

265265
/// Module ABI that finds the `IndexId` for an index name.
@@ -294,8 +294,8 @@ fn table_id_from_name(scope: &mut PinScope<'_, '_>, args: FunctionCallbackArgume
294294
/// Throws a `TypeError`:
295295
/// - if `name` is not `string`.
296296
fn index_id_from_name(scope: &mut PinScope<'_, '_>, args: FunctionCallbackArguments<'_>) -> SysCallResult<IndexId> {
297-
let name: &str = deserialize_js(scope, args.get(0))?;
298-
Ok(env_on_isolate(scope).instance_env.index_id_from_name(name)?)
297+
let name: String = deserialize_js(scope, args.get(0))?;
298+
Ok(env_on_isolate(scope).instance_env.index_id_from_name(&name)?)
299299
}
300300

301301
/// Module ABI that returns the number of rows currently in table identified by `table_id`.

crates/primitives/src/errno.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ macro_rules! errnos {
2222
SCHEDULE_AT_DELAY_TOO_LONG(13, "Specified delay in scheduling row was too long"),
2323
INDEX_NOT_UNIQUE(14, "The index was not unique"),
2424
NO_SUCH_ROW(15, "The row was not found, e.g., in an update call"),
25+
AUTO_INC_OVERFLOW(16, "The auto-increment sequence overflowed"),
2526
);
2627
};
2728
}

0 commit comments

Comments
 (0)