diff --git a/crates/core/src/host/v8/mod.rs b/crates/core/src/host/v8/mod.rs index 6e58666a248..cb7bb455ddb 100644 --- a/crates/core/src/host/v8/mod.rs +++ b/crates/core/src/host/v8/mod.rs @@ -210,6 +210,7 @@ fn env_on_isolate_unwrap(isolate: &mut Isolate) -> &mut JsInstanceEnv { /// The environment of a [`JsInstance`]. struct JsInstanceEnv { instance_env: InstanceEnv, + module_def: Option>, /// The slab of `BufferIters` created for this instance. iters: RowIters, @@ -233,6 +234,7 @@ impl JsInstanceEnv { fn new(instance_env: InstanceEnv) -> Self { Self { instance_env, + module_def: None, call_times: CallTimes::new(), iters: <_>::default(), chunk_pool: <_>::default(), @@ -273,6 +275,14 @@ impl JsInstanceEnv { wasm_instance_env_call_times, } } + + fn set_module_def(&mut self, module_def: Arc) { + self.module_def = Some(module_def); + } + + fn module_def(&self) -> Option> { + self.module_def.clone() + } } /// An instance for a [`JsModule`]. @@ -616,6 +626,7 @@ async fn spawn_instance_worker( return; } Ok((crf, module_common)) => { + env_on_isolate_unwrap(scope).set_module_def(module_common.info().module_def.clone()); // Success! Send `module_common` to the spawner. if send_result(Ok(module_common.clone())).is_err() { return; @@ -757,8 +768,7 @@ async fn spawn_instance_worker( } /// The embedder data slot for the `__get_error_constructor__` function. -/// One greater than the greatest value of [`syscall::ModuleHookKey`]. -const GET_ERROR_CONSTRUCTOR_SLOT: i32 = 5; +const GET_ERROR_CONSTRUCTOR_SLOT: i32 = syscall::ModuleHookKey::GetErrorConstructor as i32; /// Compiles, instantiate, and evaluate `code` as a module. fn eval_module<'scope>( @@ -854,7 +864,9 @@ impl WasmInstance for V8Instance<'_, '_, '_> { self.scope.get_slot::().unwrap().instance_env.tx.clone() } - fn set_module_def(&mut self, _: Arc) {} + fn set_module_def(&mut self, module_def: Arc) { + env_on_isolate_unwrap(self.scope).set_module_def(module_def); + } fn call_reducer(&mut self, op: ReducerOp<'_>, budget: FunctionBudget) -> ReducerExecuteResult { common_call(self.scope, budget, op, |scope, op| { diff --git a/crates/core/src/host/v8/syscall/common.rs b/crates/core/src/host/v8/syscall/common.rs index ee297ab3583..69e5e082389 100644 --- a/crates/core/src/host/v8/syscall/common.rs +++ b/crates/core/src/host/v8/syscall/common.rs @@ -12,18 +12,22 @@ use super::super::{ util::make_uint8array, JsInstanceEnv, }; -use super::HookFunctions; +use super::{call_call_view, call_call_view_anon, get_registered_hooks, HookFunctions}; +use crate::database_logger::{LogLevel, Record}; +use crate::error::NodesError; use crate::host::instance_env::InstanceEnv; -use crate::host::wasm_common::{RowIterIdx, TimingSpan, TimingSpanIdx}; -use crate::{ - database_logger::{LogLevel, Record}, - host::wasm_common::module_host_actor::ProcedureOp, +use crate::host::wasm_common::module_host_actor::{ + deserialize_view_rows, run_query_for_view, AnonymousViewOp, ProcedureOp, ViewOp, ViewResult, ViewReturnData, }; +use crate::host::wasm_common::{RowIterIdx, TimingSpan, TimingSpanIdx}; use anyhow::Context; use bytes::Bytes; +use spacetimedb_datastore::locking_tx_datastore::{FuncCallType, MutTxId, ViewCallInfo}; use spacetimedb_lib::{ConnectionId, Identity, RawModuleDef, Timestamp}; use spacetimedb_primitives::{ColId, IndexId, ProcedureId, TableId}; use spacetimedb_sats::bsatn; +use spacetimedb_schema::def::ModuleDef; +use spacetimedb_schema::identifier::Identifier; use v8::{FunctionCallbackArguments, Isolate, Local, PinScope, Value}; /// Calls the `__call_procedure__` function `fun`. @@ -652,9 +656,209 @@ pub fn procedure_commit_mut_tx( scope: &mut PinScope<'_, '_>, _args: FunctionCallbackArguments<'_>, ) -> SysCallResult<()> { - let env = get_env(scope)?; + let tx = { + let env = get_env(scope)?; + env.instance_env.take_mutable_tx_for_commit()? + }; - env.instance_env.commit_mutable_tx()?; + let hooks = get_registered_hooks(scope).ok_or_else(|| { + SysCallError::Exception( + TypeError("module hooks are unavailable while committing a procedure transaction").throw(scope), + ) + })?; + let module_def = get_env(scope)?.module_def().ok_or_else(|| { + SysCallError::Exception( + TypeError("module definition is unavailable while committing a procedure transaction").throw(scope), + ) + })?; + let tx = refresh_views(scope, tx, &hooks, &module_def)?; + get_env(scope)?.instance_env.commit_procedure_tx(tx)?; Ok(()) } + +/// Refresh all views made stale by a procedure `tx`. +/// +/// This runs each pending view call in the same mutable transaction and writes the refreshed rows +/// into the corresponding backing view tables. If any step fails (missing metadata, view execution, +/// row decoding, SQL execution, or materialization), this method rolls back `tx` and returns an error. +/// +/// On success, it returns the same transaction handle so the caller can commit it. +fn refresh_views( + scope: &mut PinScope<'_, '_>, + tx: MutTxId, + hooks: &HookFunctions<'_>, + module_def: &ModuleDef, +) -> SysCallResult { + let views_for_refresh = tx.views_for_refresh().cloned().collect::>(); + let stdb = get_env(scope)?.instance_env.relational_db().clone(); + let database_identity = *get_env(scope)?.instance_env.database_identity(); + let mut tx_slot = get_env(scope)?.instance_env.tx.clone(); + let mut tx = Some(tx); + + for view_call in views_for_refresh { + let res: SysCallResult<()> = (|| { + let view_def = module_def + .get_view_by_id(view_call.fn_ptr, view_call.sender.is_none()) + .ok_or_else(|| { + SysCallError::Exception( + TypeError(format!( + "view with fn_ptr `{}` not found while refreshing procedure transaction", + view_call.fn_ptr + )) + .throw(scope), + ) + })?; + + let current_tx = tx.take().expect("procedure tx missing during view refresh"); + let (next_tx, call_result) = + tx_slot.set(current_tx, || call_view(scope, hooks, &view_call, &view_def.name)); + tx = Some(next_tx); + let return_data = call_result?; + + let typespace = module_def.typespace(); + let row_product_type = typespace + .resolve(view_def.product_type_ref) + .resolve_refs() + .map_err(|err| { + SysCallError::Exception( + TypeError(format!( + "failed resolving row type for refreshed view `{}`: {err}", + view_def.name + )) + .throw(scope), + ) + })? + .into_product() + .map_err(|_| { + SysCallError::Exception( + TypeError(format!( + "failed resolving row product type for refreshed view `{}`", + view_def.name + )) + .throw(scope), + ) + })?; + + let rows = match ViewResult::from_return_data(return_data).map_err(|err| { + SysCallError::Exception( + TypeError(format!( + "failed parsing result for refreshed view `{}`: {err}", + view_def.name + )) + .throw(scope), + ) + })? { + ViewResult::Rows(bytes) => { + deserialize_view_rows(view_def.product_type_ref, bytes, typespace).map_err(NodesError::from)? + } + ViewResult::RawSql(query) => run_query_for_view( + tx.as_mut().expect("procedure tx missing while running view query"), + &query, + &row_product_type, + &view_call, + database_identity, + ) + .map_err(|err| { + SysCallError::Exception( + TypeError(format!( + "failed running query for refreshed view `{}`: {err}", + view_def.name + )) + .throw(scope), + ) + })?, + }; + + match view_call.sender { + Some(sender) => stdb + .materialize_view( + tx.as_mut() + .expect("procedure tx missing while materializing authenticated view"), + view_call.table_id, + sender, + rows, + ) + .map_err(NodesError::from)?, + None => stdb + .materialize_anonymous_view( + tx.as_mut() + .expect("procedure tx missing while materializing anonymous view"), + view_call.table_id, + rows, + ) + .map_err(NodesError::from)?, + } + + Ok(()) + })(); + + if let Err(err) = res { + let tx = tx.expect("procedure tx missing while rolling back failed view refresh"); + get_env(scope)?.instance_env.rollback_procedure_tx(tx); + return Err(err); + } + } + + Ok(tx.expect("procedure tx missing after refreshing views")) +} + +/// Execute a view and return its payload. +/// +/// This helper is used by [`refresh_views`] while a procedure transaction is being committed. +/// It temporarily sets the active function type to the target view for dependency tracking, +/// invokes the applicable JS hook, restores the previous function type, and returns [`ViewReturnData`]. +fn call_view( + scope: &mut PinScope<'_, '_>, + hooks: &HookFunctions<'_>, + view_call: &ViewCallInfo, + view_name: &Identifier, +) -> SysCallResult { + let prev_func_type = get_env(scope)? + .instance_env + .swap_func_type(FuncCallType::View(view_call.clone())); + + let result = { + let args = crate::host::ArgsTuple::nullary(); + match view_call.sender { + Some(sender) => call_call_view( + scope, + hooks, + ViewOp { + name: view_name, + view_id: view_call.view_id, + table_id: view_call.table_id, + fn_ptr: view_call.fn_ptr, + args: &args, + sender: &sender, + timestamp: Timestamp::now(), + }, + ), + None => call_call_view_anon( + scope, + hooks, + AnonymousViewOp { + name: view_name, + view_id: view_call.view_id, + table_id: view_call.table_id, + fn_ptr: view_call.fn_ptr, + args: &args, + timestamp: Timestamp::now(), + }, + ), + } + }; + + get_env(scope)?.instance_env.swap_func_type(prev_func_type); + + result.map_err(|err| match err { + ErrorOrException::Err(err) => SysCallError::Exception( + TypeError(format!( + "failed executing refreshed view `{}` during procedure commit: {err}", + view_name + )) + .throw(scope), + ), + ErrorOrException::Exception(exc) => SysCallError::Exception(exc), + }) +} diff --git a/crates/core/src/host/v8/syscall/hooks.rs b/crates/core/src/host/v8/syscall/hooks.rs index 56aa2565451..d2fef8ac88f 100644 --- a/crates/core/src/host/v8/syscall/hooks.rs +++ b/crates/core/src/host/v8/syscall/hooks.rs @@ -42,6 +42,37 @@ pub(super) fn set_hook_slots( Ok(()) } +/// Registers the hooks for the current module instance so they can be reconstructed later +/// from procedure syscalls that only have access to the current V8 context. +pub(in super::super) fn set_registered_hooks(scope: &mut PinScope<'_, '_>, hooks: &HookFunctions<'_>) -> ExcResult<()> { + let mut to_register = vec![ + (ModuleHookKey::DescribeModule, hooks.describe_module), + (ModuleHookKey::CallReducer, hooks.call_reducer), + ]; + if let Some(call_view) = hooks.call_view { + to_register.push((ModuleHookKey::CallView, call_view)); + } + if let Some(call_view_anon) = hooks.call_view_anon { + to_register.push((ModuleHookKey::CallAnonymousView, call_view_anon)); + } + if let Some(call_procedure) = hooks.call_procedure { + to_register.push((ModuleHookKey::CallProcedure, call_procedure)); + } + if let Some(get_error_constructor) = hooks.get_error_constructor { + to_register.push((ModuleHookKey::GetErrorConstructor, get_error_constructor)); + } + if let Some(sender_error_class) = hooks.sender_error_class { + to_register.push((ModuleHookKey::SenderErrorClass, sender_error_class)); + } + + set_hook_slots(scope, hooks.abi, &to_register)?; + + let ctx = scope.get_current_context(); + ctx.set_embedder_data(RECV_SLOT_INDEX, hooks.recv); + + Ok(()) +} + #[derive(enum_map::Enum, Copy, Clone)] pub(in super::super) enum ModuleHookKey { DescribeModule, @@ -49,6 +80,8 @@ pub(in super::super) enum ModuleHookKey { CallView, CallAnonymousView, CallProcedure, + GetErrorConstructor, + SenderErrorClass, } impl ModuleHookKey { @@ -59,6 +92,9 @@ impl ModuleHookKey { } } +/// Context embedder slot holding the receiver (`this`) value used for hook calls. +const RECV_SLOT_INDEX: i32 = ModuleHookKey::SenderErrorClass as i32 + 1; + /// Holds the `AbiVersion` used by the module /// and the module hooks registered by the module /// for that version. @@ -126,10 +162,12 @@ pub(in super::super) fn get_registered_hooks<'scope>( Some(HookFunctions { abi: hooks.abi, - recv: v8::undefined(scope).into(), + recv: ctx + .get_embedder_data(scope, RECV_SLOT_INDEX) + .unwrap_or_else(|| v8::undefined(scope).into()), describe_module: get(ModuleHookKey::DescribeModule)?, - get_error_constructor: None, - sender_error_class: None, + get_error_constructor: get(ModuleHookKey::GetErrorConstructor), + sender_error_class: get(ModuleHookKey::SenderErrorClass), call_reducer: get(ModuleHookKey::CallReducer)?, call_view: get(ModuleHookKey::CallView), call_view_anon: get(ModuleHookKey::CallAnonymousView), diff --git a/crates/core/src/host/v8/syscall/mod.rs b/crates/core/src/host/v8/syscall/mod.rs index ffdce5defea..5c2633645d1 100644 --- a/crates/core/src/host/v8/syscall/mod.rs +++ b/crates/core/src/host/v8/syscall/mod.rs @@ -11,7 +11,7 @@ mod hooks; mod v1; mod v2; -pub(super) use self::hooks::{get_registered_hooks, HookFunctions, ModuleHookKey}; +pub(super) use self::hooks::{get_registered_hooks, set_registered_hooks, HookFunctions, ModuleHookKey}; /// The return type of a module -> host syscall. pub(super) type FnRet<'scope> = ExcResult>; diff --git a/crates/core/src/host/v8/syscall/v2.rs b/crates/core/src/host/v8/syscall/v2.rs index 8aea8789813..ac4474ac39b 100644 --- a/crates/core/src/host/v8/syscall/v2.rs +++ b/crates/core/src/host/v8/syscall/v2.rs @@ -21,7 +21,7 @@ use super::common::{ }; use super::hooks::get_hook_function; use super::hooks::HookFunctions; -use super::AbiVersion; +use super::{set_registered_hooks, AbiVersion}; use crate::error::NodesError; use crate::host::instance_env::InstanceEnv; use crate::host::wasm_common::instrumentation::span; @@ -392,8 +392,8 @@ pub fn get_hooks_from_default_export<'scope>( let call_view_anon = get_hook_function(scope, hooks, str_from_ident!(__call_view_anon__))?; let call_procedure = get_hook_function(scope, hooks, str_from_ident!(__call_procedure__))?; - // Set the hooks. - Ok(Some(HookFunctions { + // Cache hooks in context slots so syscall-time code can reconstruct them. + let hooks = HookFunctions { abi: AbiVersion::V2, recv: hooks.into(), describe_module, @@ -403,7 +403,9 @@ pub fn get_hooks_from_default_export<'scope>( call_view: Some(call_view), call_view_anon: Some(call_view_anon), call_procedure: Some(call_procedure), - })) + }; + set_registered_hooks(scope, &hooks)?; + Ok(Some(hooks)) } fn hooks_symbol<'scope>(scope: &PinScope<'scope, '_>) -> Local<'scope, v8::Symbol> { diff --git a/crates/smoketests/src/lib.rs b/crates/smoketests/src/lib.rs index 587ceecf482..6f3893b33fe 100644 --- a/crates/smoketests/src/lib.rs +++ b/crates/smoketests/src/lib.rs @@ -278,6 +278,73 @@ pub fn pnpm_path() -> Option { PNPM_PATH.get_or_init(|| which("pnpm").ok()).clone() } +/// Runs a command and returns stdout as a string. +pub fn run_cmd(args: &[&str], cwd: &Path) -> Result { + run_cmd_inner(args, cwd, None) +} + +/// Runs a command with stdin input and returns stdout as a string. +pub fn run_cmd_with_stdin(args: &[&str], cwd: &Path, stdin_input: &str) -> Result { + run_cmd_inner(args, cwd, Some(stdin_input)) +} + +fn run_cmd_inner(args: &[&str], cwd: &Path, stdin_input: Option<&str>) -> Result { + let Some(program) = args.first() else { + bail!("run_cmd called with no program"); + }; + + let mut cmd = Command::new(program); + cmd.args(&args[1..]) + .current_dir(cwd) + .stderr(Stdio::piped()) + .stdout(Stdio::piped()); + + if stdin_input.is_some() { + cmd.stdin(Stdio::piped()); + } + + let mut child = cmd + .spawn() + .with_context(|| format!("Failed to spawn command: {args:?}"))?; + + if let Some(input) = stdin_input { + use std::io::Write; + if let Some(stdin) = child.stdin.as_mut() { + stdin.write_all(input.as_bytes())?; + } + } + + let output = child.wait_with_output()?; + + if !output.status.success() { + bail!( + "command {:?} failed:\nstdout: {}\nstderr: {}", + args, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + +/// Runs a `pnpm` command and returns stdout as a string. +pub fn pnpm(args: &[&str], cwd: &Path) -> Result { + let pnpm_path = pnpm_path().context("Could not locate pnpm")?; + let pnpm_path = pnpm_path.to_str().context("pnpm path is not valid UTF-8")?; + let mut full_args = vec![pnpm_path]; + full_args.extend(args); + run_cmd(&full_args, cwd) +} + +/// Builds the local TypeScript bindings package. +pub fn build_typescript_sdk() -> Result<()> { + let workspace = workspace_root(); + let ts_bindings = workspace.join("crates/bindings-typescript"); + pnpm(&["install"], &ts_bindings)?; + pnpm(&["build"], &ts_bindings)?; + Ok(()) +} + /// Returns true if Emscripten (emcc) is available on the system. pub fn have_emscripten() -> bool { static HAVE_EMSCRIPTEN: OnceLock = OnceLock::new(); @@ -702,6 +769,61 @@ impl Smoketest { Ok(String::from_utf8_lossy(&output.stdout).to_string()) } + /// Initializes, writes, and publishes a TypeScript module from source. + /// + /// The module is initialized at `//spacetimedb`. + /// On success this updates `self.database_identity`. + pub fn publish_typescript_module_source( + &mut self, + project_dir_name: &str, + module_name: &str, + module_source: &str, + ) -> Result { + let module_root = self.project_dir.path().join(project_dir_name); + let module_root_str = module_root.to_str().context("Invalid TypeScript project path")?; + self.spacetime(&[ + "init", + "--non-interactive", + "--lang", + "typescript", + "--project-path", + module_root_str, + module_name, + ])?; + + let module_path = module_root.join("spacetimedb"); + fs::write(module_path.join("src/index.ts"), module_source).context("Failed to write TypeScript module code")?; + + build_typescript_sdk()?; + let _ = pnpm(&["uninstall", "spacetimedb"], &module_path); + + let ts_bindings = workspace_root().join("crates/bindings-typescript"); + let ts_bindings_path = ts_bindings.to_str().context("Invalid TypeScript bindings path")?; + pnpm(&["install", ts_bindings_path], &module_path)?; + + let module_path_str = module_path.to_str().context("Invalid TypeScript module path")?; + let publish_output = self.spacetime(&[ + "publish", + "--server", + &self.server_url, + "--module-path", + module_path_str, + "--yes", + "--clear-database", + module_name, + ])?; + + let re = Regex::new(r"identity: ([0-9a-fA-F]+)").unwrap(); + let identity = re + .captures(&publish_output) + .and_then(|caps| caps.get(1)) + .map(|m| m.as_str().to_string()) + .context("Failed to parse database identity from publish output")?; + self.database_identity = Some(identity.clone()); + + Ok(identity) + } + /// Writes new module code to the project. /// /// This switches from precompiled mode to runtime compilation mode. diff --git a/crates/smoketests/tests/quickstart.rs b/crates/smoketests/tests/quickstart.rs index d0d2acd71c6..39de1f501a8 100644 --- a/crates/smoketests/tests/quickstart.rs +++ b/crates/smoketests/tests/quickstart.rs @@ -4,7 +4,10 @@ use anyhow::{bail, Context, Result}; use regex::Regex; -use spacetimedb_smoketests::{pnpm_path, require_dotnet, require_emscripten, require_pnpm, workspace_root, Smoketest}; +use spacetimedb_smoketests::{ + build_typescript_sdk, pnpm, require_dotnet, require_emscripten, require_pnpm, run_cmd, run_cmd_with_stdin, + workspace_root, Smoketest, +}; use std::fs; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; @@ -26,41 +29,6 @@ fn append_to_file(path: &Path, content: &str) -> Result<()> { Ok(()) } -/// Run a command and return stdout as a string. -fn run_cmd(args: &[&str], cwd: &Path, input: Option<&str>) -> Result { - let mut cmd = Command::new(args[0]); - cmd.args(&args[1..]) - .current_dir(cwd) - .stderr(Stdio::piped()) - .stdout(Stdio::piped()); - - if input.is_some() { - cmd.stdin(Stdio::piped()); - } - - let mut child = cmd.spawn().context(format!("Failed to spawn {:?}", args))?; - - if let Some(input_str) = input { - use std::io::Write; - if let Some(stdin) = child.stdin.as_mut() { - stdin.write_all(input_str.as_bytes())?; - } - } - - let output = child.wait_with_output()?; - - if !output.status.success() { - bail!( - "Command {:?} failed:\nstdout: {}\nstderr: {}", - args, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - } - - Ok(String::from_utf8_lossy(&output.stdout).to_string()) -} - /// Parse code blocks from quickstart markdown documentation. /// Extracts code blocks with the specified language tag. /// @@ -120,30 +88,6 @@ fn parse_quickstart(doc_content: &str, language: &str, module_name: &str, server result + &end } -/// Run pnpm command. -fn pnpm(args: &[&str], cwd: &Path) -> Result { - let pnpm_path = match pnpm_path() - .expect("Could not locate pnpm") - .into_os_string() - .into_string() - { - Ok(s) => s, - Err(os_string) => anyhow::bail!("Could not convert to string: {os_string:?}"), - }; - let mut full_args = vec![pnpm_path.as_ref()]; - full_args.extend(args); - run_cmd(&full_args, cwd, None) -} - -/// Build the TypeScript SDK. -fn build_typescript_sdk() -> Result<()> { - let workspace = workspace_root(); - let ts_bindings = workspace.join("crates/bindings-typescript"); - pnpm(&["install"], &ts_bindings)?; - pnpm(&["build"], &ts_bindings)?; - Ok(()) -} - fn nuget_config_path(project_dir: &Path) -> PathBuf { let p_upper = project_dir.join("NuGet.Config"); if p_upper.exists() { @@ -613,7 +557,6 @@ log = "0.4" run_cmd( &["cargo", "new", "--bin", "--name", "quickstart_chat_client", "client"], parent, - None, )?; } "csharp" => { @@ -628,7 +571,6 @@ log = "0.4" client_path.to_str().unwrap(), ], client_path.parent().unwrap(), - None, )?; } _ => {} @@ -677,11 +619,7 @@ log = "0.4" "bin~/Release", )?; - run_cmd( - &["dotnet", "add", "package", "SpacetimeDB.ClientSDK"], - client_path, - None, - )?; + run_cmd(&["dotnet", "add", "package", "SpacetimeDB.ClientSDK"], client_path)?; } _ => {} } @@ -690,7 +628,7 @@ log = "0.4" /// Run the client with input and check output. fn check(&self, input: &str, client_path: &Path, contains: &str) -> Result<()> { - let output = run_cmd(self.config.run_cmd, client_path, Some(input))?; + let output = run_cmd_with_stdin(self.config.run_cmd, client_path, input)?; eprintln!("Output for {} client:\n{}", self.config.lang, output); if !output.contains(contains) { @@ -744,7 +682,7 @@ log = "0.4" self.sdk_setup(&client_path)?; // Build the client - run_cmd(self.config.build_cmd, &client_path, None)?; + run_cmd(self.config.build_cmd, &client_path)?; // Generate bindings (local operation) let bindings_path = client_path.join(self.config.module_bindings); diff --git a/crates/smoketests/tests/views.rs b/crates/smoketests/tests/views.rs index cc30ad7bd5a..b7bab89696f 100644 --- a/crates/smoketests/tests/views.rs +++ b/crates/smoketests/tests/views.rs @@ -1,5 +1,37 @@ use serde_json::json; -use spacetimedb_smoketests::Smoketest; +use spacetimedb_smoketests::{require_pnpm, Smoketest}; + +const TS_VIEWS_SUBSCRIBE_MODULE: &str = r#"import { schema, t, table } from "spacetimedb/server"; + +const playerState = table( + { name: "player_state" }, + { + identity: t.identity().primaryKey(), + name: t.string().unique(), + } +); + +const spacetimedb = schema({ playerState }); +export default spacetimedb; + +export const my_player = spacetimedb.view( + { public: true }, + t.option(playerState.rowType), + ctx => ctx.db.playerState.identity.find(ctx.sender) +); + +export const insert_player_proc = spacetimedb.procedure( + { name: t.string() }, + t.unit(), + (ctx, { name }) => { + const sender = ctx.sender; + ctx.withTx(tx => { + tx.db.playerState.insert({ name, identity: sender }); + }); + return {}; + } +); +"#; /// Tests that views populate the st_view_* system tables #[test] @@ -436,3 +468,45 @@ fn test_procedure_triggers_subscription_updates() { ]) ); } + +#[test] +fn test_typescript_procedure_triggers_subscription_updates() { + require_pnpm!(); + let mut test = Smoketest::builder().autopublish(false).build(); + test.publish_typescript_module_source( + "views-subscribe-typescript", + "views-subscribe-typescript", + TS_VIEWS_SUBSCRIBE_MODULE, + ) + .unwrap(); + + let sub = test.subscribe_background(&["select * from my_player"], 1).unwrap(); + test.call("insert_player_proc", &["Alice"]).unwrap(); + let events = sub.collect().unwrap(); + + let projection: Vec = events + .into_iter() + .map(|event| { + let deletes = event["my_player"]["deletes"] + .as_array() + .unwrap() + .iter() + .map(|row| json!({"name": row["name"]})) + .collect::>(); + let inserts = event["my_player"]["inserts"] + .as_array() + .unwrap() + .iter() + .map(|row| json!({"name": row["name"]})) + .collect::>(); + json!({"my_player": {"deletes": deletes, "inserts": inserts}}) + }) + .collect(); + + assert_eq!( + serde_json::json!(projection), + serde_json::json!([ + {"my_player": {"deletes": [], "inserts": [{"name": "Alice"}]}} + ]) + ); +}