From 02954c6f9d4a43c55264896fa16499d7b13e4122 Mon Sep 17 00:00:00 2001 From: Alessandro Asoni Date: Mon, 1 Jun 2026 15:40:50 +0200 Subject: [PATCH] flatten mounted module IDs into host ID spaces --- crates/core/src/client/message_handlers_v1.rs | 2 +- crates/core/src/db/relational_db.rs | 10 ++ crates/core/src/host/module_host.rs | 160 ++++++++++++++---- crates/core/src/host/scheduler.rs | 14 +- .../src/host/wasm_common/module_host_actor.rs | 36 ++-- .../src/locking_tx_datastore/mut_tx.rs | 43 +++++ 6 files changed, 213 insertions(+), 52 deletions(-) diff --git a/crates/core/src/client/message_handlers_v1.rs b/crates/core/src/client/message_handlers_v1.rs index 0ab284b2da1..039f4e4900c 100644 --- a/crates/core/src/client/message_handlers_v1.rs +++ b/crates/core/src/client/message_handlers_v1.rs @@ -49,7 +49,7 @@ pub async fn handle(client: &ClientConnection, message: DataMessage, timer: Inst res.map_err(|e| { ( Some(reducer), - mod_info.module_def.reducer_full(&**reducer).map(|(id, _)| id), + mod_info.module_def.reducer_by_name(&**reducer).map(|(id, _)| id), e.into(), ) }) diff --git a/crates/core/src/db/relational_db.rs b/crates/core/src/db/relational_db.rs index c8b81b90fea..2151ed6f8c1 100644 --- a/crates/core/src/db/relational_db.rs +++ b/crates/core/src/db/relational_db.rs @@ -1148,6 +1148,16 @@ impl RelationalDB { Ok(tx.create_view(module_def, view_def)?) } + pub fn create_view_with_prefix( + &self, + tx: &mut MutTx, + owning_def: &ModuleDef, + view_def: &ViewDef, + name_prefix: &str, + ) -> Result<(ViewId, TableId), DBError> { + Ok(tx.create_view_with_prefix(owning_def, view_def, name_prefix)?) + } + pub fn drop_view(&self, tx: &mut MutTx, view_id: ViewId) -> Result<(), DBError> { Ok(tx.drop_view(view_id)?) } diff --git a/crates/core/src/host/module_host.rs b/crates/core/src/host/module_host.rs index 1a8cf3257f9..dc3dc9846a4 100644 --- a/crates/core/src/host/module_host.rs +++ b/crates/core/src/host/module_host.rs @@ -63,7 +63,7 @@ use spacetimedb_lib::{bsatn, ConnectionId, TimeDuration, Timestamp}; use spacetimedb_primitives::{ArgId, HttpHandlerId, ProcedureId, TableId, ViewFnPtr, ViewId}; use spacetimedb_query::compile_subscription; use spacetimedb_sats::raw_identifier::RawIdentifier; -use spacetimedb_sats::{AlgebraicType, AlgebraicTypeRef, ProductValue}; +use spacetimedb_sats::{AlgebraicType, AlgebraicTypeRef, ProductValue, Typespace}; use spacetimedb_schema::auto_migrate::{AutoMigrateError, MigrationPolicy}; use spacetimedb_schema::def::{ModuleDef, ProcedureDef, ReducerDef, TableDef, ViewDef}; use spacetimedb_schema::identifier::Identifier; @@ -560,9 +560,50 @@ pub fn create_table_from_def( module_def: &ModuleDef, table_def: &TableDef, ) -> anyhow::Result<()> { - let schema = TableSchema::from_module_def(module_def, table_def, (), TableId::SENTINEL); + create_table_from_def_with_prefix(stdb, tx, module_def, table_def, "") +} + +/// Creates a mounted submodule table in `stdb`, applying the namespace to its canonical name. +/// `name_prefix` is the dot-terminated namespace string (e.g. `"alias."`). +pub fn create_table_from_def_with_prefix( + stdb: &RelationalDB, + tx: &mut MutTxId, + owning_def: &ModuleDef, + table_def: &TableDef, + name_prefix: &str, +) -> anyhow::Result<()> { + let mut schema = TableSchema::from_module_def(owning_def, table_def, (), TableId::SENTINEL); + if !name_prefix.is_empty() { + // Use accessor_name so the canonical DB name matches what TypeScript looks up + // as `namespace + table.sourceName`. '.' is not a valid XID char so namespaced + // table names can never collide with user-defined tables. + let prefixed_name = format!("{}{}", name_prefix, &*table_def.accessor_name); + schema.table_name = TableName::new_raw(RawIdentifier::from(prefixed_name)); + + // No alias needed: the namespaced canonical name is already the unique lookup key. + schema.alias = None; + + // Apply the namespace to the scheduled reducer/procedure name so the scheduler can + // resolve it via reducer_by_name / procedure_by_name, both of which use '/' as the + // namespace separator (e.g. "lib." prefix → "lib/reducerName"). + // name_prefix is always of the form "ns." or "ns1.ns2." with no internal dots in + // individual segments, so replacing '.' with '/' is unambiguous. + if let Some(schedule) = &mut schema.schedule { + let fn_prefix = name_prefix.replace('.', "/"); + let prefixed_fn = format!("{}{}", fn_prefix, &*schedule.function_name); + schedule.function_name = Identifier::new_assume_valid(RawIdentifier::from(prefixed_fn)); + } + + // Apply the namespace to index canonical names and aliases for global uniqueness. + for index in &mut schema.indexes { + index.index_name = RawIdentifier::from(format!("{}{}", name_prefix, index.index_name)); + if let Some(alias) = &index.alias { + index.alias = Some(RawIdentifier::from(format!("{}{}", name_prefix, alias))); + } + } + } stdb.create_table(tx, schema) - .with_context(|| format!("failed to create table {}", &table_def.name))?; + .with_context(|| format!("failed to create table {}{}", name_prefix, &*table_def.accessor_name))?; Ok(()) } @@ -578,6 +619,20 @@ pub fn create_table_from_view_def( Ok(()) } +/// Creates the table for a mounted `view_def` in `stdb`, applying the namespace prefix. +/// `name_prefix` is the dot-terminated namespace string (e.g. `"lib."`). +pub fn create_table_from_view_def_with_prefix( + stdb: &RelationalDB, + tx: &mut MutTxId, + owning_def: &ModuleDef, + view_def: &ViewDef, + name_prefix: &str, +) -> anyhow::Result<()> { + stdb.create_view_with_prefix(tx, owning_def, view_def, name_prefix) + .with_context(|| format!("failed to create table for view {}{}", name_prefix, &view_def.name))?; + Ok(()) +} + /// Moves out the `trapped: bool` from `res`. fn extract_trapped(res: Result<(T, bool), E>) -> (Result, bool) { match res { @@ -612,21 +667,35 @@ fn init_database_inner( let auth_ctx = AuthCtx::for_current(owner_identity); let (tx, ()) = stdb .with_auto_rollback(tx, |tx| { - // Create all in-memory tables defined by the module, - // with IDs ordered lexicographically by the table names. - let mut table_defs: Vec<_> = module_def.tables().collect(); - table_defs.sort_by_key(|x| &x.name); - for def in table_defs { - logger.info(&format!("Creating table `{}`", &def.name)); - create_table_from_def(stdb, tx, module_def, def)?; + // Create all in-memory tables defined by the module (including mounted submodules), + // with IDs ordered lexicographically by their full namespaced names. + let mut table_defs = module_def.all_tables_with_prefix(); + table_defs.sort_by(|(p1, _, d1), (p2, _, d2)| { + let n1 = format!("{}{}", p1, d1.name); + let n2 = format!("{}{}", p2, d2.name); + n1.cmp(&n2) + }); + for (prefix, owning_def, def) in table_defs { + let display_name = format!("{}{}", prefix, def.name); + logger.info(&format!("Creating table `{}`", display_name)); + create_table_from_def_with_prefix(stdb, tx, owning_def, def, &prefix)?; } - // Create all in-memory views defined by the module. - let mut view_defs: Vec<_> = module_def.views().collect(); - view_defs.sort_by_key(|x| &x.name); - for def in view_defs { - logger.info(&format!("Creating table for view `{}`", &def.name)); - create_table_from_view_def(stdb, tx, module_def, def)?; + // Create all in-memory views defined by the module (root + mounted). + let mut view_defs: Vec<(String, &ModuleDef, &ViewDef)> = module_def.all_views_with_prefix(); + view_defs.sort_by(|(p1, _, d1), (p2, _, d2)| { + let n1 = format!("{}{}", p1, d1.name); + let n2 = format!("{}{}", p2, d2.name); + n1.cmp(&n2) + }); + for (prefix, owning_def, def) in view_defs { + let display_name = format!("{}{}", prefix, def.name); + logger.info(&format!("Creating table for view `{}`", display_name)); + if prefix.is_empty() { + create_table_from_view_def(stdb, tx, owning_def, def)?; + } else { + create_table_from_view_def_with_prefix(stdb, tx, owning_def, def, &prefix)?; + } } // Insert the late-bound row-level security expressions. @@ -706,7 +775,7 @@ pub fn call_identity_connected( // abort the connection: we can't really recover. let tx = Some(ScopeGuard::into_inner(mut_tx)); let params = ModuleHost::call_reducer_params( - module, + &module.module_def, caller_auth.claims.identity, Some(caller_connection_id), None, @@ -1087,6 +1156,10 @@ pub struct CallViewParams { pub args: ArgsTuple, pub row_type: AlgebraicTypeRef, pub timestamp: Timestamp, + /// The typespace of the module that owns this view. + /// For root views this equals the top-level typespace; + /// for mounted views this is the mount's own typespace. + pub view_typespace: Typespace, } pub struct CallProcedureParams { @@ -2083,7 +2156,7 @@ impl ModuleHost { // that `st_client` is updated appropriately. let tx = Some(mut_tx); let result = Self::call_reducer_params( - info, + &info.module_def, caller_identity, Some(caller_connection_id), None, @@ -2170,7 +2243,7 @@ impl ModuleHost { } fn call_reducer_params( - module: &ModuleInfo, + owning_def: &ModuleDef, caller_identity: Identity, caller_connection_id: Option, client: Option>, @@ -2181,7 +2254,7 @@ impl ModuleHost { args: FunctionArgs, ) -> Result { let args = args - .into_tuple_for_def(&module.module_def, reducer_def) + .into_tuple_for_def(owning_def, reducer_def) .map_err(InvalidReducerArguments)?; let caller_connection_id = caller_connection_id.unwrap_or(ConnectionId::ZERO); Ok(CallReducerParams { @@ -2206,10 +2279,10 @@ impl ModuleHost { reducer_name: &str, args: FunctionArgs, ) -> Result<(&'a ReducerDef, CallReducerParams), ReducerCallError> { - let (reducer_id, reducer_def) = self + let (reducer_id, reducer_def, owning_def) = self .info .module_def - .reducer_full(reducer_name) + .reducer_by_name_with_module(reducer_name) .ok_or(ReducerCallError::NoSuchReducer)?; if let Some(lifecycle) = reducer_def.lifecycle { return Err(ReducerCallError::LifecycleReducer(lifecycle)); @@ -2222,7 +2295,7 @@ impl ModuleHost { Ok(( reducer_def, Self::call_reducer_params( - &self.info, + owning_def, caller_identity, caller_connection_id, client, @@ -2248,7 +2321,17 @@ impl ModuleHost { fn log_reducer_submit_error(&self, reducer_name: &str, err: &ReducerCallError) { let log_message = match err { - ReducerCallError::NoSuchReducer => Some(no_such_function_log_message("reducer", reducer_name)), + // Only log NoSuchReducer when the name is also not a known procedure. + // The HTTP /call/:reducer endpoint falls back to procedure on NoSuchReducer, + // so a valid procedure name would otherwise incorrectly produce an error log. + ReducerCallError::NoSuchReducer => { + let module_def = &self.info().module_def; + if module_def.procedure_by_name(reducer_name).is_none() { + Some(no_such_function_log_message("reducer", reducer_name)) + } else { + None + } + } ReducerCallError::Args(_) => Some(args_error_log_message("reducer", reducer_name)), _ => None, }; @@ -2719,10 +2802,10 @@ impl ModuleHost { procedure_name: &str, args: FunctionArgs, ) -> Result<(&'a ProcedureDef, CallProcedureParams), ProcedureCallError> { - let (procedure_id, procedure_def) = self + let (procedure_id, procedure_def, owning_def) = self .info .module_def - .procedure_full(procedure_name) + .procedure_by_name_with_module(procedure_name) .ok_or(ProcedureCallError::NoSuchProcedure)?; if procedure_def.visibility.is_private() && !self.is_database_owner(caller_identity) { @@ -2730,7 +2813,7 @@ impl ModuleHost { } let args = args - .into_tuple_for_def(&self.info.module_def, procedure_def) + .into_tuple_for_def(owning_def, procedure_def) .map_err(InvalidProcedureArguments)?; let caller_connection_id = caller_connection_id.unwrap_or(ConnectionId::ZERO); @@ -2838,7 +2921,7 @@ impl ModuleHost { view_collector.collect_views(&mut view_ids); for view_id in view_ids { let st_view_row = tx.lookup_st_view(view_id)?; - let view_name = st_view_row.view_name.into(); + let view_name = Identifier::new_assume_valid(st_view_row.view_name.into()); let view_id = st_view_row.view_id; let table_id = st_view_row.table_id.ok_or(ViewCallError::TableDoesNotExist(view_id))?; let is_anonymous = st_view_row.is_anonymous; @@ -2904,11 +2987,13 @@ impl ModuleHost { sender, } in tx.views_for_refresh().cloned().collect::>() { - let Some(view_def) = module_def.get_view_by_id(fn_ptr, sender.is_none()) else { + let Some((view_def, owning_def)) = + module_def.get_view_by_global_id_with_module(fn_ptr, sender.is_none()) + else { outcome = ViewOutcome::Failed(format!("view with fn_ptr `{fn_ptr}` not found")); break; }; - let args = match FunctionArgs::Nullary.into_tuple_for_def(module_def, view_def) { + let args = match FunctionArgs::Nullary.into_tuple_for_def(owning_def, view_def) { Ok(args) => args, Err(err) => { outcome = ViewOutcome::Failed(format!("failed to build view args: {err}")); @@ -2922,12 +3007,13 @@ impl ModuleHost { &view_def.name, view_id, table_id, - view_def.fn_ptr, + fn_ptr, caller, sender, args, view_def.product_type_ref, timestamp, + owning_def.typespace().clone(), ); // Increment execution stats @@ -2990,15 +3076,17 @@ impl ModuleHost { timestamp: Timestamp, ) -> Result<(ViewCallResult, bool), ViewCallError> { let module_def = &instance.common.info().module_def; - let view_def = module_def.view(view_name).ok_or(ViewCallError::NoSuchView)?; - let fn_ptr = view_def.fn_ptr; + let (global_fn_ptr, view_def, owning_def) = module_def + .view_by_name_with_global_fn_ptr(view_name.as_ref()) + .ok_or(ViewCallError::NoSuchView)?; let row_type = view_def.product_type_ref; let args = args - .into_tuple_for_def(module_def, view_def) + .into_tuple_for_def(owning_def, view_def) .map_err(InvalidViewArguments)?; Ok(Self::call_view_inner( - instance, tx, view_name, view_id, table_id, fn_ptr, caller, sender, args, row_type, timestamp, + instance, tx, view_name, view_id, table_id, global_fn_ptr, caller, sender, args, row_type, timestamp, + owning_def.typespace().clone(), )) } @@ -3014,6 +3102,7 @@ impl ModuleHost { args: ArgsTuple, row_type: AlgebraicTypeRef, timestamp: Timestamp, + view_typespace: Typespace, ) -> (ViewCallResult, bool) { let view_name = name.clone(); let params = CallViewParams { @@ -3026,6 +3115,7 @@ impl ModuleHost { sender, args, row_type, + view_typespace, }; instance.common.call_view_with_tx(tx, params, instance.instance) diff --git a/crates/core/src/host/scheduler.rs b/crates/core/src/host/scheduler.rs index f001a19ab53..8dee9e4df17 100644 --- a/crates/core/src/host/scheduler.rs +++ b/crates/core/src/host/scheduler.rs @@ -306,12 +306,13 @@ impl ScheduledFunctionParams { } fn kind(&self, module: &ModuleInfo) -> ScheduledFunctionKind { - if module.module_def.procedure_full(self.function_name()).is_some() { + if module.module_def.procedure_by_name(self.function_name()).is_some() { ScheduledFunctionKind::Procedure } else { ScheduledFunctionKind::Reducer } } + } #[derive(thiserror::Error, Debug)] @@ -760,11 +761,14 @@ fn function_to_reducer_call_params( ) -> anyhow::Result<(Timestamp, Instant, CallReducerParams)> { let identity = module.database_identity; + // Find the reducer and deserialize the arguments. + // Use the owning module's typespace (not necessarily the root's) so that type-index + // references inside the def are resolved correctly for mounted submodules. let module = &module.module_def; - let Some((id, def)) = module.reducer_full(name) else { + let Some((id, def, owning)) = module.reducer_by_name_with_module(name) else { return Err(anyhow!("Reducer `{name}` not found")); }; - let args = args.into_tuple_for_def(module, def).map_err(InvalidReducerArguments)?; + let args = args.into_tuple_for_def(owning, def).map_err(InvalidReducerArguments)?; let (ts, instant) = scheduled_call_time(at); Ok((ts, instant, CallReducerParams::from_system(ts, identity, id, args))) @@ -779,11 +783,11 @@ fn function_to_procedure_call_params( let identity = module.database_identity; let module = &module.module_def; - let Some((id, def)) = module.procedure_full(name) else { + let Some((id, def, owning)) = module.procedure_by_name_with_module(name) else { return Err(anyhow!("Procedure `{name}` not found")); }; let args = args - .into_tuple_for_def(module, def) + .into_tuple_for_def(owning, def) .map_err(InvalidProcedureArguments)?; let (ts, instant) = scheduled_call_time(at); diff --git a/crates/core/src/host/wasm_common/module_host_actor.rs b/crates/core/src/host/wasm_common/module_host_actor.rs index e3000fbdddc..8d62b1c6844 100644 --- a/crates/core/src/host/wasm_common/module_host_actor.rs +++ b/crates/core/src/host/wasm_common/module_host_actor.rs @@ -1306,6 +1306,7 @@ impl InstanceCommon { args, row_type, timestamp, + view_typespace, } = params; let _outer_span = start_call_function_span(&view_name, &caller, None); @@ -1360,15 +1361,14 @@ impl InstanceCommon { // This is wrapped in a closure to simplify error handling. let outcome: Result = (|| { let result = ViewResult::from_return_data(raw).context("Error parsing view result")?; - let typespace = self.info.module_def.typespace(); - let row_product_type = typespace + let row_product_type = view_typespace .resolve(row_type) .resolve_refs()? .into_product() .map_err(|_| anyhow!("Error resolving row type for view"))?; let rows = match result { - ViewResult::Rows(bytes) => deserialize_view_rows(row_type, bytes, typespace) + ViewResult::Rows(bytes) => deserialize_view_rows(row_type, bytes, &view_typespace) .context("Error deserializing rows returned by view".to_string())?, ViewResult::RawSql(query) => self .run_query_for_view( @@ -1505,24 +1505,36 @@ fn collect_subscribed_view_calls( ) -> Result, anyhow::Error> { let mut view_calls = Vec::new(); - for view in module_def.views() { + for (prefix, owning_def, view) in module_def.all_views_with_prefix() { let ViewDef { - name: view_name, + accessor_name, + name: local_name, is_anonymous, - fn_ptr, product_type_ref, .. } = view; + let display_name = if prefix.is_empty() { + local_name.to_string() + } else { + format!("{}{}", prefix, accessor_name) + }; + + let (global_fn_ptr, _, _) = module_def + .view_by_name_with_global_fn_ptr(&display_name) + .ok_or_else(|| anyhow::anyhow!("view {} not found in module_def", display_name))?; + let st_view = tx - .view_from_name(view_name)? - .ok_or_else(|| anyhow::anyhow!("view {} not found in database", &view_name))?; + .view_from_name(&display_name)? + .ok_or_else(|| anyhow::anyhow!("view {} not found in database", display_name))?; let view_id = st_view.view_id; let table_id = st_view .table_id - .ok_or_else(|| anyhow::anyhow!("view {} does not have a backing table in database", &view_name))?; + .ok_or_else(|| anyhow::anyhow!("view {} does not have a backing table in database", display_name))?; let subs = tx.lookup_st_view_subs(view_id)?; + let view_name = Identifier::new_assume_valid(display_name.into()); + let view_typespace = owning_def.typespace().clone(); if *is_anonymous { if subs.is_empty() { @@ -1532,12 +1544,13 @@ fn collect_subscribed_view_calls( view_name: view_name.clone(), view_id, table_id, - fn_ptr: *fn_ptr, + fn_ptr: global_fn_ptr, caller: owner_identity, sender: None, args: ArgsTuple::nullary(), row_type: *product_type_ref, timestamp: Timestamp::now(), + view_typespace: view_typespace.clone(), }); continue; } @@ -1547,12 +1560,13 @@ fn collect_subscribed_view_calls( view_name: view_name.clone(), view_id, table_id, - fn_ptr: *fn_ptr, + fn_ptr: global_fn_ptr, caller: owner_identity, sender: Some(sub.identity.into()), args: ArgsTuple::nullary(), row_type: *product_type_ref, timestamp: Timestamp::now(), + view_typespace: view_typespace.clone(), }); } } diff --git a/crates/datastore/src/locking_tx_datastore/mut_tx.rs b/crates/datastore/src/locking_tx_datastore/mut_tx.rs index 9008bc6da4e..0dcdb6f0af7 100644 --- a/crates/datastore/src/locking_tx_datastore/mut_tx.rs +++ b/crates/datastore/src/locking_tx_datastore/mut_tx.rs @@ -588,6 +588,49 @@ impl MutTxId { Ok((view_id, table_id)) } + /// Like [`create_view`] but registers the view under `name_prefix + view_def.accessor_name` + /// (e.g. `"lib.library_view"`), using `owning_def` for type resolution. + /// + /// Used for mounted submodule views whose canonical names are dot-namespaced. + pub fn create_view_with_prefix( + &mut self, + owning_def: &ModuleDef, + view_def: &ViewDef, + name_prefix: &str, + ) -> Result<(ViewId, TableId)> { + let mut table_schema = TableSchema::from_view_def_for_datastore(owning_def, view_def); + let prefixed_name = format!("{}{}", name_prefix, &*view_def.accessor_name); + table_schema.table_name = TableName::new_raw(RawIdentifier::from(prefixed_name.clone())); + + // Clear alias so st_table_accessor doesn't get the bare (un-prefixed) accessor name, + // which would conflict when two mounts have views with the same local name. + // The namespaced canonical name is already the unique lookup key (same as tables). + table_schema.alias = None; + + // Prefix index and constraint names so they remain globally unique across mounts. + for index in &mut table_schema.indexes { + index.index_name = RawIdentifier::from(format!("{}{}", name_prefix, index.index_name)); + } + for constraint in &mut table_schema.constraints { + constraint.constraint_name = + RawIdentifier::from(format!("{}{}", name_prefix, constraint.constraint_name)); + } + + let table_id = self.create_table(table_schema)?; + + let view_name = RawIdentifier::from(prefixed_name); + let view_id = self + .view_id_from_name(&view_name)? + .ok_or(ViewError::NotFound(view_name))?; + + self.insert_into_st_view_param(view_id, &view_def.param_columns)?; + self.insert_into_st_view_column(view_id, &view_def.return_columns)?; + + self.committed_state_write_lock.ephemeral_tables.insert(table_id); + + Ok((view_id, table_id)) + } + /// Drop the backing table of a view and update the system tables. pub fn drop_view(&mut self, view_id: ViewId) -> Result<()> { let st_view_row = self.lookup_st_view(view_id)?;