From c8cac1da22430a94b83a086658e3b782d17ea468 Mon Sep 17 00:00:00 2001 From: Alessandro Asoni Date: Mon, 1 Jun 2026 15:40:28 +0200 Subject: [PATCH 1/2] Add sub-module mounts support + codegen --- .../internal/autogen/RawModuleDef.g.h | 2 +- .../autogen/RawModuleDefV10Section.g.h | 3 +- .../internal/autogen/RawModuleMountV10.g.h | 37 + .../internal/autogen/RawReducerDefV9.g.h | 2 +- .../Autogen/RawModuleDefV10Section.g.cs | 3 +- .../Internal/Autogen/RawModuleMountV10.g.cs | 36 + .../src/lib/autogen/types.ts | 69 +- crates/bindings-typescript/src/lib/schema.ts | 39 +- crates/codegen/src/cpp.rs | 58 ++ crates/codegen/src/typescript.rs | 254 ++++++- .../src/host/wasm_common/module_host_actor.rs | 2 +- crates/lib/src/db/raw_def/v10.rs | 19 + crates/schema/src/def.rs | 654 +++++++++++++++++- crates/schema/src/def/validate/v10.rs | 289 +++++++- crates/schema/src/def/validate/v9.rs | 1 + crates/schema/src/error.rs | 13 + crates/schema/src/table_name.rs | 20 +- 17 files changed, 1420 insertions(+), 81 deletions(-) create mode 100644 crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleMountV10.g.h create mode 100644 crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleMountV10.g.cs diff --git a/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleDef.g.h b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleDef.g.h index c7f144eb07d..294169acc3b 100644 --- a/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleDef.g.h +++ b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleDef.g.h @@ -12,9 +12,9 @@ #include #include "../autogen_base.h" #include "spacetimedb/bsatn/bsatn.h" +#include "RawModuleDefV10.g.h" #include "RawModuleDefV8.g.h" #include "RawModuleDefV9.g.h" -#include "RawModuleDefV10.g.h" namespace SpacetimeDB::Internal { diff --git a/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleDefV10Section.g.h b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleDefV10Section.g.h index 1efcad29ed5..e614d8b5225 100644 --- a/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleDefV10Section.g.h +++ b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleDefV10Section.g.h @@ -25,8 +25,9 @@ #include "RawTableDefV10.g.h" #include "RawRowLevelSecurityDefV9.g.h" #include "RawHttpRouteDefV10.g.h" +#include "RawModuleMountV10.g.h" namespace SpacetimeDB::Internal { -SPACETIMEDB_INTERNAL_TAGGED_ENUM(RawModuleDefV10Section, SpacetimeDB::Internal::Typespace, std::vector, std::vector, std::vector, std::vector, std::vector, std::vector, std::vector, std::vector, SpacetimeDB::Internal::CaseConversionPolicy, SpacetimeDB::Internal::ExplicitNames, std::vector, std::vector) +SPACETIMEDB_INTERNAL_TAGGED_ENUM(RawModuleDefV10Section, SpacetimeDB::Internal::Typespace, std::vector, std::vector, std::vector, std::vector, std::vector, std::vector, std::vector, std::vector, SpacetimeDB::Internal::CaseConversionPolicy, SpacetimeDB::Internal::ExplicitNames, std::vector, std::vector, std::vector) } // namespace SpacetimeDB::Internal diff --git a/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleMountV10.g.h b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleMountV10.g.h new file mode 100644 index 00000000000..26ac7c9a690 --- /dev/null +++ b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleMountV10.g.h @@ -0,0 +1,37 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +// This was generated using spacetimedb codegen. + +#pragma once + +#include +#include +#include +#include +#include +#include "../autogen_base.h" +#include "spacetimedb/bsatn/bsatn.h" + +// Forward declaration breaks the circular include chain: +// RawModuleMountV10 -> RawModuleDefV10 -> RawModuleDefV10Section -> RawModuleMountV10 +namespace SpacetimeDB::Internal { struct RawModuleDefV10; } + +namespace SpacetimeDB::Internal { + +SPACETIMEDB_INTERNAL_PRODUCT_TYPE(RawModuleMountV10) { + std::string namespace_; // renamed: 'namespace' is a C++ keyword + std::shared_ptr module; // shared_ptr breaks infinite-size recursion + + void bsatn_serialize(::SpacetimeDB::bsatn::Writer& writer) const { + ::SpacetimeDB::bsatn::serialize(writer, namespace_); + if (module) ::SpacetimeDB::bsatn::serialize(writer, *module); + } + bool operator==(const RawModuleMountV10& o) const noexcept { + if (namespace_ != o.namespace_) return false; + if (module && o.module) return *module == *o.module; + return !module && !o.module; + } + bool operator!=(const RawModuleMountV10& o) const noexcept { return !(*this == o); } +}; +} // namespace SpacetimeDB::Internal diff --git a/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawReducerDefV9.g.h b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawReducerDefV9.g.h index 8121773a40d..964ed98df12 100644 --- a/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawReducerDefV9.g.h +++ b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawReducerDefV9.g.h @@ -12,8 +12,8 @@ #include #include "../autogen_base.h" #include "spacetimedb/bsatn/bsatn.h" -#include "Lifecycle.g.h" #include "ProductType.g.h" +#include "Lifecycle.g.h" namespace SpacetimeDB::Internal { diff --git a/crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleDefV10Section.g.cs b/crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleDefV10Section.g.cs index 6706fb278f0..7cedeb59070 100644 --- a/crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleDefV10Section.g.cs +++ b/crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleDefV10Section.g.cs @@ -21,6 +21,7 @@ public partial record RawModuleDefV10Section : SpacetimeDB.TaggedEnum<( SpacetimeDB.CaseConversionPolicy CaseConversionPolicy, ExplicitNames ExplicitNames, System.Collections.Generic.List HttpHandlers, - System.Collections.Generic.List HttpRoutes + System.Collections.Generic.List HttpRoutes, + System.Collections.Generic.List Mounts )>; } diff --git a/crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleMountV10.g.cs b/crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleMountV10.g.cs new file mode 100644 index 00000000000..0df52895d65 --- /dev/null +++ b/crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleMountV10.g.cs @@ -0,0 +1,36 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace SpacetimeDB.Internal +{ + [SpacetimeDB.Type] + [DataContract] + public sealed partial class RawModuleMountV10 + { + [DataMember(Name = "namespace")] + public string Namespace; + [DataMember(Name = "module")] + public RawModuleDefV10 Module; + + public RawModuleMountV10( + string Namespace, + RawModuleDefV10 Module + ) + { + this.Namespace = Namespace; + this.Module = Module; + } + + public RawModuleMountV10() + { + this.Namespace = ""; + this.Module = new(); + } + } +} diff --git a/crates/bindings-typescript/src/lib/autogen/types.ts b/crates/bindings-typescript/src/lib/autogen/types.ts index c0855af29bf..e0a09e75ae7 100644 --- a/crates/bindings-typescript/src/lib/autogen/types.ts +++ b/crates/bindings-typescript/src/lib/autogen/types.ts @@ -339,56 +339,77 @@ export const RawModuleDef = __t.enum('RawModuleDef', { }); export type RawModuleDef = __Infer; -export const RawModuleDefV10 = __t.object('RawModuleDefV10', { - get sections() { +export type RawModuleDefV10 = { + sections: RawModuleDefV10Section[]; +}; + +export const RawModuleDefV10: any = __t.object('RawModuleDefV10', { + get sections(): any { return __t.array(RawModuleDefV10Section); }, }); -export type RawModuleDefV10 = __Infer; // The tagged union or sum type for the algebraic type `RawModuleDefV10Section`. -export const RawModuleDefV10Section = __t.enum('RawModuleDefV10Section', { - get Typespace() { +export type RawModuleDefV10Section = + | { tag: 'Typespace'; value: Typespace } + | { tag: 'Types'; value: RawTypeDefV10[] } + | { tag: 'Tables'; value: RawTableDefV10[] } + | { tag: 'Reducers'; value: RawReducerDefV10[] } + | { tag: 'Procedures'; value: RawProcedureDefV10[] } + | { tag: 'Views'; value: RawViewDefV10[] } + | { tag: 'Schedules'; value: RawScheduleDefV10[] } + | { tag: 'LifeCycleReducers'; value: RawLifeCycleReducerDefV10[] } + | { tag: 'RowLevelSecurity'; value: RawRowLevelSecurityDefV9[] } + | { tag: 'CaseConversionPolicy'; value: CaseConversionPolicy } + | { tag: 'ExplicitNames'; value: ExplicitNames } + | { tag: 'HttpHandlers'; value: RawHttpHandlerDefV10[] } + | { tag: 'HttpRoutes'; value: RawHttpRouteDefV10[] } + | { tag: 'Mounts'; value: RawModuleMountV10[] }; + +export const RawModuleDefV10Section: any = __t.enum('RawModuleDefV10Section', { + get Typespace(): any { return Typespace; }, - get Types() { + get Types(): any { return __t.array(RawTypeDefV10); }, - get Tables() { + get Tables(): any { return __t.array(RawTableDefV10); }, - get Reducers() { + get Reducers(): any { return __t.array(RawReducerDefV10); }, - get Procedures() { + get Procedures(): any { return __t.array(RawProcedureDefV10); }, - get Views() { + get Views(): any { return __t.array(RawViewDefV10); }, - get Schedules() { + get Schedules(): any { return __t.array(RawScheduleDefV10); }, - get LifeCycleReducers() { + get LifeCycleReducers(): any { return __t.array(RawLifeCycleReducerDefV10); }, - get RowLevelSecurity() { + get RowLevelSecurity(): any { return __t.array(RawRowLevelSecurityDefV9); }, - get CaseConversionPolicy() { + get CaseConversionPolicy(): any { return CaseConversionPolicy; }, - get ExplicitNames() { + get ExplicitNames(): any { return ExplicitNames; }, - get HttpHandlers() { + get HttpHandlers(): any { return __t.array(RawHttpHandlerDefV10); }, - get HttpRoutes() { + get HttpRoutes(): any { return __t.array(RawHttpRouteDefV10); }, + get Mounts(): any { + return __t.array(RawModuleMountV10); + }, }); -export type RawModuleDefV10Section = __Infer; export const RawModuleDefV8 = __t.object('RawModuleDefV8', { get typespace() { @@ -428,6 +449,18 @@ export const RawModuleDefV9 = __t.object('RawModuleDefV9', { }); export type RawModuleDefV9 = __Infer; +export type RawModuleMountV10 = { + namespace: string; + module: RawModuleDefV10; +}; + +export const RawModuleMountV10: any = __t.object('RawModuleMountV10', { + namespace: __t.string(), + get module(): any { + return RawModuleDefV10; + }, +}); + export const RawProcedureDefV10 = __t.object('RawProcedureDefV10', { sourceName: __t.string(), get params() { diff --git a/crates/bindings-typescript/src/lib/schema.ts b/crates/bindings-typescript/src/lib/schema.ts index ab480c93db5..f92baafd377 100644 --- a/crates/bindings-typescript/src/lib/schema.ts +++ b/crates/bindings-typescript/src/lib/schema.ts @@ -7,10 +7,22 @@ import { } from './algebraic_type'; import type { CaseConversionPolicy, + ExplicitNames, + RawHttpHandlerDefV10, + RawHttpRouteDefV10, + RawLifeCycleReducerDefV10, + RawModuleMountV10, RawModuleDefV10, RawModuleDefV10Section, + RawProcedureDefV10, + RawReducerDefV10, + RawRowLevelSecurityDefV9, + RawScheduleDefV10, RawScopedTypeNameV10, RawTableDefV10, + RawTypeDefV10, + RawViewDefV10, + Typespace, } from './autogen/types'; import type { UntypedIndex } from './indexes'; import type { UntypedTableDef } from './table'; @@ -42,6 +54,7 @@ export type TableNamesOf = Values< */ export type UntypedSchemaDef = { tables: Record; + namespaces?: Record; }; /** @@ -174,7 +187,20 @@ type CompoundTypeCache = Map< >; export type ModuleDef = { - [S in RawModuleDefV10Section as Uncapitalize]: S['value']; + typespace: Typespace; + types: RawTypeDefV10[]; + tables: RawTableDefV10[]; + reducers: RawReducerDefV10[]; + procedures: RawProcedureDefV10[]; + views: RawViewDefV10[]; + schedules: RawScheduleDefV10[]; + lifeCycleReducers: RawLifeCycleReducerDefV10[]; + httpHandlers: RawHttpHandlerDefV10[]; + httpRoutes: RawHttpRouteDefV10[]; + rowLevelSecurity: RawRowLevelSecurityDefV9[]; + caseConversionPolicy: CaseConversionPolicy; + explicitNames: ExplicitNames; + mounts: RawModuleMountV10[]; }; type Section = RawModuleDefV10Section; @@ -201,6 +227,7 @@ export class ModuleContext { explicitNames: { entries: [], }, + mounts: [], }; get moduleDef(): ModuleDef { @@ -259,9 +286,19 @@ export class ModuleContext { value: module.caseConversionPolicy, } ); + push( + module.mounts && { + tag: 'Mounts', + value: module.mounts, + } + ); return { sections }; } + addMount(mount: RawModuleMountV10) { + this.#moduleDef.mounts.push(mount); + } + /** * Set the case conversion policy for this module. * Called by the settings mechanism. diff --git a/crates/codegen/src/cpp.rs b/crates/codegen/src/cpp.rs index 9ddbeae8bb0..c7e07e5e3fc 100644 --- a/crates/codegen/src/cpp.rs +++ b/crates/codegen/src/cpp.rs @@ -231,6 +231,53 @@ impl<'opts> Cpp<'opts> { writeln!(output, "}};").unwrap(); } + fn generate_raw_module_mount_v10_special(&self) -> String { + // RawModuleMountV10 is special for two reasons: + // 1. Its `namespace` field is a C++ keyword, renamed to `namespace_`. + // 2. It contains `RawModuleDefV10` which creates a circular include chain: + // RawModuleMountV10 → RawModuleDefV10 → RawModuleDefV10Section → RawModuleMountV10 + // We break this with a forward declaration and shared_ptr (which only needs a declaration). + r#"// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +// This was generated using spacetimedb codegen. + +#pragma once + +#include +#include +#include +#include +#include +#include "../autogen_base.h" +#include "spacetimedb/bsatn/bsatn.h" + +// Forward declaration breaks the circular include chain: +// RawModuleMountV10 -> RawModuleDefV10 -> RawModuleDefV10Section -> RawModuleMountV10 +namespace SpacetimeDB::Internal { struct RawModuleDefV10; } + +namespace SpacetimeDB::Internal { + +SPACETIMEDB_INTERNAL_PRODUCT_TYPE(RawModuleMountV10) { + std::string namespace_; // renamed: 'namespace' is a C++ keyword + std::shared_ptr module; // shared_ptr breaks infinite-size recursion + + void bsatn_serialize(::SpacetimeDB::bsatn::Writer& writer) const { + ::SpacetimeDB::bsatn::serialize(writer, namespace_); + if (module) ::SpacetimeDB::bsatn::serialize(writer, *module); + } + bool operator==(const RawModuleMountV10& o) const noexcept { + if (namespace_ != o.namespace_) return false; + if (module && o.module) return *module == *o.module; + return !module && !o.module; + } + bool operator!=(const RawModuleMountV10& o) const noexcept { return !(*this == o); } +}; +} // namespace SpacetimeDB::Internal +"# + .to_string() + } + // Generate minimal sum type (TaggedEnum only) fn write_sum_type(&self, output: &mut String, module: &ModuleDef, type_name: &str, sum: &SumTypeDef) { // Special case: Generate proper tagged enum for RawIndexAlgorithm with data variants @@ -529,6 +576,17 @@ impl Lang for Cpp<'_> { }]; } + // Special handling for RawModuleMountV10: + // (1) its `namespace` field is a C++ keyword; (2) its `module` field creates a + // circular include chain through RawModuleDefV10 → RawModuleDefV10Section. + // We break both with a forward declaration and shared_ptr. + if name.to_string() == "RawModuleMountV10" { + return vec![OutputFile { + filename: format!("{name}.g.h"), + code: self.generate_raw_module_mount_v10_special(), + }]; + } + self.write_standard_includes(&mut output); // Add includes for dependencies diff --git a/crates/codegen/src/typescript.rs b/crates/codegen/src/typescript.rs index 28bc1fb4c91..114246cf443 100644 --- a/crates/codegen/src/typescript.rs +++ b/crates/codegen/src/typescript.rs @@ -475,6 +475,75 @@ fn generate_procedures_file(module: &ModuleDef, options: &CodegenOptions) -> Out } } +/// Collect all `AlgebraicTypeRef`s directly referenced by a type def. +fn direct_refs_of_def(def: &AlgebraicTypeDef) -> Vec { + fn refs_of_use(ty: &AlgebraicTypeUse, out: &mut Vec) { + match ty { + AlgebraicTypeUse::Ref(r) => out.push(*r), + AlgebraicTypeUse::Array(e) | AlgebraicTypeUse::Option(e) => refs_of_use(e, out), + AlgebraicTypeUse::Result { ok_ty, err_ty } => { + refs_of_use(ok_ty, out); + refs_of_use(err_ty, out); + } + _ => {} + } + } + let mut refs = Vec::new(); + match def { + AlgebraicTypeDef::Product(p) => { + for (_, ty) in &p.elements { + refs_of_use(ty, &mut refs); + } + } + AlgebraicTypeDef::Sum(s) => { + for (_, ty) in &s.variants { + refs_of_use(ty, &mut refs); + } + } + AlgebraicTypeDef::PlainEnum(_) => {} + } + refs +} + +/// BFS to find all type refs reachable from `start` in the type-dependency graph. +fn reachable_from( + typespace: &spacetimedb_schema::type_for_generate::TypespaceForGenerate, + start: AlgebraicTypeRef, +) -> BTreeSet { + let mut visited = BTreeSet::new(); + let mut stack = vec![start]; + while let Some(r) = stack.pop() { + if !visited.insert(r) { + continue; + } + if let Some(def) = typespace.get(r) { + for neighbor in direct_refs_of_def(def) { + stack.push(neighbor); + } + } + } + visited +} + +/// Get all strongly connected components within the provided ModuleDef types. +/// Used to compute circular dependencies within the provided ModuleDef. +fn algebraic_type_scc(module: &ModuleDef) -> BTreeSet { + let Some(at_ref) = iter_types(module) + .find(|ty| type_ref_name(module, ty.ty) == "AlgebraicType") + .map(|ty| ty.ty) + else { + return BTreeSet::new(); + }; + + let typespace = module.typespace_for_generate(); + let from_at = reachable_from(typespace, at_ref); + from_at + .iter() + .filter(|&&r| reachable_from(typespace, r).contains(&at_ref)) + .copied() + .collect() +} + fn generate_types_file(module: &ModuleDef) -> OutputFile { let mut output = CodeIndenter::new(String::new(), INDENT); let out = &mut output; @@ -486,6 +555,7 @@ fn generate_types_file(module: &ModuleDef) -> OutputFile { .reducers() .map(|reducer| reducer.accessor_name.deref().to_case(Case::Pascal)) .collect::>(); + let algebraic_scc = algebraic_type_scc(module); for ty in iter_types(module) { let type_name = collect_case(Case::Pascal, ty.accessor_name.name_segments()); @@ -493,9 +563,13 @@ fn generate_types_file(module: &ModuleDef) -> OutputFile { continue; } - match &module.typespace_for_generate()[ty.ty] { - AlgebraicTypeDef::Product(product) => define_body_for_product(module, out, &type_name, &product.elements), - AlgebraicTypeDef::Sum(sum) => define_body_for_sum(module, out, &type_name, &sum.variants), + let type_def = &module.typespace_for_generate()[ty.ty]; + let is_recursive = type_def.is_recursive() && !algebraic_scc.contains(&ty.ty); + match type_def { + AlgebraicTypeDef::Product(product) => { + define_body_for_product(module, out, &type_name, &product.elements, is_recursive) + } + AlgebraicTypeDef::Sum(sum) => define_body_for_sum(module, out, &type_name, &sum.variants, is_recursive), AlgebraicTypeDef::PlainEnum(plain_enum) => { let variants = plain_enum .variants @@ -503,7 +577,7 @@ fn generate_types_file(module: &ModuleDef) -> OutputFile { .cloned() .map(|var| (var, AlgebraicTypeUse::Unit)) .collect::>(); - define_body_for_sum(module, out, &type_name, &variants) + define_body_for_sum(module, out, &type_name, &variants, false) } } } @@ -514,6 +588,105 @@ fn generate_types_file(module: &ModuleDef) -> OutputFile { } } +/// Converts an `AlgebraicTypeUse` to a TypeScript type expression for use in explicit type aliases. +/// Used when generating `export type Foo = { ... }` for recursive types. +fn ts_type_use(module: &ModuleDef, ty: &AlgebraicTypeUse) -> String { + match ty { + AlgebraicTypeUse::String => "string".to_string(), + AlgebraicTypeUse::Primitive(PrimitiveType::Bool) => "boolean".to_string(), + AlgebraicTypeUse::Primitive( + PrimitiveType::I8 + | PrimitiveType::U8 + | PrimitiveType::I16 + | PrimitiveType::U16 + | PrimitiveType::I32 + | PrimitiveType::U32 + | PrimitiveType::F32 + | PrimitiveType::F64, + ) => "number".to_string(), + AlgebraicTypeUse::Primitive(_) => "bigint".to_string(), + AlgebraicTypeUse::Array(elem) if matches!(&**elem, AlgebraicTypeUse::Primitive(PrimitiveType::U8)) => { + "Uint8Array".to_string() + } + AlgebraicTypeUse::Array(elem) => format!("{}[]", ts_type_use(module, elem)), + AlgebraicTypeUse::Option(inner) => format!("{} | undefined", ts_type_use(module, inner)), + AlgebraicTypeUse::Ref(r) => type_ref_name(module, *r), + AlgebraicTypeUse::Unit => "Record".to_string(), + AlgebraicTypeUse::Never => "never".to_string(), + AlgebraicTypeUse::Identity => "__Identity".to_string(), + AlgebraicTypeUse::ConnectionId => "__ConnectionId".to_string(), + AlgebraicTypeUse::Timestamp => "__Timestamp".to_string(), + AlgebraicTypeUse::TimeDuration => "__TimeDuration".to_string(), + AlgebraicTypeUse::ScheduleAt => "__ScheduleAt".to_string(), + AlgebraicTypeUse::Uuid => "__Uuid".to_string(), + AlgebraicTypeUse::Result { ok_ty, err_ty } => { + format!( + "{{ ok: {} }} | {{ err: {} }}", + ts_type_use(module, ok_ty), + ts_type_use(module, err_ty) + ) + } + } +} + +/// Emits an explicit TypeScript type alias for a product type, used for recursive types. +/// e.g. `export type Foo = { bar: Bar; };` +fn write_ts_type_alias_for_product( + module: &ModuleDef, + out: &mut Indenter, + name: &str, + elements: &[(Identifier, AlgebraicTypeUse)], +) { + writeln!(out, "export type {name} = {{"); + out.indent(1); + for (ident, ty) in elements { + let field_name = ident.deref().to_case(Case::Camel); + writeln!(out, "{field_name}: {};", ts_type_use(module, ty)); + } + out.dedent(1); + writeln!(out, "}};"); + out.newline(); +} + +/// Emits an explicit TypeScript type alias for a sum type, used for recursive types. +/// e.g. `export type Foo = | { tag: 'Bar'; value: Bar } | { tag: 'Baz'; value: Baz };` +fn write_ts_type_alias_for_sum( + module: &ModuleDef, + out: &mut Indenter, + name: &str, + variants: &[(Identifier, AlgebraicTypeUse)], +) { + writeln!(out, "export type {name} ="); + out.indent(1); + for (i, (ident, ty)) in variants.iter().enumerate() { + let variant_name = ident.deref().to_case(Case::Pascal); + let is_last = i == variants.len() - 1; + let semicolon = if is_last { ";" } else { "" }; + writeln!( + out, + "| {{ tag: '{variant_name}'; value: {} }}{semicolon}", + ts_type_use(module, ty) + ); + } + out.dedent(1); + out.newline(); +} + +/// Emits a getter field with an explicit `: any` return-type annotation. +/// +/// Required for getters inside `const X: any = __t.object/enum(…)` declarations of +/// recursive types: without the annotation TypeScript emits TS7023 ("implicitly has +/// return type 'any' because it is referenced in its own return expressions"). +fn write_any_getter_field(module: &ModuleDef, out: &mut Indenter, name: &str, ty: &AlgebraicTypeUse) { + writeln!(out, "get {name}(): any {{"); + out.indent(1); + write!(out, "return "); + write_type_builder(module, out, ty).unwrap(); + writeln!(out, ";"); + out.dedent(1); + writeln!(out, "}},"); +} + fn print_index_imports(out: &mut Indenter) { // All library imports are prefixed with `__` to avoid // clashing with the names of user generated types. @@ -624,16 +797,38 @@ fn define_body_for_product( out: &mut Indenter, name: &str, elements: &[(Identifier, AlgebraicTypeUse)], + is_recursive: bool, ) { - write!(out, "export const {name} = __t.object(\"{name}\", {{"); - if elements.is_empty() { + if is_recursive { + // Emit an explicit TS type alias to break the circular type-inference chain + write_ts_type_alias_for_product(module, out, name, elements); + writeln!(out, "export const {name}: any = __t.object(\"{name}\", {{"); + if !elements.is_empty() { + out.with_indent(|out| { + for (ident, ty) in elements { + let field_name = ident.deref().to_case(Case::Camel); + if type_contains_ref(ty) { + write_any_getter_field(module, out, &field_name, ty); + } else { + write!(out, "{field_name}: "); + write_type_builder(module, out, ty).unwrap(); + writeln!(out, ","); + } + } + }); + } writeln!(out, "}});"); } else { - writeln!(out); - out.with_indent(|out| write_object_type_builder_fields(module, out, elements, None, true, false).unwrap()); - writeln!(out, "}});"); + write!(out, "export const {name} = __t.object(\"{name}\", {{"); + if elements.is_empty() { + writeln!(out, "}});"); + } else { + writeln!(out); + out.with_indent(|out| write_object_type_builder_fields(module, out, elements, None, true, false).unwrap()); + writeln!(out, "}});"); + } + writeln!(out, "export type {name} = __Infer;"); } - writeln!(out, "export type {name} = __Infer;"); out.newline(); } @@ -858,13 +1053,8 @@ fn define_body_for_sum( out: &mut Indenter, name: &str, variants: &[(Identifier, AlgebraicTypeUse)], + is_recursive: bool, ) { - writeln!(out, "// The tagged union or sum type for the algebraic type `{name}`."); - write!(out, "export const {name}"); - if name == "AlgebraicType" { - write!(out, ": __TypeBuilder<__AlgebraicTypeType, __AlgebraicTypeType>"); - } - writeln!(out, " = __t.enum(\"{name}\", {{"); // Convert variant names to PascalCase let pascal_variants: Vec<(Identifier, AlgebraicTypeUse)> = variants .iter() @@ -873,9 +1063,35 @@ fn define_body_for_sum( (Identifier::for_test(pascal), ty.clone()) }) .collect(); - out.with_indent(|out| write_object_type_builder_fields(module, out, &pascal_variants, None, false, false).unwrap()); - writeln!(out, "}});"); - writeln!(out, "export type {name} = __Infer;"); + + writeln!(out, "// The tagged union or sum type for the algebraic type `{name}`."); + if is_recursive { + write_ts_type_alias_for_sum(module, out, name, variants); + writeln!(out, "export const {name}: any = __t.enum(\"{name}\", {{"); + out.with_indent(|out| { + for (ident, ty) in &pascal_variants { + if type_contains_ref(ty) { + write_any_getter_field(module, out, ident.deref(), ty); + } else { + write!(out, "{}: ", ident.deref()); + write_type_builder(module, out, ty).unwrap(); + writeln!(out, ","); + } + } + }); + writeln!(out, "}});"); + } else { + write!(out, "export const {name}"); + if name == "AlgebraicType" { + write!(out, ": __TypeBuilder<__AlgebraicTypeType, __AlgebraicTypeType>"); + } + writeln!(out, " = __t.enum(\"{name}\", {{"); + out.with_indent(|out| { + write_object_type_builder_fields(module, out, &pascal_variants, None, false, false).unwrap() + }); + writeln!(out, "}});"); + writeln!(out, "export type {name} = __Infer;"); + } out.newline(); } 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 2fb5eab0492..e3000fbdddc 100644 --- a/crates/core/src/host/wasm_common/module_host_actor.rs +++ b/crates/core/src/host/wasm_common/module_host_actor.rs @@ -1579,7 +1579,7 @@ impl AllVmMetrics { let def = &info.module_def; let reducers = def.reducer_ids_and_defs(); let num_reducers = reducers.len() as u32; - let reducers = reducers.map(|(_, def)| def.name()); + let reducers = reducers.into_iter().map(|(_, def)| def.name()); // These are the views: let views = def.views().map(|def| def.name()); diff --git a/crates/lib/src/db/raw_def/v10.rs b/crates/lib/src/db/raw_def/v10.rs index 47a4281e86a..969580cb7f0 100644 --- a/crates/lib/src/db/raw_def/v10.rs +++ b/crates/lib/src/db/raw_def/v10.rs @@ -95,6 +95,9 @@ pub enum RawModuleDefV10Section { /// HTTP route definitions. HttpRoutes(Vec), + + /// Mounted submodules, keyed by the namespace they are mounted under. + Mounts(Vec), } #[derive(Debug, Clone, SpacetimeType)] @@ -121,6 +124,14 @@ pub enum MethodOrAny { Method(crate::http::Method), } +#[derive(Debug, Clone, SpacetimeType)] +#[sats(crate = crate)] +#[cfg_attr(feature = "test", derive(PartialEq, Eq, PartialOrd, Ord))] +pub struct RawModuleMountV10 { + pub namespace: String, + pub module: RawModuleDefV10, +} + #[derive(Debug, Clone, Copy, Default, SpacetimeType)] #[cfg_attr(feature = "test", derive(PartialEq, Eq, PartialOrd, Ord))] #[sats(crate = crate)] @@ -540,6 +551,14 @@ pub struct RawViewDefV10 { } impl RawModuleDefV10 { + /// Get the mounted submodules for this module definition. + pub fn mounts(&self) -> Option<&Vec> { + self.sections.iter().find_map(|s| match s { + RawModuleDefV10Section::Mounts(mounts) => Some(mounts), + _ => None, + }) + } + /// Get the types section, if present. pub fn types(&self) -> Option<&Vec> { self.sections.iter().find_map(|s| match s { diff --git a/crates/schema/src/def.rs b/crates/schema/src/def.rs index baae44ed76f..6c306b7379d 100644 --- a/crates/schema/src/def.rs +++ b/crates/schema/src/def.rs @@ -33,9 +33,9 @@ use spacetimedb_data_structures::map::{Equivalent, HashMap}; use spacetimedb_lib::db::raw_def; use spacetimedb_lib::db::raw_def::v10::{ ExplicitNames, MethodOrAny, RawConstraintDefV10, RawHttpHandlerDefV10, RawHttpRouteDefV10, RawIndexDefV10, - RawLifeCycleReducerDefV10, RawModuleDefV10, RawModuleDefV10Section, RawProcedureDefV10, RawReducerDefV10, - RawRowLevelSecurityDefV10, RawScheduleDefV10, RawScopedTypeNameV10, RawSequenceDefV10, RawTableDefV10, - RawTypeDefV10, RawViewDefV10, + RawLifeCycleReducerDefV10, RawModuleDefV10, RawModuleDefV10Section, RawModuleMountV10, RawProcedureDefV10, + RawReducerDefV10, RawRowLevelSecurityDefV10, RawScheduleDefV10, RawScopedTypeNameV10, RawSequenceDefV10, + RawTableDefV10, RawTypeDefV10, RawViewDefV10, }; use spacetimedb_lib::db::raw_def::v9::{ Lifecycle, RawColumnDefaultValueV9, RawConstraintDataV9, RawConstraintDefV9, RawIndexAlgorithm, RawIndexDefV9, @@ -43,12 +43,15 @@ use spacetimedb_lib::db::raw_def::v9::{ RawScheduleDefV9, RawScopedTypeNameV9, RawSequenceDefV9, RawSql, RawTableDefV9, RawTypeDefV9, RawUniqueConstraintDataV9, RawViewDefV9, TableAccess, TableType, }; -use spacetimedb_lib::{ProductType, RawModuleDef}; +use spacetimedb_lib::RawModuleDef; use spacetimedb_primitives::{ ColId, ColList, ColOrCols, ColSet, HttpHandlerId, ProcedureId, ReducerId, TableId, ViewFnPtr, }; use spacetimedb_sats::raw_identifier::RawIdentifier; -use spacetimedb_sats::{AlgebraicType, AlgebraicTypeRef, AlgebraicValue, Typespace}; +use spacetimedb_sats::{ + AlgebraicType, AlgebraicTypeRef, AlgebraicValue, ArrayType, ProductType, ProductTypeElement, SumType, + SumTypeVariant, Typespace, +}; pub mod deserialize; pub mod error; @@ -163,6 +166,9 @@ pub struct ModuleDef { /// was authored under. #[allow(unused)] raw_module_def_version: RawModuleDefVersion, + + /// Mounted submodules, keyed by the namespace they are mounted under. + mounts: IndexMap, } #[derive(Debug, Clone, Copy, Eq, PartialEq)] @@ -179,6 +185,11 @@ impl ModuleDef { self.raw_module_def_version } + /// The mounted submodules of the module definition. + pub fn mounts(&self) -> &IndexMap { + &self.mounts + } + /// The tables of the module definition. pub fn tables(&self) -> impl Iterator { self.tables.values() @@ -204,14 +215,165 @@ impl ModuleDef { self.tables().filter_map(|table| table.schedule.as_ref()) } + /// All tables across this module and all mounted submodules, in depth-first order. + /// + /// Each item is `(namespace, owning_def, table_def)` where `namespace` is the dot-terminated + /// namespace string (e.g., `"alias."`) to be prepended to the table's name for database storage. + /// The consumer module's own tables yield namespace `""`. + pub fn all_tables_with_prefix(&self) -> Vec<(String, &ModuleDef, &TableDef)> { + let mut out = Vec::new(); + self.collect_tables_with_prefix("", &mut out); + out + } + + fn collect_tables_with_prefix<'a>(&'a self, prefix: &str, out: &mut Vec<(String, &'a ModuleDef, &'a TableDef)>) { + for table in self.tables.values() { + out.push((prefix.to_string(), self, table)); + } + for (ns, mount) in &self.mounts { + mount.collect_tables_with_prefix(&format!("{prefix}{ns}."), out); + } + } + + /// All views across this module and all mounted submodules, in depth-first order. + /// + /// Each item is `(namespace, owning_def, view_def)` where `namespace` is the dot-terminated + /// namespace string (e.g., `"alias."`) to be prepended to the view's name. + /// The consumer module's own views yield namespace `""`. + pub fn all_views_with_prefix(&self) -> Vec<(String, &ModuleDef, &ViewDef)> { + let mut out = Vec::new(); + self.collect_views_with_prefix("", &mut out); + out + } + + fn collect_views_with_prefix<'a>(&'a self, prefix: &str, out: &mut Vec<(String, &'a ModuleDef, &'a ViewDef)>) { + for view in self.views.values() { + out.push((prefix.to_string(), self, view)); + } + for (ns, mount) in &self.mounts { + mount.collect_views_with_prefix(&format!("{prefix}{ns}."), out); + } + } + + /// Look up a table by its full namespaced name (e.g., `"lib.library_table"` or `"user"`). + pub fn find_table_by_full_name(&self, full_name: &str) -> Option<(String, &ModuleDef, &TableDef)> { + self.all_tables_with_prefix() + .into_iter() + .find(|(prefix, _, table_def)| format!("{}{}", prefix, &*table_def.accessor_name) == full_name) + } + + /// Look up a view by its full namespaced name (e.g., `"lib.library_view"` or `"my_view"`). + pub fn find_view_by_full_name(&self, full_name: &str) -> Option<(String, &ModuleDef, &ViewDef)> { + self.all_views_with_prefix() + .into_iter() + .find(|(prefix, _, view_def)| format!("{}{}", prefix, &*view_def.name) == full_name) + } + + /// Look up an index by its full namespaced name (e.g., `"lib.library_table_id_idx_btree"`). + pub fn find_index_by_full_name(&self, full_name: &str) -> Option<(String, &ModuleDef, &TableDef, &IndexDef)> { + for (prefix, owning, table) in self.all_tables_with_prefix() { + for idx in table.indexes.values() { + if format!("{}{}", prefix, &*idx.name) == full_name { + return Some((prefix, owning, table, idx)); + } + } + } + None + } + + /// Look up a sequence by its full namespaced name (e.g., `"lib.library_table_id_seq"`). + pub fn find_sequence_by_full_name(&self, full_name: &str) -> Option<(String, &ModuleDef, &TableDef, &SequenceDef)> { + for (prefix, owning, table) in self.all_tables_with_prefix() { + for seq in table.sequences.values() { + if format!("{}{}", prefix, &*seq.name) == full_name { + return Some((prefix, owning, table, seq)); + } + } + } + None + } + + /// Look up a constraint by its full namespaced name (e.g., `"lib.library_table_id_unique"`). + pub fn find_constraint_by_full_name(&self, full_name: &str) -> Option<(String, &ModuleDef, &TableDef, &ConstraintDef)> { + for (prefix, owning, table) in self.all_tables_with_prefix() { + for constraint in table.constraints.values() { + if format!("{}{}", prefix, &*constraint.name) == full_name { + return Some((prefix, owning, table, constraint)); + } + } + } + None + } + /// The reducers of the module definition. pub fn reducers(&self) -> impl Iterator { self.reducers.values() } - /// Returns an iterator over all reducer ids and definitions. - pub fn reducer_ids_and_defs(&self) -> impl ExactSizeIterator { - self.reducers.values().enumerate().map(|(idx, def)| (idx.into(), def)) + /// Returns all reducer ids and definitions in depth-first mount order. + /// + /// IDs are assigned as follows: consumer's own reducers first (0..N), then each + /// mounted submodule's reducers in the order they appear in `mounts`, recursively. + pub fn reducer_ids_and_defs(&self) -> Vec<(ReducerId, &ReducerDef)> { + let mut out = Vec::with_capacity(self.reducer_count()); + self.collect_reducers(0, &mut out); + out + } + + /// Total reducer count including all mounted submodules (depth-first sum). + pub fn reducer_count(&self) -> usize { + self.reducers.len() + self.mounts.values().map(|m| m.reducer_count()).sum::() + } + + fn collect_reducers<'a>(&'a self, offset: usize, out: &mut Vec<(ReducerId, &'a ReducerDef)>) { + for (i, def) in self.reducers.values().enumerate() { + out.push(((offset + i).into(), def)); + } + let mut child_offset = offset + self.reducers.len(); + for mount in self.mounts.values() { + mount.collect_reducers(child_offset, out); + child_offset += mount.reducer_count(); + } + } + + /// All reducers across this module and all mounted submodules, in depth-first order. + /// + /// Each item is `(prefix, owning_def, reducer_def)` where `prefix` is the slash-terminated + /// namespace string (e.g., `"lib/"`) to be prepended to the reducer's name as its wire name. + /// The consumer module's own reducers yield prefix `""`. + pub fn all_reducers_with_prefix(&self) -> Vec<(String, &ModuleDef, &ReducerDef)> { + let mut out = Vec::new(); + self.collect_reducers_with_prefix("", &mut out); + out + } + + fn collect_reducers_with_prefix<'a>(&'a self, prefix: &str, out: &mut Vec<(String, &'a ModuleDef, &'a ReducerDef)>) { + for reducer in self.reducers.values() { + out.push((prefix.to_string(), self, reducer)); + } + for (ns, mount) in &self.mounts { + mount.collect_reducers_with_prefix(&format!("{prefix}{ns}/"), out); + } + } + + /// All procedures across this module and all mounted submodules, in depth-first order. + /// + /// Each item is `(prefix, owning_def, procedure_def)` where `prefix` is the slash-terminated + /// namespace string (e.g., `"lib/"`) to be prepended to the procedure's name as its wire name. + /// The consumer module's own procedures yield prefix `""`. + pub fn all_procedures_with_prefix(&self) -> Vec<(String, &ModuleDef, &ProcedureDef)> { + let mut out = Vec::new(); + self.collect_procedures_with_prefix("", &mut out); + out + } + + fn collect_procedures_with_prefix<'a>(&'a self, prefix: &str, out: &mut Vec<(String, &'a ModuleDef, &'a ProcedureDef)>) { + for procedure in self.procedures.values() { + out.push((prefix.to_string(), self, procedure)); + } + for (ns, mount) in &self.mounts { + mount.collect_procedures_with_prefix(&format!("{prefix}{ns}/"), out); + } } /// The procedures of the module definition. @@ -363,14 +525,62 @@ impl ModuleDef { self.reducers.get_full(name).map(|(idx, _, def)| (idx.into(), def)) } - /// Look up a reducer by its id. + /// Look up a reducer by its wire name, resolving qualified names like `"myauth/verify_token"`. + /// + /// A plain name searches the consumer's own reducers. A slash-qualified name routes to + /// the matching mount and recurses. Nesting is supported: `"auth/baz/cleanup"`. + /// Returns the depth-first `ReducerId` and the `ReducerDef`. + pub fn reducer_by_name(&self, name: &str) -> Option<(ReducerId, &ReducerDef)> { + self.reducer_by_name_with_module(name).map(|(id, def, _)| (id, def)) + } + + /// Like `reducer_by_name` but also returns the `ModuleDef` that owns the reducer. + /// Use the returned `ModuleDef` (not `self`) when calling `arg_seed_for`, so that + /// type-index references in the `ReducerDef` are resolved against the correct typespace. + pub fn reducer_by_name_with_module<'a>( + &'a self, + name: &str, + ) -> Option<(ReducerId, &'a ReducerDef, &'a ModuleDef)> { + match name.split_once('/') { + None => self + .reducers + .get_full(name) + .map(|(idx, _, def)| (idx.into(), def, self)), + Some((namespace, rest)) => { + let mut offset = self.reducers.len(); + for (ns, mount) in &self.mounts { + if ns == namespace { + let (inner_id, def, owning) = mount.reducer_by_name_with_module(rest)?; + return Some(((offset + inner_id.idx()).into(), def, owning)); + } + offset += mount.reducer_count(); + } + None + } + } + } + + /// Look up a reducer by its depth-first id. pub fn reducer_by_id(&self, id: ReducerId) -> &ReducerDef { - &self.reducers[id.idx()] + self.get_reducer_by_id(id) + .unwrap_or_else(|| panic!("reducer id {id:?} out of range")) } - /// Look up a reducer by its id. + /// Look up a reducer by its depth-first id, returning `None` if it doesn't exist. pub fn get_reducer_by_id(&self, id: ReducerId) -> Option<&ReducerDef> { - self.reducers.get_index(id.idx()).map(|(_, def)| def) + let idx = id.idx(); + if idx < self.reducers.len() { + return self.reducers.get_index(idx).map(|(_, def)| def); + } + let mut offset = self.reducers.len(); + for mount in self.mounts.values() { + let count = mount.reducer_count(); + if idx < offset + count { + return mount.get_reducer_by_id(ReducerId::from(idx - offset)); + } + offset += count; + } + None } /// Look up a view by its id, and whether it is anonymous. @@ -381,6 +591,130 @@ impl ModuleDef { .map(|(_, def)| def) } + /// Look up a view by its globally-unique fn_ptr (the offset-adjusted id used by the WASM dispatch layer). + /// Returns the `ViewDef` and the owning `ModuleDef`. + pub fn get_view_by_global_id_with_module( + &self, + global_id: ViewFnPtr, + is_anonymous: bool, + ) -> Option<(&ViewDef, &ModuleDef)> { + self.get_view_by_global_id_inner(global_id.0, is_anonymous, 0, 0) + } + + fn get_view_by_global_id_inner( + &self, + global_id: u32, + is_anonymous: bool, + anon_offset: u32, + non_anon_offset: u32, + ) -> Option<(&ViewDef, &ModuleDef)> { + let local_count = if is_anonymous { + self.anon_view_count() as u32 + } else { + self.non_anon_view_count() as u32 + }; + let offset = if is_anonymous { anon_offset } else { non_anon_offset }; + if global_id < offset + local_count { + return self + .views + .values() + .find(|def| def.fn_ptr.0 + offset == global_id && def.is_anonymous == is_anonymous) + .map(|def| (def, self)); + } + let mut anon_off = anon_offset + self.anon_view_count() as u32; + let mut non_anon_off = non_anon_offset + self.non_anon_view_count() as u32; + for mount in self.mounts.values() { + let mount_anon = mount.total_anon_view_count() as u32; + let mount_non_anon = mount.total_non_anon_view_count() as u32; + let mount_count = if is_anonymous { mount_anon } else { mount_non_anon }; + let mount_off = if is_anonymous { anon_off } else { non_anon_off }; + if global_id < mount_off + mount_count { + return mount.get_view_by_global_id_inner(global_id, is_anonymous, anon_off, non_anon_off); + } + anon_off += mount_anon; + non_anon_off += mount_non_anon; + } + None + } + + /// Look up a view by its wire name, resolving dot-qualified names like `"lib.library_view"`. + /// + /// A plain name searches this module's own views. A dot-qualified name routes to + /// the matching mount and recurses. Returns the `ViewDef` and the owning `ModuleDef`. + pub fn view_by_name_with_module<'a>( + &'a self, + name: &str, + ) -> Option<(&'a ViewDef, &'a ModuleDef)> { + match name.split_once('.') { + None => self.views.get(name).map(|def| (def, self)), + Some((namespace, rest)) => { + let mount = self.mounts.get(namespace)?; + mount.view_by_name_with_module(rest) + } + } + } + + /// Like [`view_by_name_with_module`] but also returns the globally-unique `ViewFnPtr` + /// that the WASM dispatch layer expects (offset by all anon/non-anon views that precede + /// this one in depth-first module order). + pub fn view_by_name_with_global_fn_ptr<'a>( + &'a self, + name: &str, + ) -> Option<(ViewFnPtr, &'a ViewDef, &'a ModuleDef)> { + let anon_offset = 0u32; + let non_anon_offset = 0u32; + self.view_by_name_with_global_fn_ptr_inner(name, anon_offset, non_anon_offset) + } + + fn view_by_name_with_global_fn_ptr_inner<'a>( + &'a self, + name: &str, + anon_offset: u32, + non_anon_offset: u32, + ) -> Option<(ViewFnPtr, &'a ViewDef, &'a ModuleDef)> { + match name.split_once('.') { + None => { + let def = self.views.get(name)?; + let offset = if def.is_anonymous { anon_offset } else { non_anon_offset }; + Some((ViewFnPtr(def.fn_ptr.0 + offset), def, self)) + } + Some((namespace, rest)) => { + let mut anon_off = anon_offset + self.anon_view_count() as u32; + let mut non_anon_off = non_anon_offset + self.non_anon_view_count() as u32; + for (ns, mount) in &self.mounts { + if ns == namespace { + return mount.view_by_name_with_global_fn_ptr_inner(rest, anon_off, non_anon_off); + } + anon_off += mount.total_anon_view_count() as u32; + non_anon_off += mount.total_non_anon_view_count() as u32; + } + None + } + } + } + + /// Count of anonymous views in this module (not including mounts). + pub fn anon_view_count(&self) -> usize { + self.views.values().filter(|v| v.is_anonymous).count() + } + + /// Count of non-anonymous views in this module (not including mounts). + pub fn non_anon_view_count(&self) -> usize { + self.views.values().filter(|v| !v.is_anonymous).count() + } + + /// Total anonymous view count including all mounted submodules (depth-first sum). + pub fn total_anon_view_count(&self) -> usize { + self.anon_view_count() + + self.mounts.values().map(|m| m.total_anon_view_count()).sum::() + } + + /// Total non-anonymous view count including all mounted submodules (depth-first sum). + pub fn total_non_anon_view_count(&self) -> usize { + self.non_anon_view_count() + + self.mounts.values().map(|m| m.total_non_anon_view_count()).sum::() + } + /// Convenience method to look up a procedure, possibly by a string. pub fn procedure>(&self, name: &K) -> Option<&ProcedureDef> { // If the string IS a valid identifier, we can just look it up. @@ -398,12 +732,63 @@ impl ModuleDef { /// Look up a procuedure by its id, panicking if it doesn't exist. pub fn procedure_by_id(&self, id: ProcedureId) -> &ProcedureDef { - &self.procedures[id.idx()] + self.get_procedure_by_id(id) + .unwrap_or_else(|| panic!("procedure id {id:?} out of range")) } /// Look up a procuedure by its id, returning `None` if it doesn't exist. pub fn get_procedure_by_id(&self, id: ProcedureId) -> Option<&ProcedureDef> { - self.procedures.get_index(id.idx()).map(|(_, def)| def) + let idx = id.idx(); + if idx < self.procedures.len() { + return self.procedures.get_index(idx).map(|(_, def)| def); + } + let mut offset = self.procedures.len(); + for mount in self.mounts.values() { + let count = mount.procedure_count(); + if idx < offset + count { + return mount.get_procedure_by_id(ProcedureId::from(idx - offset)); + } + offset += count; + } + None + } + + /// Total procedure count including all mounted submodules (depth-first sum). + pub fn procedure_count(&self) -> usize { + self.procedures.len() + self.mounts.values().map(|m| m.procedure_count()).sum::() + } + + /// Look up a procedure by its wire name, resolving qualified names like `"mylib/proc_name"`. + /// + /// A plain name searches the module's own procedures. A slash-qualified name routes to + /// the matching mount and recurses. Returns the depth-first `ProcedureId` and the `ProcedureDef`. + pub fn procedure_by_name(&self, name: &str) -> Option<(ProcedureId, &ProcedureDef)> { + self.procedure_by_name_with_module(name).map(|(id, def, _)| (id, def)) + } + + /// Like `procedure_by_name` but also returns the `ModuleDef` that owns the procedure. + /// Use the returned `ModuleDef` (not `self`) when calling `arg_seed_for`. + pub fn procedure_by_name_with_module<'a>( + &'a self, + name: &str, + ) -> Option<(ProcedureId, &'a ProcedureDef, &'a ModuleDef)> { + match name.split_once('/') { + None => self + .procedures + .get_full(name) + .map(|(idx, _, def)| (idx.into(), def, self)), + Some((namespace, rest)) => { + let mut offset = self.procedures.len(); + for (ns, mount) in &self.mounts { + if ns == namespace { + let (inner_id, def, owning) = mount.procedure_by_name_with_module(rest)?; + return Some(((offset + inner_id.idx()).into(), def, owning)); + } + offset += mount.procedure_count(); + } + None + } + } } /// Looks up a lifecycle reducer defined in the module. @@ -411,6 +796,11 @@ impl ModuleDef { self.lifecycle_reducers[lifecycle].map(|i| (i, &self.reducers[i.idx()])) } + /// All lifecycle reducer assignments for this module (does not include mounted submodules). + pub fn lifecycle_reducers_map(&self) -> &EnumMap> { + &self.lifecycle_reducers + } + /// Returns a `DeserializeSeed` that can pull data from a `Deserializer` for `def`. pub fn arg_seed_for<'a, T>(&'a self, def: &'a T) -> ArgsSeed<'a, T> { ArgsSeed(self.typespace.with_type(def)) @@ -506,6 +896,7 @@ impl From for RawModuleDefV9 { http_handlers: _, http_routes: _, raw_module_def_version: _, + mounts, } = val; // Extract column defaults from tables before consuming tables @@ -524,21 +915,154 @@ impl From for RawModuleDefV9 { }) .collect(); + // Flatten mounted modules into the root table/reducer/procedure lists and typespace. + // Each mount's typespace is appended to the merged types with all AlgebraicTypeRef + // indices shifted by the current length, keeping internal references valid. + let root_anon_view_count = views.values().filter(|v| v.is_anonymous).count() as u32; + let root_non_anon_view_count = views.values().filter(|v| !v.is_anonymous).count() as u32; + let mut flat_tables: Vec = to_raw(tables); + let mut flat_reducers: Vec = reducers.into_iter().map(|(_, def)| def.into()).collect(); + let mut flat_misc: Vec = column_defaults + .into_iter() + .chain(procedures.into_iter().map(|(_, def)| def.into())) + .chain(views.into_iter().map(|(_, def)| def.into())) + .collect(); + let mut merged_types = typespace.types; + let mut anon_view_offset = root_anon_view_count; + let mut view_offset = root_non_anon_view_count; + collect_v9_mounts( + &mounts, + "", + &mut flat_tables, + &mut flat_reducers, + &mut flat_misc, + &mut merged_types, + &mut anon_view_offset, + &mut view_offset, + ); + RawModuleDefV9 { - tables: to_raw(tables), - reducers: reducers.into_iter().map(|(_, def)| def.into()).collect(), + tables: flat_tables, + reducers: flat_reducers, types: to_raw(types), - misc_exports: column_defaults - .into_iter() - .chain(procedures.into_iter().map(|(_, def)| def.into())) - .chain(views.into_iter().map(|(_, def)| def.into())) - .collect(), - typespace, + misc_exports: flat_misc, + typespace: Typespace::new(merged_types), row_level_security: row_level_security_raw.into_iter().map(|(_, def)| def).collect(), } } } +/// Recursively flatten mounted `ModuleDef`s into `flat_tables`, `flat_reducers`, +/// `flat_misc`, and `merged_types`. +/// +/// Tables are emitted with dot-namespaced names (e.g. `"lib.library_table"`). +/// Reducers, procedures, and views are emitted with slash/dot-namespaced names. +/// View fn_ptrs are shifted by `anon_view_offset` / `view_offset` so that the +/// TypeScript WASM runtime's flat dispatch arrays remain consistent. +/// Each mount's typespace types are appended with all `AlgebraicTypeRef` indices shifted +/// by the current length of `merged_types`, keeping internal references valid. +fn collect_v9_mounts( + mounts: &IndexMap, + parent_prefix: &str, + flat_tables: &mut Vec, + flat_reducers: &mut Vec, + flat_misc: &mut Vec, + merged_types: &mut Vec, + anon_view_offset: &mut u32, + view_offset: &mut u32, +) { + for (ns, mount) in mounts { + let prefix = format!("{parent_prefix}{ns}."); + let fn_prefix = prefix.replace('.', "/"); + let type_offset = merged_types.len() as u32; + for ty in &mount.typespace().types { + merged_types.push(shift_type_refs(ty.clone(), type_offset)); + } + for table_def in mount.tables() { + let mut raw: RawTableDefV9 = table_def.clone().into(); + raw.name = format!("{}{}", prefix, table_def.accessor_name).into(); + raw.product_type_ref = AlgebraicTypeRef(table_def.product_type_ref.0 + type_offset); + flat_tables.push(raw); + } + for reducer_def in mount.reducers() { + let mut raw: RawReducerDefV9 = reducer_def.clone().into(); + raw.name = format!("{}{}", fn_prefix, reducer_def.name).into(); + flat_reducers.push(raw); + } + for procedure_def in mount.procedures() { + let mut raw: RawProcedureDefV9 = procedure_def.clone().into(); + raw.name = format!("{}{}", fn_prefix, procedure_def.name).into(); + flat_misc.push(RawMiscModuleExportV9::Procedure(raw)); + } + let mount_anon = mount.anon_view_count() as u32; + let mount_non_anon = mount.non_anon_view_count() as u32; + for view_def in mount.views() { + let mut raw: RawViewDefV9 = view_def.clone().into(); + raw.name = format!("{}{}", prefix, view_def.accessor_name).into(); + let local_offset = if view_def.is_anonymous { + *anon_view_offset + } else { + *view_offset + }; + raw.index = view_def.fn_ptr.0 + local_offset; + // Shift all AlgebraicTypeRefs in the return type and params to the merged typespace. + raw.return_type = shift_type_refs(raw.return_type, type_offset); + raw.params = ProductType::new( + raw.params + .elements + .iter() + .map(|e| ProductTypeElement { + name: e.name.clone(), + algebraic_type: shift_type_refs(e.algebraic_type.clone(), type_offset), + }) + .collect::>(), + ); + flat_misc.push(RawMiscModuleExportV9::View(raw)); + } + collect_v9_mounts( + mount.mounts(), + &prefix, + flat_tables, + flat_reducers, + flat_misc, + merged_types, + anon_view_offset, + view_offset, + ); + *anon_view_offset += mount_anon; + *view_offset += mount_non_anon; + } +} + +/// Recursively shift all `AlgebraicTypeRef` indices in `ty` by `offset`. +fn shift_type_refs(ty: AlgebraicType, offset: u32) -> AlgebraicType { + match ty { + AlgebraicType::Ref(r) => AlgebraicType::Ref(AlgebraicTypeRef(r.0 + offset)), + AlgebraicType::Product(p) => AlgebraicType::Product(ProductType::new( + p.elements + .iter() + .map(|e| ProductTypeElement { + name: e.name.clone(), + algebraic_type: shift_type_refs(e.algebraic_type.clone(), offset), + }) + .collect::>(), + )), + AlgebraicType::Sum(s) => AlgebraicType::Sum(SumType::new( + s.variants + .iter() + .map(|v| SumTypeVariant { + name: v.name.clone(), + algebraic_type: shift_type_refs(v.algebraic_type.clone(), offset), + }) + .collect::>(), + )), + AlgebraicType::Array(a) => AlgebraicType::Array(ArrayType { + elem_ty: Box::new(shift_type_refs(*a.elem_ty, offset)), + }), + other => other, + } +} + impl TryFrom for ModuleDef { type Error = ValidationErrors; @@ -564,6 +1088,7 @@ impl From for RawModuleDefV10 { http_handlers, http_routes, raw_module_def_version: _, + mounts, } = val; let mut sections = Vec::new(); @@ -698,6 +1223,17 @@ impl From for RawModuleDefV10 { // Always emit ExplicitNames so canonical names survive the round-trip. sections.push(RawModuleDefV10Section::ExplicitNames(explicit_names)); + let mounts: Vec<_> = mounts + .into_iter() + .map(|(namespace, module)| RawModuleMountV10 { + namespace, + module: module.into(), + }) + .collect(); + if !mounts.is_empty() { + sections.push(RawModuleDefV10Section::Mounts(mounts)); + } + RawModuleDefV10 { sections } } } @@ -2143,4 +2679,78 @@ mod tests { .count() == 2)) } + + #[test] + fn mounted_reducer_ids_are_depth_first() { + use spacetimedb_lib::db::raw_def::v10::{ + RawModuleDefV10Builder, RawModuleDefV10Section, RawModuleMountV10, + }; + + // baz library: 1 reducer + let mut baz_builder = RawModuleDefV10Builder::new(); + baz_builder.add_reducer("baz_reduce", ProductType::unit()); + + // auth library: 1 own reducer, mounts baz + let mut auth_builder = RawModuleDefV10Builder::new(); + auth_builder.add_reducer("auth_verify", ProductType::unit()); + let mut auth_raw = auth_builder.finish(); + auth_raw.sections.push(RawModuleDefV10Section::Mounts(vec![RawModuleMountV10 { + namespace: "baz".to_string(), + module: baz_builder.finish(), + }])); + + // consumer: 2 own reducers, mounts auth + let mut consumer_builder = RawModuleDefV10Builder::new(); + consumer_builder.add_reducer("consumer_a", ProductType::unit()); + consumer_builder.add_reducer("consumer_b", ProductType::unit()); + let mut consumer_raw = consumer_builder.finish(); + consumer_raw.sections.push(RawModuleDefV10Section::Mounts(vec![RawModuleMountV10 { + namespace: "auth".to_string(), + module: auth_raw, + }])); + + let def: ModuleDef = consumer_raw.try_into().expect("valid module"); + + // Total count: 2 consumer + 1 auth + 1 baz + assert_eq!(def.reducer_count(), 4); + + // Depth-first order: consumer_a=0, consumer_b=1, auth_verify=2, baz_reduce=3 + let ids_and_defs = def.reducer_ids_and_defs(); + assert_eq!(ids_and_defs.len(), 4); + assert_eq!(ids_and_defs[0].0, ReducerId(0)); + assert_eq!(&*ids_and_defs[0].1.name, "consumer_a"); + assert_eq!(ids_and_defs[1].0, ReducerId(1)); + assert_eq!(&*ids_and_defs[1].1.name, "consumer_b"); + assert_eq!(ids_and_defs[2].0, ReducerId(2)); + assert_eq!(&*ids_and_defs[2].1.name, "auth_verify"); + assert_eq!(ids_and_defs[3].0, ReducerId(3)); + assert_eq!(&*ids_and_defs[3].1.name, "baz_reduce"); + + // get_reducer_by_id resolves mounted reducer IDs correctly + assert_eq!(&*def.reducer_by_id(ReducerId(2)).name, "auth_verify"); + assert_eq!(&*def.reducer_by_id(ReducerId(3)).name, "baz_reduce"); + assert!(def.get_reducer_by_id(ReducerId(4)).is_none()); + + // reducer_by_name routes plain names to own reducers + let (id, rdef) = def.reducer_by_name("consumer_a").expect("plain name resolves"); + assert_eq!(id, ReducerId(0)); + assert_eq!(&*rdef.name, "consumer_a"); + + // reducer_by_name routes qualified names to mounted reducers + let (id, rdef) = def.reducer_by_name("auth/auth_verify").expect("qualified name resolves"); + assert_eq!(id, ReducerId(2)); + assert_eq!(&*rdef.name, "auth_verify"); + + // reducer_by_name routes deeply nested qualified names + let (id, rdef) = def + .reducer_by_name("auth/baz/baz_reduce") + .expect("nested qualified name resolves"); + assert_eq!(id, ReducerId(3)); + assert_eq!(&*rdef.name, "baz_reduce"); + + // Non-existent names return None + assert!(def.reducer_by_name("auth/nonexistent").is_none()); + assert!(def.reducer_by_name("nonexistent").is_none()); + assert!(def.reducer_by_name("nonamespace/auth_verify").is_none()); + } } diff --git a/crates/schema/src/def/validate/v10.rs b/crates/schema/src/def/validate/v10.rs index 5ea6370f2d0..e686839ead1 100644 --- a/crates/schema/src/def/validate/v10.rs +++ b/crates/schema/src/def/validate/v10.rs @@ -1,10 +1,13 @@ +use enum_map::EnumMap; use spacetimedb_data_structures::error_stream::ErrorStream; use spacetimedb_data_structures::map::HashMap; use spacetimedb_lib::bsatn::Deserializer; use spacetimedb_lib::db::raw_def::v10::*; +use spacetimedb_lib::db::raw_def::v9::Lifecycle; use spacetimedb_lib::db::view::{extract_view_return_product_type_ref, ViewKind}; use spacetimedb_lib::de::DeserializeSeed as _; use spacetimedb_lib::http::character_is_acceptable_for_route_path; +use spacetimedb_primitives::ReducerId; use spacetimedb_sats::{Typespace, WithTypespace}; use crate::def::validate::v9::{ @@ -82,6 +85,12 @@ pub fn validate(def: RawModuleDefV10) -> Result { .cloned() .map(ExplicitNamesLookup::new) .unwrap_or_default(); + let mounts = def + .mounts() + .into_iter() + .flat_map(|mounts| mounts.iter().cloned()) + .map(validate_mount) + .collect_all_errors::>(); // Original `typespace` needs to be preserved to be assign `accesor_name`s to columns. let typespace_with_accessor_names = typespace.clone(); @@ -290,14 +299,15 @@ pub fn validate(def: RawModuleDefV10) -> Result { .map(|rls| (rls.sql.clone(), rls.to_owned())) .collect(); - let (tables, types, reducers, procedures, views, http_handlers, http_routes) = - tables_types_reducers_procedures_views - .map( - |(tables, types, reducers, procedures, views, (http_handlers, http_routes))| { - (tables, types, reducers, procedures, views, http_handlers, http_routes) - }, - ) - .map_err(|errors: ValidationErrors| errors.sort_deduplicate())?; + let ((tables, types, reducers, procedures, views, (http_handlers, http_routes)), mounts) = ( + tables_types_reducers_procedures_views, + mounts.and_then(validate_mount_names_are_unique), + ) + .combine_errors() + .map_err(|errors: ValidationErrors| errors.sort_deduplicate())?; + + validate_no_lifecycle_conflicts(&lifecycle_reducers, &mounts) + .map_err(|errors: ValidationErrors| errors.sort_deduplicate())?; let typespace_for_generate = typespace_for_generate.finish(); @@ -316,9 +326,92 @@ pub fn validate(def: RawModuleDefV10) -> Result { http_handlers, http_routes, raw_module_def_version: RawModuleDefVersion::V10, + mounts, }) } +fn validate_mount(mount: RawModuleMountV10) -> Result<(String, ModuleDef)> { + Identifier::new(mount.namespace.clone().into()) + .map_err(|error| ValidationErrors::from(ValidationError::IdentifierError { error }))?; + + if mount.namespace.len() > 63 { + return Err(ValidationErrors::from(ValidationError::NamespaceTooLong { + namespace: mount.namespace.clone().into(), + len: mount.namespace.len(), + })); + } + + Ok((mount.namespace, validate(mount.module)?)) +} + +fn validate_mount_names_are_unique(mounts: Vec<(String, ModuleDef)>) -> Result> { + let mut errors = vec![]; + let mut map = IndexMap::with_capacity(mounts.len()); + + for (namespace, def) in mounts { + if map.contains_key(&namespace) { + errors.push(ValidationError::DuplicateName { name: namespace.into() }); + } else { + map.insert(namespace, def); + } + } + + ValidationErrors::add_extra_errors(Ok(map), errors) +} + +/// Check that no two modules in the mount tree claim the same lifecycle reducer. +/// +/// The host assigns exactly one reducer per lifecycle slot; if both the consumer +/// and a mounted submodule (or two sibling mounts) declare `__init__` (etc.), the +/// module must be rejected at publish time. +fn validate_no_lifecycle_conflicts( + root_lifecycles: &EnumMap>, + mounts: &IndexMap, +) -> Result<()> { + let mut claimed_by: EnumMap> = EnumMap::default(); + let mut errors: Vec = vec![]; + + for (lifecycle, opt_id) in root_lifecycles { + if opt_id.is_some() { + claimed_by[lifecycle] = Some("".to_string()); + } + } + + collect_lifecycle_conflicts(mounts, "", &mut claimed_by, &mut errors); + + ValidationErrors::add_extra_errors(Ok(()), errors) +} + +fn collect_lifecycle_conflicts( + mounts: &IndexMap, + parent_path: &str, + claimed_by: &mut EnumMap>, + errors: &mut Vec, +) { + for (ns, def) in mounts { + let path = if parent_path.is_empty() { + ns.clone() + } else { + format!("{parent_path}::{ns}") + }; + + for (lifecycle, opt_id) in def.lifecycle_reducers_map() { + if opt_id.is_some() { + match &claimed_by[lifecycle] { + Some(prior) => errors.push(ValidationError::ConflictingMountLifecycle { + lifecycle, + first: prior.clone(), + second: path.clone(), + }), + None => claimed_by[lifecycle] = Some(path.clone()), + } + } + } + + collect_lifecycle_conflicts(def.mounts(), &path, claimed_by, errors); + } +} + /// Change the visibility of scheduled functions and lifecycle reducers to Internal. /// fn change_scheduled_functions_and_lifetimes_visibility( @@ -1004,12 +1097,16 @@ mod tests { use itertools::Itertools; use spacetimedb_data_structures::expect_error_matching; - use spacetimedb_lib::db::raw_def::v10::{CaseConversionPolicy, MethodOrAny, RawModuleDefV10Builder}; + use spacetimedb_lib::db::raw_def::v10::{ + CaseConversionPolicy, MethodOrAny, RawModuleDefV10, RawModuleDefV10Builder, RawModuleDefV10Section, + RawModuleMountV10, + }; use spacetimedb_lib::db::raw_def::v9::{btree, direct, hash}; use spacetimedb_lib::db::raw_def::*; use spacetimedb_lib::http::Method as HttpMethod; use spacetimedb_lib::ScheduleAt; use spacetimedb_primitives::{ColId, ColList, ColSet}; + use spacetimedb_sats::raw_identifier::RawIdentifier; use spacetimedb_sats::{AlgebraicType, AlgebraicTypeRef, AlgebraicValue, ProductType, SumValue}; use v9::{Lifecycle, TableAccess, TableType}; @@ -1384,6 +1481,66 @@ mod tests { }); } + #[test] + fn validates_mounted_submodules_recursively() { + let mut mounted_builder = RawModuleDefV10Builder::new(); + mounted_builder + .build_table_with_new_type("Sessions", ProductType::from([("id", AlgebraicType::U64)]), true) + .finish(); + + let raw = RawModuleDefV10 { + sections: vec![RawModuleDefV10Section::Mounts(vec![RawModuleMountV10 { + namespace: "authlib".to_string(), + module: mounted_builder.finish(), + }])], + }; + + let def: ModuleDef = raw.try_into().expect("mounted module should validate"); + let mounts = def.mounts(); + + assert_eq!(mounts.len(), 1); + let mounted = mounts.get("authlib").expect("authlib mount should exist"); + assert!(mounted.table(&expect_identifier("sessions")).is_some()); + } + + #[test] + fn invalid_mount_namespace() { + let raw = RawModuleDefV10 { + sections: vec![RawModuleDefV10Section::Mounts(vec![RawModuleMountV10 { + namespace: "".to_string(), + module: RawModuleDefV10::default(), + }])], + }; + + let result: Result = raw.try_into(); + + expect_error_matching!(result, ValidationError::IdentifierError { error } => { + error == &IdentifierError::Empty {} + }); + } + + #[test] + fn duplicate_mount_namespace() { + let raw = RawModuleDefV10 { + sections: vec![RawModuleDefV10Section::Mounts(vec![ + RawModuleMountV10 { + namespace: "authlib".to_string(), + module: RawModuleDefV10::default(), + }, + RawModuleMountV10 { + namespace: "authlib".to_string(), + module: RawModuleDefV10::default(), + }, + ])], + }; + + let result: Result = raw.try_into(); + + expect_error_matching!(result, ValidationError::DuplicateName { name } => { + name == &RawIdentifier::from("authlib") + }); + } + #[test] fn invalid_unique_constraint_column_ref() { let mut builder = RawModuleDefV10Builder::new(); @@ -2426,4 +2583,118 @@ mod tests { assert_eq!(view.return_columns[0].view_name, id("Level2Person")); assert_eq!(view.param_columns[0].view_name, id("Level2Person")); } + + #[test] + fn namespace_exactly_63_chars_is_ok() { + let namespace = "a".repeat(63); + let raw = RawModuleDefV10 { + sections: vec![RawModuleDefV10Section::Mounts(vec![RawModuleMountV10 { + namespace, + module: RawModuleDefV10::default(), + }])], + }; + let result: Result = raw.try_into(); + assert!(result.is_ok(), "63-char namespace should be valid"); + } + + #[test] + fn namespace_64_chars_is_rejected() { + let namespace = "a".repeat(64); + let raw = RawModuleDefV10 { + sections: vec![RawModuleDefV10Section::Mounts(vec![RawModuleMountV10 { + namespace: namespace.clone(), + module: RawModuleDefV10::default(), + }])], + }; + let expected_ns = RawIdentifier::from(namespace.clone()); + let result: Result = raw.try_into(); + expect_error_matching!(result, ValidationError::NamespaceTooLong { namespace: ns, len } => { + ns == &expected_ns && len == &64usize + }); + } + + fn make_module_with_lifecycle(lifecycle: Lifecycle) -> RawModuleDefV10 { + let mut b = RawModuleDefV10Builder::new(); + b.add_lifecycle_reducer(lifecycle, "lifecycle_fn", ProductType::unit()); + b.finish() + } + + #[test] + fn consumer_and_mount_same_lifecycle_is_rejected() { + // Build the consumer's sections using the builder, then add a Mounts section. + let consumer_raw = make_module_with_lifecycle(Lifecycle::Init); + let mut sections = consumer_raw.sections; + sections.push(RawModuleDefV10Section::Mounts(vec![RawModuleMountV10 { + namespace: "auth".to_string(), + module: make_module_with_lifecycle(Lifecycle::Init), + }])); + + let result: Result = RawModuleDefV10 { sections }.try_into(); + expect_error_matching!(result, ValidationError::ConflictingMountLifecycle { lifecycle, first, second } => { + lifecycle == &Lifecycle::Init && first == "" && second == "auth" + }); + } + + #[test] + fn two_sibling_mounts_same_lifecycle_is_rejected() { + let raw = RawModuleDefV10 { + sections: vec![RawModuleDefV10Section::Mounts(vec![ + RawModuleMountV10 { + namespace: "auth".to_string(), + module: make_module_with_lifecycle(Lifecycle::OnConnect), + }, + RawModuleMountV10 { + namespace: "payments".to_string(), + module: make_module_with_lifecycle(Lifecycle::OnConnect), + }, + ])], + }; + + let result: Result = raw.try_into(); + expect_error_matching!(result, ValidationError::ConflictingMountLifecycle { lifecycle, first, second } => { + lifecycle == &Lifecycle::OnConnect && first == "auth" && second == "payments" + }); + } + + #[test] + fn different_lifecycles_across_mounts_is_ok() { + let raw = RawModuleDefV10 { + sections: vec![RawModuleDefV10Section::Mounts(vec![ + RawModuleMountV10 { + namespace: "auth".to_string(), + module: make_module_with_lifecycle(Lifecycle::Init), + }, + RawModuleMountV10 { + namespace: "payments".to_string(), + module: make_module_with_lifecycle(Lifecycle::OnConnect), + }, + ])], + }; + + let result: Result = raw.try_into(); + assert!(result.is_ok(), "different lifecycles across mounts should be valid"); + } + + #[test] + fn nested_mount_conflicts_with_root_lifecycle() { + // consumer → auth → baz: consumer claims Init, baz also claims Init. + let auth = RawModuleDefV10 { + sections: vec![RawModuleDefV10Section::Mounts(vec![RawModuleMountV10 { + namespace: "baz".to_string(), + module: make_module_with_lifecycle(Lifecycle::Init), + }])], + }; + + let consumer_raw = make_module_with_lifecycle(Lifecycle::Init); + let mut sections = consumer_raw.sections; + sections.push(RawModuleDefV10Section::Mounts(vec![RawModuleMountV10 { + namespace: "auth".to_string(), + module: auth, + }])); + + let result: Result = RawModuleDefV10 { sections }.try_into(); + expect_error_matching!(result, ValidationError::ConflictingMountLifecycle { lifecycle, first, second } => { + lifecycle == &Lifecycle::Init && first == "" && second == "auth::baz" + }); + } } diff --git a/crates/schema/src/def/validate/v9.rs b/crates/schema/src/def/validate/v9.rs index 618f8e3c9c4..7a65913291c 100644 --- a/crates/schema/src/def/validate/v9.rs +++ b/crates/schema/src/def/validate/v9.rs @@ -168,6 +168,7 @@ pub fn validate(def: RawModuleDefV9) -> Result { http_handlers: IndexMap::new(), http_routes: Vec::new(), raw_module_def_version: RawModuleDefVersion::V9OrEarlier, + mounts: IndexMap::new(), }) } diff --git a/crates/schema/src/error.rs b/crates/schema/src/error.rs index 33abb0c1866..369f8090f42 100644 --- a/crates/schema/src/error.rs +++ b/crates/schema/src/error.rs @@ -165,6 +165,19 @@ pub enum ValidationError { ok_type: PrettyAlgebraicType, err_type: PrettyAlgebraicType, }, + #[error( + "lifecycle event {lifecycle:?} is claimed by used `{first}` and `{second}`; \ + only one module in the dependency tree may declare each lifecycle" + )] + ConflictingMountLifecycle { + lifecycle: Lifecycle, + /// Namespace path of the first claimant + first: String, + /// Namespace path of the second claimant + second: String, + }, + #[error("mount namespace `{namespace}` is {len} characters, which exceeds the 63-character limit")] + NamespaceTooLong { namespace: RawIdentifier, len: usize }, } /// A wrapper around an `AlgebraicType` that implements `fmt::Display`. diff --git a/crates/schema/src/table_name.rs b/crates/schema/src/table_name.rs index 3fe32ed70da..ffd2e79fcd1 100644 --- a/crates/schema/src/table_name.rs +++ b/crates/schema/src/table_name.rs @@ -5,20 +5,26 @@ use spacetimedb_sats::{impl_deserialize, impl_serialize, impl_st, raw_identifier /// The name of a table. #[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct TableName(Identifier); +pub struct TableName(RawIdentifier); -impl_st!([] TableName, ts => Identifier::make_type(ts)); +impl_st!([] TableName, ts => RawIdentifier::make_type(ts)); impl_serialize!([] TableName, (self, ser) => self.0.serialize(ser)); -impl_deserialize!([] TableName, de => Identifier::deserialize(de).map(Self)); +impl_deserialize!([] TableName, de => RawIdentifier::deserialize(de).map(Self)); impl TableName { + /// Construct from a validated identifier (all user-defined tables). pub fn new(id: Identifier) -> Self { - Self(id) + Self(id.into()) + } + + /// Construct from an arbitrary raw string (e.g. mounted tables whose names contain `.`). + pub fn new_raw(name: RawIdentifier) -> Self { + Self(name) } #[cfg(any(test, feature = "test"))] pub fn for_test(name: &str) -> Self { - Self(Identifier::for_test(name)) + Self(RawIdentifier::new(name)) } } @@ -38,13 +44,13 @@ impl AsRef for TableName { impl From for Identifier { fn from(id: TableName) -> Self { - id.0 + Identifier::new(id.0).expect("TableName contains '.' or other non-identifier chars; use RawIdentifier instead") } } impl From for RawIdentifier { fn from(id: TableName) -> Self { - Identifier::from(id).into() + id.0 } } From 02954c6f9d4a43c55264896fa16499d7b13e4122 Mon Sep 17 00:00:00 2001 From: Alessandro Asoni Date: Mon, 1 Jun 2026 15:40:50 +0200 Subject: [PATCH 2/2] 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)?;