Skip to content

Add TypeScript submodule support for SpacetimeDB modules#5486

Open
aasoni wants to merge 8 commits into
masterfrom
alessandro/ts-submodules
Open

Add TypeScript submodule support for SpacetimeDB modules#5486
aasoni wants to merge 8 commits into
masterfrom
alessandro/ts-submodules

Conversation

@aasoni

@aasoni aasoni commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Add TypeScript submodule support for SpacetimeDB modules

Description of Changes

  • TypeScript server SDK: runtime API for module mounting (submodules), including namespaced dispatch, ctx.as helpers for cross-namespace calls, and HTTP handler delegation
  • TypeScript client codegen: namespace-aware table/reducer/procedure exports and query builder for submodule tables and views
  • Migrations: auto-migrate support for submodule tables, views, and indexes; AddConstraint step added alongside NamespacedIdentifier-based migration steps
  • CLI: namespace support for spacetime call and spacetime sql
  • C++/C# bindings: autogenerated RawSubmoduleV10 types
  • module-test-ts: submodule integration test (lib_submodule)

API and ABI breaking changes

Adds a submodule field to ModuleDef. Older modules will be publishable to new servers, but new modules that have submodules will not be publishable to old servers.

Expected complexity level and risk

5

Testing

Beyond the rust tests defined in this PR, the following tests were done on the full PR sequence once the entire namespace feature was implemented for typescript:
Feature Test Checklist

Module:

  • root module can import another module and mount it with a namespace under its schema
  • root module and submodule can have the same function and table names without conflicting
  • ctx.db.lib.lib_table is readable/writable inside root module reducer
  • ctx.db.lib.lib_table is readable/writable inside root module procedure withTx block
  • library_reducer(ctx.as.lib) is callable inside root module reducer

Client

  • Client can subscribe to lib.library_table
  • Client can subscribe to lib.library_view
  • Client can call lib/library_reducer
  • Client can call lib/library_procedure
  • Client can subscribe to lib.sublib.sublib_table
  • Client can subscribe to lib.sublib.sublib_view
  • Client can call lib/sublib/sublib_reducer
  • Client can call lib/sublib/sublib_procedure

CLI

  • CLI can subscribe to lib.library_table
  • CLI can subscribe to lib.library_view
  • CLI can call lib/library_reducer
  • CLI can call lib/library_procedure
  • CLI can subscribe to lib.sublib.sublib_table
  • CLI can subscribe to lib.sublib.sublib_view
  • CLI can call lib/sublib/sublib_reducer
  • CLI can call lib/sublib/sublib_procedure

Migration

  • Module migrates without issue from having a submodule to not having a submodule
  • Module migrates without issue from not having a submodule to having a submodule
  • Module migrates without issue when having a submodule and root module change occurs (change reducer signature, add table, add column with default, change reducer function body, change index)
  • Module migrates without issue when having a submodule and a submodule change occurs (change reducer signature, add table, add column with default, change reducer function body, change index)

Commit Log

  • Module loads fine from commit log
  • Module snapshot is created without issue
  • Module loads fine from snapshot

@aasoni
aasoni force-pushed the alessandro/ts-submodules branch 5 times, most recently from 80e8b25 to 5de4e3d Compare July 10, 2026 11:40
@aasoni
aasoni force-pushed the alessandro/ts-submodules branch from 5de4e3d to 84c80c6 Compare July 13, 2026 15:33
Comment thread crates/bindings-typescript/src/lib/query.ts Outdated
Comment thread crates/bindings-typescript/src/lib/util.ts Outdated
Comment thread crates/bindings-typescript/src/sdk/db_connection_impl.ts Outdated
Comment thread crates/bindings-typescript/src/sdk/subscription_builder_impl.ts Outdated
Comment thread crates/bindings-typescript/src/server/schema.ts
}
}

type FlatSubmoduleDispatch = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same "Dispatch" naming question

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar answer to above

Comment thread crates/bindings-typescript/src/server/runtime.ts

(anon ? ctx.anonViews : ctx.views).push({
fn,
fn: fn as unknown as ViewFn<any, any, any>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This type cast is pretty sus. Why is this necessary?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a lot of this was with help of claude this was the reason it gave for this:

`fn`'s static type is the conditional `Anonymous extends true ? AnonymousViewFn : ViewFn`,
but `Anonymous` is only resolved at runtime via `anon`. TypeScript can't correlate the two,
so the cast bridges a distinction the type system can't express here; `anon` picks the
matching array at runtime, and view dispatch (in `runtime.ts`) invokes `fn` with the ctx
shape appropriate to that array, regardless of the type recorded when it was pushed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This really smells like LLM BS justification to me unfortunately. Or rather it is literally true, but does not rule out another way to do it that would not require this cast.

Comment thread crates/cli/src/subcommands/build.rs
&mut self,
num: Option<u32>,
module_def: &RawModuleDefV9,
module_def: &ModuleDef,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you explain why these are all changing?

@aasoni aasoni Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ModuleDefV9 doesn't have submodules so any submodule related subscription (e.g.spacetime subscribe select * from lib.table would fail as the command wouldn't be able to know the type of table as it would only know about tables in the root module)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am medium concerned that this might be a breaking change of some kind. I know that for example there are places that we read in a V9 or V10 raw version and parse it. I'd approve but I'll leave this comment open. When you verify that it's not a breaking change, you can resolve the conversation and merge it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't taken the time to understand all this, but before I do, I just wanted to relay the goal that the generated code should be the same as what you write in the module itself. Does this new codegen maintain that property, at least largely?

@aasoni aasoni Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure I follow exactly, the generated code is the same as what is generated for root modules however to avoid naming conflicts submodule types are generated inside subfolders. This is the main different between code generation for root types and code generation for submodule types.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm saying that we have a requirement that we want users to be able to import their module code into the client to be able to use them as types. Obviously if their module is written in a different language we cannot do that so we have to generate client bindings. I'm saying the client bindings should/must match what the user would have written in their TypeScript module if they had written one. And we should avoid diverging from that principle.

Comment thread crates/core/src/host/module_host.rs Outdated
pub table_id: TableId,
pub view_def: &'a ViewDef,
/// The full namespaced view name as stored in `st_view` (e.g. `"lib.library_view"`).
pub view_name: RawIdentifier,

@cloutiertyler cloutiertyler Jul 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RawIdentifier is not a great name for a dot delimited identifier or whatever. It implies that I could convert it into an Identifier by validating it, and I don't think that's possible.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are now using NamespacedIdentifier which is a list of Identifier

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I'd like to have @joshua-spacetime look at the view changes.


if *is_anonymous {
if view_instances.is_empty() {
if !subs.iter().any(|a| matches!(a, ViewInstanceArgs::Anonymous)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems materially different.

@aasoni aasoni Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restored the is_empty() check


pub trait InstanceOp {
fn name(&self) -> &Identifier;
fn name(&self) -> &RawIdentifier;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RawIdentifier really feels like the wrong name for a multipart identifier.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is now using &str. name here isn't meant to be a validated name, it's only used for logging. However I am going to see if it can be made into a NamespacedIdentifier

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I couldn't, but I think &str makes sense here given how it's used (logging)

@cloutiertyler cloutiertyler left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This definitely needs some cleanup. The most notable things that need clean up are uses of RawIdentifier and NamespacedIdentifier::new_assume_valid after validating the module def.

Left comments for the other stuff.

Comment thread crates/engine/src/update.rs Outdated

for view in module_def.views() {
if view_backing_table_needs_recreate(stdb, tx, module_def, view)? {
let name = NamespacedIdentifier::new_assume_valid(view.name.to_string());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should not be passing non-namespaced identifiers into here and doing new_assume_valid. TBH we should not have any calls like this of any kind.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is now creates a NamespacedIdentifier from the view name which is also a NamespacedIdentifier

Comment thread crates/engine/src/update.rs Outdated
spacetimedb_schema::auto_migrate::AutoMigrateStep::AddRowLevelSecurity(sql_rls) => {
log!(logger, "Adding row-level security `{sql_rls}`");
let rls = plan.new.lookup_expect(sql_rls);
let rls = plan

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't lookup_expect just support this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replaced with the moduledef lookup function (which uses a hashmap so doesn't need to scan). lookup_expect panics if the key doesn't exist. I think it's probably ok to use the panicky version but this is slightly safer

}

#[test]
fn stale_view_backing_schema_generates_startup_repair_plan() -> anyhow::Result<()> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we removing tests?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests are restored

/// is used to sort steps of an auto-migration so that removes precede adds for the same name.
#[derive(PartialEq, Eq, Debug, PartialOrd, Ord)]
pub enum AutoMigrateStep<'def> {
// It is important FOR CORRECTNESS that `Remove` variants are declared before `Add` variants in this enum!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are all of these comments being removed? This seems not good.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

comment restored.

/// Validated at execution time: fails if the table contains data.
RemoveTable(<TableDef as ModuleDefLookup>::Key<'def>),
/// Payload is the full namespaced table name (e.g., `"lib.library_table"` or `"user"`).
RemoveTable(NamespacedIdentifier),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like a relaxation of type constraints, no?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is but <TableDef as ModuleDefLookup>::Key<'def> is of type Identifier which can't hold namespaced names

Comment thread crates/schema/src/auto_migrate.rs Outdated
assert!(
steps.contains(&AutoMigrateStep::RemoveSequence(&apples_sequence)),
steps.contains(&AutoMigrateStep::RemoveSequence(
NamespacedIdentifier::new_assume_valid("Apples_id_seq")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please get rid of all these new_assume_valids

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

even in tests?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these are now all using NamespacedIdentifiers built with valid Identifiers

}

fn format_remove_table(&mut self, table_name: &Identifier) -> io::Result<()> {
fn format_remove_table(&mut self, table_name: &str) -> io::Result<()> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be NamespacedIdentifier

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it does now

impl_deserialize!([] Identifier, de => RawIdentifier::deserialize(de).map(Self::new_assume_valid));

/// Validates that `name` is a valid identifier string
pub fn validate_identifier(name: &str) -> Result<(), IdentifierError> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does this need to be a separate function? I would assume a NamespacedIdentifier would be a list of Identifiers

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is now

Comment thread crates/schema/src/identifier.rs Outdated
/// Root-level items use their plain name with no separator (e.g., `"user"`).
/// Construction from known-valid components should use `new_assume_valid`.
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct NamespacedIdentifier(Box<str>);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be a list (or pair) of Identifier not a str.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NamespacedIdentifier is now a list of Identifier

Comment thread crates/schema/src/table_name.rs Outdated
/// The name of a table.
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct TableName(Identifier);
pub struct TableName(RawIdentifier);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should definitely not be a RawIdentifier

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is now changed to be a NamespacedIdentifier instead

aasoni added 4 commits July 23, 2026 13:44
- TypeScript server SDK: runtime API for module mounting (submodules),
  including namespaced dispatch, ctx.as helpers for cross-namespace calls,
  and HTTP handler delegation
- TypeScript client codegen: namespace-aware table/reducer/procedure
  exports and query builder for submodule tables and views
- Migrations: auto-migrate support for submodule tables, views, and
  indexes; AddConstraint step added alongside NamespacedIdentifier-based
  migration steps
- CLI: namespace support for `spacetime call` and `spacetime sql`
- C++/C# bindings: autogenerated RawSubmoduleV10 types
- module-test-ts: submodule integration test (lib_submodule)
- constraint and sequence names didn't have appropriate namespaced
  prefix
- root tables should match def name exactly
- sql-parser didn't accept namespaced column in WHERE and JOIN clauses
@aasoni
aasoni force-pushed the alessandro/ts-submodules branch from 153ce36 to d3a7a4f Compare July 23, 2026 11:47
const libSubmoduleSchema = schema({ lib_data });
export default libSubmoduleSchema;

export const lib_insert = libSubmoduleSchema.reducer(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should really be camelCase.

);

// use_submodule_procedure: calls the lib submodule's lib_count procedure and returns the result.
export const use_submodule_procedure = spacetimedb.procedure(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Never use snake_case in TypeScript. Always camelCase.

import { Router } from 'spacetimedb/server';

// delegate the /health route to the submodule's handler
export const health_check = spacetimedb.httpHandler((ctx, req) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs to be camelCase.

### CLI

```bash
spacetime call my-database "myauth/verify_token" '{"token": "abc123"}'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be camelCase.

(ctx, { token }) => { /* ... */ }
);

export const session_count = spacetimedb.procedure(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be camelCase, never use snake_case in TS.

/// Returns the handle used by reducers to read from `args`
/// as well as the handle used to write the error message, if any.
fn start_funcall(&mut self, name: Identifier, ts: Timestamp, func_type: FuncCallType) {
fn start_funcall(&mut self, name: RawIdentifier, ts: Timestamp, func_type: FuncCallType) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should not be RawIdentifier.

// Start the timer.
// We'd like this tightly around `call`.
env.start_funcall(op.name().clone(), op.timestamp(), op.call_type());
env.start_funcall(RawIdentifier::new(op.name()), op.timestamp(), op.call_type());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should also not be raw identifier.


pub trait InstanceOp {
fn name(&self) -> &Identifier;
fn name(&self) -> &str;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should not be a raw str

impl InstanceOp for ReducerOp<'_> {
fn name(&self) -> &Identifier {
self.name.as_identifier()
fn name(&self) -> &str {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All these changes from Identifier to str should be fixed. We should be using Identifier if it's an Identifier or NamespacedIdentifier if it is a NamespacedIdentifier, but never raw strs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants