From 3b6080d6c5436928cb0bff5f49cccc8e6334542c Mon Sep 17 00:00:00 2001 From: Alessandro Asoni Date: Mon, 1 Jun 2026 15:46:54 +0200 Subject: [PATCH] TypeScript: codegen and SDK support for module mounts --- .../src/sdk/db_connection_impl.ts | 18 +- .../src/sdk/subscription_builder_impl.ts | 48 ++- crates/codegen/src/lib.rs | 69 ++++ crates/codegen/src/typescript.rs | 360 +++++++++++++++++- 4 files changed, 471 insertions(+), 24 deletions(-) diff --git a/crates/bindings-typescript/src/sdk/db_connection_impl.ts b/crates/bindings-typescript/src/sdk/db_connection_impl.ts index 1e604349f66..97ff5d607f2 100644 --- a/crates/bindings-typescript/src/sdk/db_connection_impl.ts +++ b/crates/bindings-typescript/src/sdk/db_connection_impl.ts @@ -30,7 +30,11 @@ import type { } from './message_types.ts'; import type { ReducerEvent } from './reducer_event.ts'; import { type UntypedRemoteModule } from './spacetime_module.ts'; -import { makeQueryBuilder } from '../lib/query'; +import { + makeFromBuilder, + makeQueryBuilder, + type SubscriptionFromBuilder, +} from '../lib/query'; import { type TableCache, type Operation, @@ -52,6 +56,7 @@ import type { } from './reducers.ts'; import type { ClientDbView } from './db_view.ts'; import type { RowType, UntypedTableDef } from '../lib/table.ts'; +import type { UntypedSchemaDef } from '../lib/schema'; import type { ProceduresView } from './procedures.ts'; import type { Values } from '../lib/type_util.ts'; import type { TransactionUpdate } from './client_api/types.ts'; @@ -457,6 +462,10 @@ export class DbConnectionImpl return makeQueryBuilder({ tables: this.#remoteModule.tables } as any); } + getFromBuilder(): SubscriptionFromBuilder { + return makeFromBuilder(this.#remoteModule.tables as SchemaDef['tables']); + } + registerSubscription( handle: SubscriptionHandleImpl, handleEmitter: EventEmitter< @@ -503,8 +512,10 @@ export class DbConnectionImpl const rows: Operation[] = []; const deserializeRow = this.#rowDeserializers[tableName]; - const { primaryKeyColName, primaryKeyColType } = - this.#rowIdMetadata[tableName]; + if (!deserializeRow) return []; + const rowIdInfo = this.#rowIdMetadata[tableName]; + if (!rowIdInfo) return []; + const { primaryKeyColName, primaryKeyColType } = rowIdInfo; let previousOffset = 0; while (reader.remaining > 0) { const row = deserializeRow(reader); @@ -793,6 +804,7 @@ export class DbConnectionImpl // Get table information for the table being updated const tableName = tableUpdate.tableName; const tableDef = this.#sourceNameToTableDef[tableName]; + if (!tableDef) continue; const table = this.clientCache.getOrCreateTable(tableDef); const newCallbacks = table.applyOperations( tableUpdate.operations as Operation< diff --git a/crates/bindings-typescript/src/sdk/subscription_builder_impl.ts b/crates/bindings-typescript/src/sdk/subscription_builder_impl.ts index 1ac436c8c9c..e057dc1a0ba 100644 --- a/crates/bindings-typescript/src/sdk/subscription_builder_impl.ts +++ b/crates/bindings-typescript/src/sdk/subscription_builder_impl.ts @@ -6,13 +6,20 @@ import type { } from './event_context'; import { EventEmitter } from './event_emitter'; import type { UntypedRemoteModule } from './spacetime_module'; -import { isRowTypedQuery, toSql, type RowTypedQuery } from '../lib/query'; +import { + isRowTypedQuery, + toSql, + type SubscriptionFromBuilder, + type RowTypedQuery, +} from '../lib/query'; +import type { UntypedSchemaDef } from '../lib/schema'; import type { Values } from '../lib/type_util'; export class SubscriptionBuilderImpl { #onApplied?: (ctx: SubscriptionEventContextInterface) => void = undefined; #onError?: (ctx: ErrorContextInterface) => void = undefined; + #pendingQueries: Array> = []; constructor(private db: DbConnectionImpl) {} /** @@ -64,6 +71,32 @@ export class SubscriptionBuilderImpl { return this; } + /** + * Accumulates a query for a later `subscribe()` call. + * Queries added via `addQuery` and queries passed directly to `subscribe` are mutually exclusive — + * call `subscribe()` with no arguments to send all accumulated queries. + * + * @param queryFn - Receives `{ from }`, where `from` exposes all tables (root and namespaced). + * @returns The current `SubscriptionBuilder` instance for chaining. + * + * @example + * ```ts + * conn.subscriptionBuilder() + * .addQuery(q => q.from.players.build()) + * .addQuery(q => q.from.inventory.items.build()) + * .subscribe(); + * ``` + */ + addQuery( + queryFn: (q: { + from: SubscriptionFromBuilder; + }) => RowTypedQuery + ): this { + const from = this.db.getFromBuilder(); + this.#pendingQueries.push(queryFn({ from })); + return this; + } + /** * Subscribe to a single query. The results of the query will be merged into the client * cache and deduplicated on the client. @@ -80,6 +113,7 @@ export class SubscriptionBuilderImpl { * subscription.unsubscribe(); * ``` */ + subscribe(): SubscriptionHandleImpl; subscribe( query_sql: string | RowTypedQuery ): SubscriptionHandleImpl; @@ -92,14 +126,22 @@ export class SubscriptionBuilderImpl { ) => RowTypedQuery | RowTypedQuery[] ): SubscriptionHandleImpl; subscribe( - query_sql: + query_sql?: | string | RowTypedQuery | Array> | ((tables: any) => RowTypedQuery | RowTypedQuery[]) ): SubscriptionHandleImpl { let queries: Array>; - if (typeof query_sql === 'function') { + if (query_sql === undefined) { + if (this.#pendingQueries.length === 0) { + throw new Error( + 'subscriptionBuilder().subscribe() called with no queries; use addQuery() first or pass a query argument' + ); + } + queries = this.#pendingQueries; + this.#pendingQueries = []; + } else if (typeof query_sql === 'function') { const tablesMap = this.db.getTablesMap?.(); const result = query_sql(tablesMap); queries = Array.isArray(result) ? result : [result]; diff --git a/crates/codegen/src/lib.rs b/crates/codegen/src/lib.rs index 28d4fb8a5a4..5125d4b0e72 100644 --- a/crates/codegen/src/lib.rs +++ b/crates/codegen/src/lib.rs @@ -1,3 +1,4 @@ +use spacetimedb_lib::db::raw_def::v9::TableAccess; use spacetimedb_schema::def::{ModuleDef, ProcedureDef, ReducerDef, TableDef, TypeDef, ViewDef}; use spacetimedb_schema::schema::{Schema, TableSchema}; mod code_indenter; @@ -33,10 +34,34 @@ pub fn generate(module: &ModuleDef, lang: &dyn Lang, options: &CodegenOptions) - itertools::chain!( util::iter_tables(module, options.visibility).map(|tbl| lang.generate_table_file(module, tbl)), module.views().map(|view| lang.generate_view_file(module, view)), + // Public tables from mounted submodules + module + .all_tables_with_prefix() + .into_iter() + .filter(|(prefix, _, table)| !prefix.is_empty() && table.table_access == TableAccess::Public) + .map(|(prefix, owning_def, table)| lang.generate_mounted_table_file(owning_def, &prefix, table)), + // Views from mounted submodules (views are currently always public) + module + .all_views_with_prefix() + .into_iter() + .filter(|(prefix, _, _)| !prefix.is_empty()) + .map(|(prefix, owning_def, view)| lang.generate_mounted_view_file(owning_def, &prefix, view)), module.types().flat_map(|typ| lang.generate_type_files(module, typ)), util::iter_reducers(module, options.visibility).map(|reducer| lang.generate_reducer_file(module, reducer)), util::iter_procedures(module, options.visibility) .map(|procedure| lang.generate_procedure_file(module, procedure)), + // Reducers from mounted submodules + module + .all_reducers_with_prefix() + .into_iter() + .filter(|(prefix, _, reducer)| !prefix.is_empty() && !reducer.visibility.is_private()) + .map(|(prefix, owning_def, reducer)| lang.generate_mounted_reducer_file(owning_def, &prefix, reducer)), + // Procedures from mounted submodules + module + .all_procedures_with_prefix() + .into_iter() + .filter(|(prefix, _, procedure)| !prefix.is_empty() && !procedure.visibility.is_private()) + .map(|(prefix, owning_def, procedure)| lang.generate_mounted_procedure_file(owning_def, &prefix, procedure)), lang.generate_global_files(module, options), ) .collect() @@ -68,4 +93,48 @@ pub trait Lang { .expect("Failed to generate table due to validation errors"); self.generate_table_file_from_schema(module, &tbl, schema) } + + /// Generate a row-type file for a public table from a mounted submodule. + /// Uses `owning_def`'s typespace for type resolution. + /// Filename goes in a subdirectory named after the namespace: + /// e.g. `alias/table_name_table.ts` for namespace `"alias."`, table `tableName`. + fn generate_mounted_table_file(&self, owning_def: &ModuleDef, namespace: &str, table: &TableDef) -> OutputFile { + let schema = TableSchema::from_module_def(owning_def, table, (), 0.into()) + .validated() + .expect("Failed to generate mounted table file"); + let mut file = self.generate_table_file_from_schema(owning_def, table, schema); + let ns_path = namespace.trim_end_matches('.').replace('.', "/"); + file.filename = format!("{}/{}", ns_path, file.filename); + file + } + + /// Generate a row-type file for a view from a mounted submodule. + fn generate_mounted_view_file(&self, owning_def: &ModuleDef, namespace: &str, view: &ViewDef) -> OutputFile { + let tbl = TableDef::from(view.clone()); + let schema = TableSchema::from_view_def_for_codegen(owning_def, view) + .validated() + .expect("Failed to generate mounted view file"); + let mut file = self.generate_table_file_from_schema(owning_def, &tbl, schema); + let ns_path = namespace.trim_end_matches('.').replace('.', "/"); + file.filename = format!("{}/{}", ns_path, file.filename); + file + } + + /// Generate an arg-schema file for a reducer from a mounted submodule. + /// Filename goes in a subdirectory named after the namespace prefix: + /// e.g. `lib/library_reducer_reducer.ts` for prefix `"lib/"`. + fn generate_mounted_reducer_file(&self, owning_def: &ModuleDef, prefix: &str, reducer: &ReducerDef) -> OutputFile { + let mut file = self.generate_reducer_file(owning_def, reducer); + let ns_path = prefix.trim_end_matches('/').replace('/', "/"); + file.filename = format!("{}/{}", ns_path, file.filename); + file + } + + /// Generate an arg-schema file for a procedure from a mounted submodule. + fn generate_mounted_procedure_file(&self, owning_def: &ModuleDef, prefix: &str, procedure: &ProcedureDef) -> OutputFile { + let mut file = self.generate_procedure_file(owning_def, procedure); + let ns_path = prefix.trim_end_matches('/').replace('/', "/"); + file.filename = format!("{}/{}", ns_path, file.filename); + file + } } diff --git a/crates/codegen/src/typescript.rs b/crates/codegen/src/typescript.rs index 114246cf443..b8278a49436 100644 --- a/crates/codegen/src/typescript.rs +++ b/crates/codegen/src/typescript.rs @@ -6,7 +6,7 @@ use crate::{CodegenOptions, OutputFile}; use super::util::{collect_case, print_auto_generated_file_comment, type_ref_name}; -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::fmt::{self, Write}; use std::iter; use std::ops::Deref; @@ -15,7 +15,8 @@ use convert_case::{Case, Casing}; use spacetimedb_lib::sats::layout::PrimitiveType; use spacetimedb_lib::sats::AlgebraicTypeRef; use spacetimedb_primitives::ColId; -use spacetimedb_schema::def::{ConstraintDef, IndexDef, ModuleDef, ReducerDef, TableDef, TypeDef}; +use spacetimedb_lib::db::raw_def::v9::TableAccess; +use spacetimedb_schema::def::{ConstraintDef, IndexDef, ModuleDef, ProcedureDef, ReducerDef, TableDef, TypeDef, ViewDef}; use spacetimedb_schema::identifier::Identifier; use spacetimedb_schema::reducer_name::ReducerName; use spacetimedb_schema::schema::TableSchema; @@ -189,6 +190,67 @@ impl Lang for TypeScript { writeln!(out, "import {table_name_pascalcase}Row from \"./{table_module_name}\";"); } + // Import row types for mounted namespace tables (public only) + let ns_tables: Vec<_> = module + .all_tables_with_prefix() + .into_iter() + .filter(|(prefix, _, table)| !prefix.is_empty() && table.table_access == TableAccess::Public) + .collect(); + let ns_views: Vec<_> = module + .all_views_with_prefix() + .into_iter() + .filter(|(prefix, _, _)| !prefix.is_empty()) + .collect(); + let ns_reducers: Vec<_> = module + .all_reducers_with_prefix() + .into_iter() + .filter(|(prefix, _, reducer)| !prefix.is_empty() && !reducer.visibility.is_private()) + .collect(); + let ns_procedures: Vec<_> = module + .all_procedures_with_prefix() + .into_iter() + .filter(|(prefix, _, procedure)| !prefix.is_empty() && !procedure.visibility.is_private()) + .collect(); + if !ns_tables.is_empty() || !ns_views.is_empty() { + writeln!(out); + writeln!(out, "// Import namespace table schema definitions"); + for (prefix, _, table) in &ns_tables { + let ns_path = mounted_ns_path(prefix); + let file_stem = table_module_name(&table.accessor_name); + let row_type = mounted_row_type_name(prefix, table.accessor_name.deref()); + writeln!(out, "import {row_type}Row from \"./{ns_path}/{file_stem}\";"); + } + for (prefix, _, view) in &ns_views { + let ns_path = mounted_ns_path(prefix); + let file_stem = table_module_name(&view.accessor_name); + let row_type = mounted_row_type_name(prefix, view.accessor_name.deref()); + writeln!(out, "import {row_type}Row from \"./{ns_path}/{file_stem}\";"); + } + } + if !ns_reducers.is_empty() { + writeln!(out); + writeln!(out, "// Import namespace reducer arg schemas"); + for (prefix, _, reducer) in &ns_reducers { + if !is_reducer_invokable(reducer) { + continue; + } + let ns_path = mounted_fn_ns_path(prefix); + let module_name = reducer_module_name(&reducer.accessor_name); + let args_type = mounted_reducer_args_type_name(prefix, &reducer.accessor_name); + writeln!(out, "import {args_type} from \"./{ns_path}/{module_name}\";"); + } + } + if !ns_procedures.is_empty() { + writeln!(out); + writeln!(out, "// Import namespace procedure arg schemas"); + for (prefix, _, procedure) in &ns_procedures { + let ns_path = mounted_fn_ns_path(prefix); + let module_name = procedure_module_name(&procedure.accessor_name); + let args_type = mounted_procedure_args_type_name(prefix, &procedure.accessor_name); + writeln!(out, "import * as {args_type} from \"./{ns_path}/{module_name}\";"); + } + } + writeln!(out); writeln!(out, "/** Type-only namespace exports for generated type groups. */"); @@ -205,7 +267,7 @@ impl Lang for TypeScript { module, out, type_ref, - &table.name, + table.name.deref(), iter_indexes(table), iter_constraints(table), table.is_event, @@ -218,10 +280,32 @@ impl Lang for TypeScript { let view_name_pascalcase = view.accessor_name.deref().to_case(Case::Pascal); writeln!(out, "{}: __table({{", view.accessor_name); out.indent(1); - write_table_opts(module, out, type_ref, &view.name, iter::empty(), iter::empty(), false); + write_table_opts(module, out, type_ref, view.name.deref(), iter::empty(), iter::empty(), false); out.dedent(1); writeln!(out, "}}, {}Row),", view_name_pascalcase); } + // Namespace tables from mounted submodules + for (prefix, owning_def, table) in &ns_tables { + let source_name = mounted_source_name(prefix, table.accessor_name.deref()); + let row_type = mounted_row_type_name(prefix, table.accessor_name.deref()); + let type_ref = table.product_type_ref; + writeln!(out, "\"{source_name}\": __table({{"); + out.indent(1); + write_table_opts(owning_def, out, type_ref, &source_name, iter_indexes(table), iter_constraints(table), table.is_event); + out.dedent(1); + writeln!(out, "}}, {row_type}Row),"); + } + // Namespace views from mounted submodules + for (prefix, owning_def, view) in &ns_views { + let source_name = mounted_source_name(prefix, view.accessor_name.deref()); + let row_type = mounted_row_type_name(prefix, view.accessor_name.deref()); + let type_ref = view.product_type_ref; + writeln!(out, "\"{source_name}\": __table({{"); + out.indent(1); + write_table_opts(owning_def, out, type_ref, &source_name, iter::empty(), iter::empty(), false); + out.dedent(1); + writeln!(out, "}}, {row_type}Row),"); + } out.dedent(1); writeln!(out, "}});"); @@ -237,6 +321,14 @@ impl Lang for TypeScript { let args_type = reducer_args_type_name(&reducer.accessor_name); writeln!(out, "__reducerSchema(\"{}\", {}),", reducer.name, args_type); } + for (prefix, _, reducer) in &ns_reducers { + if !is_reducer_invokable(reducer) { + continue; + } + let wire_name = format!("{}{}", prefix, reducer.name); + let args_type = mounted_reducer_args_type_name(prefix, &reducer.accessor_name); + writeln!(out, "__reducerSchema(\"{wire_name}\", {args_type}),"); + } out.dedent(1); writeln!(out, ");"); @@ -255,6 +347,11 @@ impl Lang for TypeScript { procedure.name, ); } + for (prefix, _, procedure) in &ns_procedures { + let wire_name = format!("{}{}", prefix, procedure.name); + let args_type = mounted_procedure_args_type_name(prefix, &procedure.accessor_name); + writeln!(out, "__procedureSchema(\"{wire_name}\", {args_type}.params, {args_type}.returnType),"); + } out.dedent(1); writeln!(out, ");"); @@ -285,25 +382,84 @@ impl Lang for TypeScript { writeln!(out); writeln!(out, "/** The tables available in this remote SpacetimeDB module. Each table reference doubles as a query builder. */"); - writeln!( - out, - "export const tables: __QueryBuilder = __makeQueryBuilder(tablesSchema.schemaType);" - ); + if ns_tables.is_empty() && ns_views.is_empty() { + writeln!( + out, + "export const tables: __QueryBuilder = __makeQueryBuilder(tablesSchema.schemaType);" + ); + } else { + writeln!(out, "const _qb = __makeQueryBuilder(tablesSchema.schemaType);"); + writeln!(out, "export const tables = {{"); + out.indent(1); + // Root tables + for table in iter_tables(module, options.visibility) { + let key = table.accessor_name.deref(); + writeln!(out, "{key}: _qb.{key},"); + } + // Root views + for view in iter_views(module) { + let key = view.accessor_name.deref(); + writeln!(out, "{key}: _qb.{key},"); + } + // Build and emit namespace tree + let tree = build_ns_tree(&ns_tables, &ns_views); + emit_ns_tree(out, &tree); + out.dedent(1); + writeln!(out, "}} as const;"); + } writeln!(out); writeln!(out, "/** The reducers available in this remote SpacetimeDB module. */"); - writeln!( - out, - "export const reducers = __convertToAccessorMap(reducersSchema.reducersType.reducers);" - ); + if ns_reducers.is_empty() { + writeln!( + out, + "export const reducers = __convertToAccessorMap(reducersSchema.reducersType.reducers);" + ); + } else { + writeln!( + out, + "const _reducers = __convertToAccessorMap(reducersSchema.reducersType.reducers);" + ); + writeln!(out, "export const reducers = {{"); + out.indent(1); + for reducer in iter_reducers(module, options.visibility) { + if !is_reducer_invokable(reducer) { + continue; + } + let key = reducer.accessor_name.deref().to_case(Case::Camel); + writeln!(out, "{key}: _reducers.{key},"); + } + let tree = build_reducer_ns_tree(&ns_reducers); + emit_fn_ns_tree(out, "_reducers", &tree); + out.dedent(1); + writeln!(out, "}} as const;"); + } + writeln!(out); writeln!( out, "/** The procedures available in this remote SpacetimeDB module. */" ); - writeln!( - out, - "export const procedures = __convertToAccessorMap(proceduresSchema.procedures);" - ); + if ns_procedures.is_empty() { + writeln!( + out, + "export const procedures = __convertToAccessorMap(proceduresSchema.procedures);" + ); + } else { + writeln!( + out, + "const _procedures = __convertToAccessorMap(proceduresSchema.procedures);" + ); + writeln!(out, "export const procedures = {{"); + out.indent(1); + for procedure in iter_procedures(module, options.visibility) { + let key = procedure.accessor_name.deref().to_case(Case::Camel); + writeln!(out, "{key}: _procedures.{key},"); + } + let tree = build_procedure_ns_tree(&ns_procedures); + emit_fn_ns_tree(out, "_procedures", &tree); + out.dedent(1); + writeln!(out, "}} as const;"); + } // Write type aliases for EventContext, ReducerEventContext, SubscriptionEventContext, ErrorContext writeln!(out); @@ -836,13 +992,13 @@ fn write_table_opts<'a>( module: &ModuleDef, out: &mut Indenter, type_ref: AlgebraicTypeRef, - name: &Identifier, + name: &str, indexes: impl Iterator, constraints: impl Iterator, is_event: bool, ) { let product_def = module.typespace_for_generate()[type_ref].as_product().unwrap(); - writeln!(out, "name: '{}',", name.deref()); + writeln!(out, "name: '{}',", name); writeln!(out, "indexes: ["); out.indent(1); for index_def in indexes { @@ -1099,6 +1255,22 @@ fn table_module_name(table_name: &Identifier) -> String { table_name.deref().to_case(Case::Snake) + "_table" } +/// Combined accessor name for a mounted namespace table/view. +/// E.g. namespace="alias.", accessor_name="tableName" → "aliasTableName" +/// Source name (wire name) for a mounted namespace table/view. +/// E.g. namespace="alias.", accessor_name="tableName" → "alias.tableName" +fn mounted_source_name(namespace: &str, accessor_name: &str) -> String { + format!("{}{}", namespace, accessor_name) +} + +/// TypeScript import symbol for a mounted namespace table/view row type. +/// Uses `_` separator to avoid colliding with root tables that share the same PascalCase prefix. +/// E.g. namespace="lib.", accessor_name="library_table" → "Lib_LibraryTable" +fn mounted_row_type_name(namespace: &str, accessor_name: &str) -> String { + let ns_part = namespace.trim_end_matches('.').replace('.', "_").to_case(Case::Pascal); + format!("{}_{}", ns_part, accessor_name.to_case(Case::Pascal)) +} + fn reducer_args_type_name(reducer_name: &ReducerName) -> String { reducer_name.deref().to_case(Case::Pascal) + "Reducer" } @@ -1115,6 +1287,158 @@ fn procedure_module_name(procedure_name: &Identifier) -> String { procedure_name.deref().to_case(Case::Snake) + "_procedure" } +/// Converts a dot-terminated table namespace like `"lib."` or `"lib.sublib."` to a path like `"lib"` or `"lib/sublib"`. +fn mounted_ns_path(namespace: &str) -> String { + namespace.trim_end_matches('.').replace('.', "/") +} + +/// Converts a slash-terminated reducer/procedure namespace like `"lib/"` or `"lib/sublib/"` to a path like `"lib"` or `"lib/sublib"`. +fn mounted_fn_ns_path(prefix: &str) -> String { + prefix.trim_end_matches('/').to_string() +} + +/// TypeScript import symbol for a mounted namespace reducer/procedure. +/// Uses `_` separator to avoid colliding with root reducers/procedures sharing the same prefix. +/// E.g. prefix="lib/", accessor_name="library_reducer" → "Lib_LibraryReducer" +fn mounted_fn_type_name(prefix: &str, accessor_name: &str) -> String { + let ns_part = prefix.trim_end_matches('/').replace('/', "_").to_case(Case::Pascal); + format!("{}_{}", ns_part, accessor_name.to_case(Case::Pascal)) +} + +fn mounted_reducer_args_type_name(prefix: &str, accessor_name: &ReducerName) -> String { + mounted_fn_type_name(prefix, accessor_name.deref()) + "Reducer" +} + +fn mounted_procedure_args_type_name(prefix: &str, accessor_name: &Identifier) -> String { + mounted_fn_type_name(prefix, accessor_name.deref()) + "Procedure" +} + +/// A node in the recursive namespace tree used to emit the nested `tables` export. +struct NsTree { + /// (combined_qb_key, local_ts_key) for table/view entries at this level. + entries: Vec<(String, String)>, + /// Child namespace nodes keyed by namespace segment. + children: BTreeMap, +} + +impl NsTree { + fn new() -> Self { + NsTree { entries: Vec::new(), children: BTreeMap::new() } + } + + fn insert(&mut self, path_segs: &[&str], combined_qb_key: String, local_ts_key: String) { + if path_segs.is_empty() { + self.entries.push((combined_qb_key, local_ts_key)); + } else { + self.children + .entry(path_segs[0].to_string()) + .or_insert_with(NsTree::new) + .insert(&path_segs[1..], combined_qb_key, local_ts_key); + } + } +} + +/// Build the namespace tree from all mounted tables and views. +fn build_ns_tree<'a>( + ns_tables: &[(String, &'a ModuleDef, &'a TableDef)], + ns_views: &[(String, &'a ModuleDef, &'a ViewDef)], +) -> BTreeMap { + let mut tree: BTreeMap = BTreeMap::new(); + for (prefix, _, table) in ns_tables { + let source_name = mounted_source_name(prefix, table.accessor_name.deref()); + let local = table.accessor_name.deref().to_case(Case::Camel); + // prefix like "lib." → segments ["lib"], or "lib.sublib." → ["lib", "sublib"] + let segs: Vec<&str> = prefix.trim_end_matches('.').split('.').collect(); + if let Some((first, rest)) = segs.split_first() { + tree.entry(first.to_string()) + .or_insert_with(NsTree::new) + .insert(rest, source_name, local); + } + } + for (prefix, _, view) in ns_views { + let source_name = mounted_source_name(prefix, view.accessor_name.deref()); + let local = view.accessor_name.deref().to_case(Case::Camel); + let segs: Vec<&str> = prefix.trim_end_matches('.').split('.').collect(); + if let Some((first, rest)) = segs.split_first() { + tree.entry(first.to_string()) + .or_insert_with(NsTree::new) + .insert(rest, source_name, local); + } + } + tree +} + +/// Recursively emit the namespace tree as nested TypeScript object blocks. +fn emit_ns_tree(out: &mut Indenter, tree: &BTreeMap) { + for (ns, node) in tree { + writeln!(out, "{ns}: {{"); + out.indent(1); + for (qb_key, local_key) in &node.entries { + writeln!(out, "{local_key}: _qb[\"{qb_key}\"],"); + } + emit_ns_tree(out, &node.children); + out.dedent(1); + writeln!(out, "}},"); + } +} + +/// Build namespace tree for mounted reducers (uses `/` path separator). +/// `flat_key` matches SDK's `accessorName = toCamelCase(wireName)`. +/// SDK toCamelCase only splits on `_`/`-`, so `/` is kept verbatim: +/// `"lib/library_reducer"` → `"lib/libraryReducer"`. Bracket notation is required. +fn build_reducer_ns_tree<'a>( + ns_reducers: &[(String, &'a ModuleDef, &'a ReducerDef)], +) -> BTreeMap { + let mut tree: BTreeMap = BTreeMap::new(); + for (prefix, _, reducer) in ns_reducers { + if !is_reducer_invokable(reducer) { + continue; + } + let flat_key = format!("{}{}", prefix, reducer.accessor_name.deref().to_case(Case::Camel)); + let local = reducer.accessor_name.deref().to_case(Case::Camel); + let segs: Vec<&str> = prefix.trim_end_matches('/').split('/').collect(); + if let Some((first, rest)) = segs.split_first() { + tree.entry(first.to_string()) + .or_insert_with(NsTree::new) + .insert(rest, flat_key, local); + } + } + tree +} + +/// Build namespace tree for mounted procedures (uses `/` path separator). +fn build_procedure_ns_tree<'a>( + ns_procedures: &[(String, &'a ModuleDef, &'a ProcedureDef)], +) -> BTreeMap { + let mut tree: BTreeMap = BTreeMap::new(); + for (prefix, _, procedure) in ns_procedures { + let flat_key = format!("{}{}", prefix, procedure.accessor_name.deref().to_case(Case::Camel)); + let local = procedure.accessor_name.deref().to_case(Case::Camel); + let segs: Vec<&str> = prefix.trim_end_matches('/').split('/').collect(); + if let Some((first, rest)) = segs.split_first() { + tree.entry(first.to_string()) + .or_insert_with(NsTree::new) + .insert(rest, flat_key, local); + } + } + tree +} + +/// Emit a namespace tree for reducers/procedures using bracket notation. +/// Flat keys contain `/` (e.g. `"lib/libraryReducer"`) so dot notation is invalid JS. +fn emit_fn_ns_tree(out: &mut Indenter, map_var: &str, tree: &BTreeMap) { + for (ns, node) in tree { + writeln!(out, "{ns}: {{"); + out.indent(1); + for (flat_key, local_key) in &node.entries { + writeln!(out, "{local_key}: {map_var}[\"{flat_key}\"],"); + } + emit_fn_ns_tree(out, map_var, &node.children); + out.dedent(1); + writeln!(out, "}},"); + } +} + pub fn type_name(module: &ModuleDef, ty: &AlgebraicTypeUse) -> String { let mut s = String::new(); write_type(module, &mut s, ty, None, None).unwrap();