Skip to content

Commit 3b6080d

Browse files
committed
TypeScript: codegen and SDK support for module mounts
1 parent be4574a commit 3b6080d

4 files changed

Lines changed: 471 additions & 24 deletions

File tree

crates/bindings-typescript/src/sdk/db_connection_impl.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,11 @@ import type {
3030
} from './message_types.ts';
3131
import type { ReducerEvent } from './reducer_event.ts';
3232
import { type UntypedRemoteModule } from './spacetime_module.ts';
33-
import { makeQueryBuilder } from '../lib/query';
33+
import {
34+
makeFromBuilder,
35+
makeQueryBuilder,
36+
type SubscriptionFromBuilder,
37+
} from '../lib/query';
3438
import {
3539
type TableCache,
3640
type Operation,
@@ -52,6 +56,7 @@ import type {
5256
} from './reducers.ts';
5357
import type { ClientDbView } from './db_view.ts';
5458
import type { RowType, UntypedTableDef } from '../lib/table.ts';
59+
import type { UntypedSchemaDef } from '../lib/schema';
5560
import type { ProceduresView } from './procedures.ts';
5661
import type { Values } from '../lib/type_util.ts';
5762
import type { TransactionUpdate } from './client_api/types.ts';
@@ -457,6 +462,10 @@ export class DbConnectionImpl<RemoteModule extends UntypedRemoteModule>
457462
return makeQueryBuilder({ tables: this.#remoteModule.tables } as any);
458463
}
459464

465+
getFromBuilder<SchemaDef extends UntypedSchemaDef>(): SubscriptionFromBuilder<SchemaDef> {
466+
return makeFromBuilder<SchemaDef>(this.#remoteModule.tables as SchemaDef['tables']);
467+
}
468+
460469
registerSubscription(
461470
handle: SubscriptionHandleImpl<RemoteModule>,
462471
handleEmitter: EventEmitter<
@@ -503,8 +512,10 @@ export class DbConnectionImpl<RemoteModule extends UntypedRemoteModule>
503512
const rows: Operation[] = [];
504513

505514
const deserializeRow = this.#rowDeserializers[tableName];
506-
const { primaryKeyColName, primaryKeyColType } =
507-
this.#rowIdMetadata[tableName];
515+
if (!deserializeRow) return [];
516+
const rowIdInfo = this.#rowIdMetadata[tableName];
517+
if (!rowIdInfo) return [];
518+
const { primaryKeyColName, primaryKeyColType } = rowIdInfo;
508519
let previousOffset = 0;
509520
while (reader.remaining > 0) {
510521
const row = deserializeRow(reader);
@@ -793,6 +804,7 @@ export class DbConnectionImpl<RemoteModule extends UntypedRemoteModule>
793804
// Get table information for the table being updated
794805
const tableName = tableUpdate.tableName;
795806
const tableDef = this.#sourceNameToTableDef[tableName];
807+
if (!tableDef) continue;
796808
const table = this.clientCache.getOrCreateTable(tableDef);
797809
const newCallbacks = table.applyOperations(
798810
tableUpdate.operations as Operation<

crates/bindings-typescript/src/sdk/subscription_builder_impl.ts

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,20 @@ import type {
66
} from './event_context';
77
import { EventEmitter } from './event_emitter';
88
import type { UntypedRemoteModule } from './spacetime_module';
9-
import { isRowTypedQuery, toSql, type RowTypedQuery } from '../lib/query';
9+
import {
10+
isRowTypedQuery,
11+
toSql,
12+
type SubscriptionFromBuilder,
13+
type RowTypedQuery,
14+
} from '../lib/query';
15+
import type { UntypedSchemaDef } from '../lib/schema';
1016
import type { Values } from '../lib/type_util';
1117

1218
export class SubscriptionBuilderImpl<RemoteModule extends UntypedRemoteModule> {
1319
#onApplied?: (ctx: SubscriptionEventContextInterface<RemoteModule>) => void =
1420
undefined;
1521
#onError?: (ctx: ErrorContextInterface<RemoteModule>) => void = undefined;
22+
#pendingQueries: Array<string | RowTypedQuery<any, any>> = [];
1623
constructor(private db: DbConnectionImpl<RemoteModule>) {}
1724

1825
/**
@@ -64,6 +71,32 @@ export class SubscriptionBuilderImpl<RemoteModule extends UntypedRemoteModule> {
6471
return this;
6572
}
6673

74+
/**
75+
* Accumulates a query for a later `subscribe()` call.
76+
* Queries added via `addQuery` and queries passed directly to `subscribe` are mutually exclusive —
77+
* call `subscribe()` with no arguments to send all accumulated queries.
78+
*
79+
* @param queryFn - Receives `{ from }`, where `from` exposes all tables (root and namespaced).
80+
* @returns The current `SubscriptionBuilder` instance for chaining.
81+
*
82+
* @example
83+
* ```ts
84+
* conn.subscriptionBuilder()
85+
* .addQuery(q => q.from.players.build())
86+
* .addQuery(q => q.from.inventory.items.build())
87+
* .subscribe();
88+
* ```
89+
*/
90+
addQuery(
91+
queryFn: (q: {
92+
from: SubscriptionFromBuilder<RemoteModule & UntypedSchemaDef>;
93+
}) => RowTypedQuery<any, any>
94+
): this {
95+
const from = this.db.getFromBuilder<RemoteModule & UntypedSchemaDef>();
96+
this.#pendingQueries.push(queryFn({ from }));
97+
return this;
98+
}
99+
67100
/**
68101
* Subscribe to a single query. The results of the query will be merged into the client
69102
* cache and deduplicated on the client.
@@ -80,6 +113,7 @@ export class SubscriptionBuilderImpl<RemoteModule extends UntypedRemoteModule> {
80113
* subscription.unsubscribe();
81114
* ```
82115
*/
116+
subscribe(): SubscriptionHandleImpl<RemoteModule>;
83117
subscribe(
84118
query_sql: string | RowTypedQuery<any, any>
85119
): SubscriptionHandleImpl<RemoteModule>;
@@ -92,14 +126,22 @@ export class SubscriptionBuilderImpl<RemoteModule extends UntypedRemoteModule> {
92126
) => RowTypedQuery<any, any> | RowTypedQuery<any, any>[]
93127
): SubscriptionHandleImpl<RemoteModule>;
94128
subscribe(
95-
query_sql:
129+
query_sql?:
96130
| string
97131
| RowTypedQuery<any, any>
98132
| Array<string | RowTypedQuery<any, any>>
99133
| ((tables: any) => RowTypedQuery<any, any> | RowTypedQuery<any, any>[])
100134
): SubscriptionHandleImpl<RemoteModule> {
101135
let queries: Array<string | RowTypedQuery<any, any>>;
102-
if (typeof query_sql === 'function') {
136+
if (query_sql === undefined) {
137+
if (this.#pendingQueries.length === 0) {
138+
throw new Error(
139+
'subscriptionBuilder().subscribe() called with no queries; use addQuery() first or pass a query argument'
140+
);
141+
}
142+
queries = this.#pendingQueries;
143+
this.#pendingQueries = [];
144+
} else if (typeof query_sql === 'function') {
103145
const tablesMap = this.db.getTablesMap?.();
104146
const result = query_sql(tablesMap);
105147
queries = Array.isArray(result) ? result : [result];

crates/codegen/src/lib.rs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use spacetimedb_lib::db::raw_def::v9::TableAccess;
12
use spacetimedb_schema::def::{ModuleDef, ProcedureDef, ReducerDef, TableDef, TypeDef, ViewDef};
23
use spacetimedb_schema::schema::{Schema, TableSchema};
34
mod code_indenter;
@@ -33,10 +34,34 @@ pub fn generate(module: &ModuleDef, lang: &dyn Lang, options: &CodegenOptions) -
3334
itertools::chain!(
3435
util::iter_tables(module, options.visibility).map(|tbl| lang.generate_table_file(module, tbl)),
3536
module.views().map(|view| lang.generate_view_file(module, view)),
37+
// Public tables from mounted submodules
38+
module
39+
.all_tables_with_prefix()
40+
.into_iter()
41+
.filter(|(prefix, _, table)| !prefix.is_empty() && table.table_access == TableAccess::Public)
42+
.map(|(prefix, owning_def, table)| lang.generate_mounted_table_file(owning_def, &prefix, table)),
43+
// Views from mounted submodules (views are currently always public)
44+
module
45+
.all_views_with_prefix()
46+
.into_iter()
47+
.filter(|(prefix, _, _)| !prefix.is_empty())
48+
.map(|(prefix, owning_def, view)| lang.generate_mounted_view_file(owning_def, &prefix, view)),
3649
module.types().flat_map(|typ| lang.generate_type_files(module, typ)),
3750
util::iter_reducers(module, options.visibility).map(|reducer| lang.generate_reducer_file(module, reducer)),
3851
util::iter_procedures(module, options.visibility)
3952
.map(|procedure| lang.generate_procedure_file(module, procedure)),
53+
// Reducers from mounted submodules
54+
module
55+
.all_reducers_with_prefix()
56+
.into_iter()
57+
.filter(|(prefix, _, reducer)| !prefix.is_empty() && !reducer.visibility.is_private())
58+
.map(|(prefix, owning_def, reducer)| lang.generate_mounted_reducer_file(owning_def, &prefix, reducer)),
59+
// Procedures from mounted submodules
60+
module
61+
.all_procedures_with_prefix()
62+
.into_iter()
63+
.filter(|(prefix, _, procedure)| !prefix.is_empty() && !procedure.visibility.is_private())
64+
.map(|(prefix, owning_def, procedure)| lang.generate_mounted_procedure_file(owning_def, &prefix, procedure)),
4065
lang.generate_global_files(module, options),
4166
)
4267
.collect()
@@ -68,4 +93,48 @@ pub trait Lang {
6893
.expect("Failed to generate table due to validation errors");
6994
self.generate_table_file_from_schema(module, &tbl, schema)
7095
}
96+
97+
/// Generate a row-type file for a public table from a mounted submodule.
98+
/// Uses `owning_def`'s typespace for type resolution.
99+
/// Filename goes in a subdirectory named after the namespace:
100+
/// e.g. `alias/table_name_table.ts` for namespace `"alias."`, table `tableName`.
101+
fn generate_mounted_table_file(&self, owning_def: &ModuleDef, namespace: &str, table: &TableDef) -> OutputFile {
102+
let schema = TableSchema::from_module_def(owning_def, table, (), 0.into())
103+
.validated()
104+
.expect("Failed to generate mounted table file");
105+
let mut file = self.generate_table_file_from_schema(owning_def, table, schema);
106+
let ns_path = namespace.trim_end_matches('.').replace('.', "/");
107+
file.filename = format!("{}/{}", ns_path, file.filename);
108+
file
109+
}
110+
111+
/// Generate a row-type file for a view from a mounted submodule.
112+
fn generate_mounted_view_file(&self, owning_def: &ModuleDef, namespace: &str, view: &ViewDef) -> OutputFile {
113+
let tbl = TableDef::from(view.clone());
114+
let schema = TableSchema::from_view_def_for_codegen(owning_def, view)
115+
.validated()
116+
.expect("Failed to generate mounted view file");
117+
let mut file = self.generate_table_file_from_schema(owning_def, &tbl, schema);
118+
let ns_path = namespace.trim_end_matches('.').replace('.', "/");
119+
file.filename = format!("{}/{}", ns_path, file.filename);
120+
file
121+
}
122+
123+
/// Generate an arg-schema file for a reducer from a mounted submodule.
124+
/// Filename goes in a subdirectory named after the namespace prefix:
125+
/// e.g. `lib/library_reducer_reducer.ts` for prefix `"lib/"`.
126+
fn generate_mounted_reducer_file(&self, owning_def: &ModuleDef, prefix: &str, reducer: &ReducerDef) -> OutputFile {
127+
let mut file = self.generate_reducer_file(owning_def, reducer);
128+
let ns_path = prefix.trim_end_matches('/').replace('/', "/");
129+
file.filename = format!("{}/{}", ns_path, file.filename);
130+
file
131+
}
132+
133+
/// Generate an arg-schema file for a procedure from a mounted submodule.
134+
fn generate_mounted_procedure_file(&self, owning_def: &ModuleDef, prefix: &str, procedure: &ProcedureDef) -> OutputFile {
135+
let mut file = self.generate_procedure_file(owning_def, procedure);
136+
let ns_path = prefix.trim_end_matches('/').replace('/', "/");
137+
file.filename = format!("{}/{}", ns_path, file.filename);
138+
file
139+
}
71140
}

0 commit comments

Comments
 (0)