diff --git a/Cargo.lock b/Cargo.lock index 6ed83e61eb0..fa76aebc3ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2078,6 +2078,16 @@ dependencies = [ "serde", ] +[[package]] +name = "event-table-client" +version = "2.0.0" +dependencies = [ + "anyhow", + "env_logger 0.10.2", + "spacetimedb-sdk", + "test-counter", +] + [[package]] name = "fallible-iterator" version = "0.2.0" @@ -6870,6 +6880,13 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sdk-test-event-table-module" +version = "0.1.0" +dependencies = [ + "spacetimedb 2.0.0", +] + [[package]] name = "sdk-test-module" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 4c94938a7db..3f7e3e5679e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,11 +49,13 @@ members = [ "modules/sdk-test-connect-disconnect", "modules/sdk-test-procedure", "modules/sdk-test-view", + "modules/sdk-test-event-table", "sdks/rust/tests/test-client", "sdks/rust/tests/test-counter", "sdks/rust/tests/connect_disconnect_client", "sdks/rust/tests/procedure-client", "sdks/rust/tests/view-client", + "sdks/rust/tests/event-table-client", "tools/ci", "tools/upgrade-version", "tools/license-check", diff --git a/crates/bindings-macro/src/lib.rs b/crates/bindings-macro/src/lib.rs index 52b13f00267..50da833bae1 100644 --- a/crates/bindings-macro/src/lib.rs +++ b/crates/bindings-macro/src/lib.rs @@ -61,6 +61,7 @@ mod sym { symbol!(unique); symbol!(update); symbol!(default); + symbol!(event); symbol!(u8); symbol!(i8); diff --git a/crates/bindings-macro/src/table.rs b/crates/bindings-macro/src/table.rs index 9d8143fce6f..b93f907ec90 100644 --- a/crates/bindings-macro/src/table.rs +++ b/crates/bindings-macro/src/table.rs @@ -19,6 +19,7 @@ pub(crate) struct TableArgs { scheduled: Option, name: Ident, indices: Vec, + event: Option, } enum TableAccess { @@ -71,6 +72,7 @@ impl TableArgs { let mut scheduled = None; let mut name = None; let mut indices = Vec::new(); + let mut event = None; syn::meta::parser(|meta| { match_meta!(match meta { sym::public => { @@ -91,6 +93,10 @@ impl TableArgs { check_duplicate(&scheduled, &meta)?; scheduled = Some(ScheduledArg::parse_meta(meta)?); } + sym::event => { + check_duplicate(&event, &meta)?; + event = Some(meta.path.span()); + } }); Ok(()) }) @@ -107,6 +113,7 @@ impl TableArgs { scheduled, name, indices, + event, }) } } @@ -852,6 +859,18 @@ pub(crate) fn table_impl(mut args: TableArgs, item: &syn::DeriveInput) -> syn::R ); let table_access = args.access.iter().map(|acc| acc.to_value()); + let is_event = args.event.iter().map(|_| { + quote!( + const IS_EVENT: bool = true; + ) + }); + let can_be_lookup_impl = if args.event.is_none() { + quote! { + impl spacetimedb::query_builder::CanBeLookupTable for #original_struct_ident {} + } + } else { + quote! {} + }; let unique_col_ids = unique_columns.iter().map(|col| col.index); let primary_col_id = primary_key_column.clone().into_iter().map(|col| col.index); let sequence_col_ids = sequenced_columns.iter().map(|col| col.index); @@ -977,6 +996,7 @@ pub(crate) fn table_impl(mut args: TableArgs, item: &syn::DeriveInput) -> syn::R const TABLE_NAME: &'static str = #table_name; // the default value if not specified is Private #(const TABLE_ACCESS: spacetimedb::table::TableAccess = #table_access;)* + #(#is_event)* const UNIQUE_COLUMNS: &'static [u16] = &[#(#unique_col_ids),*]; const INDEXES: &'static [spacetimedb::table::IndexDesc<'static>] = &[#(#index_descs),*]; #(const PRIMARY_KEY: Option = Some(#primary_col_id);)* @@ -1088,6 +1108,8 @@ pub(crate) fn table_impl(mut args: TableArgs, item: &syn::DeriveInput) -> syn::R } } + #can_be_lookup_impl + }; let table_query_handle_def = quote! { diff --git a/crates/bindings-typescript/src/lib/schema.ts b/crates/bindings-typescript/src/lib/schema.ts index e8bc6521322..98ef235646c 100644 --- a/crates/bindings-typescript/src/lib/schema.ts +++ b/crates/bindings-typescript/src/lib/schema.ts @@ -117,6 +117,7 @@ export function tableToSchema< }; }) as T['idxs'], tableDef, + ...(tableDef.isEvent ? { isEvent: true } : {}), }; } diff --git a/crates/bindings-typescript/src/lib/table.ts b/crates/bindings-typescript/src/lib/table.ts index 2ab05b9ea8f..8ade4e9b94a 100644 --- a/crates/bindings-typescript/src/lib/table.ts +++ b/crates/bindings-typescript/src/lib/table.ts @@ -117,6 +117,7 @@ export type UntypedTableDef = { indexes: readonly IndexOpts[]; constraints: readonly ConstraintOpts[]; tableDef: Infer; + isEvent?: boolean; }; /** @@ -179,6 +180,7 @@ export type TableOpts = { { [k: string]: RowBuilder }, ReturnType >; + event?: boolean; }; /** @@ -301,6 +303,7 @@ export function table>( public: isPublic = false, indexes: userIndexes = [], scheduled, + event: isEvent = false, } = opts; // 1. column catalogue + helpers @@ -476,7 +479,7 @@ export function table>( tableType: { tag: 'User' }, tableAccess: { tag: isPublic ? 'Public' : 'Private' }, defaultValues, - isEvent: false, + isEvent, }; }, idxs: {} as OptsIndices, diff --git a/crates/bindings-typescript/src/sdk/client_table.ts b/crates/bindings-typescript/src/sdk/client_table.ts index 2e4e102301b..b6cd2c1a56d 100644 --- a/crates/bindings-typescript/src/sdk/client_table.ts +++ b/crates/bindings-typescript/src/sdk/client_table.ts @@ -42,7 +42,7 @@ export type ClientTablePrimaryKeyMethods< ): void; }; -export type ClientTableMethods< +export type ClientTableInsertMethods< RemoteModule extends UntypedRemoteModule, TableName extends TableNamesOf, > = { @@ -66,7 +66,12 @@ export type ClientTableMethods< row: Prettify>> ) => void ): void; +}; +export type ClientTableDeleteMethods< + RemoteModule extends UntypedRemoteModule, + TableName extends TableNamesOf, +> = { /** * Registers a callback to be invoked when a row is deleted from the table. */ @@ -89,6 +94,12 @@ export type ClientTableMethods< ): void; }; +export type ClientTableMethods< + RemoteModule extends UntypedRemoteModule, + TableName extends TableNamesOf, +> = ClientTableInsertMethods & + ClientTableDeleteMethods; + /** * Table * @@ -107,6 +118,12 @@ export type ClientTable< > >; +type IsEventTable = TableDef extends { + isEvent: true; +} + ? true + : false; + type HasPrimaryKey = ColumnsHavePrimaryKey< TableDef['columns'] >; @@ -142,13 +159,21 @@ export type ClientTableCoreImplementable< /** * Core methods of ClientTable, without the indexes mixed in. - * Includes only staticly known methods. + * Includes only statically known methods. + * + * Event tables only expose insert callbacks (no delete or update), + * matching the Rust SDK's `EventTable` trait. */ export type ClientTableCore< RemoteModule extends UntypedRemoteModule, TableName extends TableNamesOf, > = ReadonlyTableMethods> & - ClientTableMethods & - (HasPrimaryKey> extends true - ? ClientTablePrimaryKeyMethods - : {}); + ClientTableInsertMethods & + (IsEventTable> extends true + ? {} + : ClientTableDeleteMethods & + (HasPrimaryKey< + TableDefForTableName + > extends true + ? ClientTablePrimaryKeyMethods + : {})); diff --git a/crates/bindings-typescript/src/sdk/db_connection_impl.ts b/crates/bindings-typescript/src/sdk/db_connection_impl.ts index 96f959f14a8..344a9dbc4a9 100644 --- a/crates/bindings-typescript/src/sdk/db_connection_impl.ts +++ b/crates/bindings-typescript/src/sdk/db_connection_impl.ts @@ -510,7 +510,8 @@ export class DbConnectionImpl return inserts.concat(deletes); } if (rows.tag === 'EventTable') { - // TODO: Decide how event tables should be merged into the cache. + // Event table rows are insert-only. The table cache handles skipping + // storage for event tables and only firing on_insert callbacks. return this.#parseRowList('insert', tableName, rows.value.events); } return []; diff --git a/crates/bindings-typescript/src/sdk/table_cache.ts b/crates/bindings-typescript/src/sdk/table_cache.ts index 947ce304c32..9f04ccad0c2 100644 --- a/crates/bindings-typescript/src/sdk/table_cache.ts +++ b/crates/bindings-typescript/src/sdk/table_cache.ts @@ -261,6 +261,23 @@ export class TableCacheImpl< ctx: EventContextInterface ): PendingCallback[] => { const pendingCallbacks: PendingCallback[] = []; + + // Event tables: fire on_insert callbacks but don't store rows in the cache. + if (this.tableDef.isEvent) { + for (const op of operations) { + if (op.type === 'insert') { + pendingCallbacks.push({ + type: 'insert', + table: this.tableDef.sourceName, + cb: () => { + this.emitter.emit('insert', ctx, op.row); + }, + }); + } + } + return pendingCallbacks; + } + // TODO: performance const hasPrimaryKey = Object.values(this.tableDef.columns).some( col => col.columnMetadata.isPrimaryKey === true diff --git a/crates/bindings/src/rt.rs b/crates/bindings/src/rt.rs index 4c2ec69254d..d2d690d14e1 100644 --- a/crates/bindings/src/rt.rs +++ b/crates/bindings/src/rt.rs @@ -740,7 +740,8 @@ pub fn register_table() { .inner .build_table(T::TABLE_NAME, product_type_ref) .with_type(TableType::User) - .with_access(T::TABLE_ACCESS); + .with_access(T::TABLE_ACCESS) + .with_event(T::IS_EVENT); for &col in T::UNIQUE_COLUMNS { table = table.with_unique_constraint(col); diff --git a/crates/bindings/src/table.rs b/crates/bindings/src/table.rs index 394d2bfdf8d..850f31e625a 100644 --- a/crates/bindings/src/table.rs +++ b/crates/bindings/src/table.rs @@ -128,6 +128,7 @@ pub trait TableInternal: Sized { const PRIMARY_KEY: Option = None; const SEQUENCES: &'static [u16]; const SCHEDULE: Option> = None; + const IS_EVENT: bool = false; /// Returns the ID of this table. fn table_id() -> TableId; diff --git a/crates/codegen/src/csharp.rs b/crates/codegen/src/csharp.rs index 7873b794848..a2fd23af34c 100644 --- a/crates/codegen/src/csharp.rs +++ b/crates/codegen/src/csharp.rs @@ -521,9 +521,14 @@ impl Lang for Csharp<'_> { let csharp_table_class_name = csharp_table_name.clone() + "Handle"; let table_type = type_ref_name(module, table.product_type_ref); + let base_class = if table.is_event { + "RemoteEventTableHandle" + } else { + "RemoteTableHandle" + }; writeln!( output, - "public sealed class {csharp_table_class_name} : RemoteTableHandle" + "public sealed class {csharp_table_class_name} : {base_class}" ); indented_block(output, |output| { writeln!( diff --git a/crates/codegen/src/rust.rs b/crates/codegen/src/rust.rs index a55abbbd54d..590368e794a 100644 --- a/crates/codegen/src/rust.rs +++ b/crates/codegen/src/rust.rs @@ -169,6 +169,51 @@ impl {accessor_trait} for super::RemoteTables {{ }} pub struct {insert_callback_id}(__sdk::CallbackId); + +" + ); + + if table.is_event { + // Event tables: implement the `EventTable` trait, which exposes only on-insert callbacks, + // not on-delete or on-update. + // on-update callbacks aren't meaningful for event tables, + // as they never have resident rows, so they can never update an existing row. + // on-delete callbacks are meaningful, but exactly equivalent to the on-insert callbacks, + // so not particularly useful. + // Also, don't emit unique index accessors: no resident rows means these would always be empty, + // so no reason to have them. + write!( + out, + " +impl<'ctx> __sdk::EventTable for {table_handle}<'ctx> {{ + type Row = {row_type}; + type EventContext = super::EventContext; + + fn count(&self) -> u64 {{ self.imp.count() }} + fn iter(&self) -> impl Iterator + '_ {{ self.imp.iter() }} + + type InsertCallbackId = {insert_callback_id}; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> {insert_callback_id} {{ + {insert_callback_id}(self.imp.on_insert(Box::new(callback))) + }} + + fn remove_on_insert(&self, callback: {insert_callback_id}) {{ + self.imp.remove_on_insert(callback.0) + }} +}} +" + ); + } else { + // Non-event tables: implement the `Table` trait, which exposes on-insert and on-delete callbacks. + // Also possibly implement `TableWithPrimrayKey`, which exposes on-update callbacks, + // and emit accessors for unique columns. + write!( + out, + " pub struct {delete_callback_id}(__sdk::CallbackId); impl<'ctx> __sdk::Table for {table_handle}<'ctx> {{ @@ -205,32 +250,15 @@ impl<'ctx> __sdk::Table for {table_handle}<'ctx> {{ }} }} " - ); - - out.delimited_block( - " -#[doc(hidden)] -pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { -", - |out| { - writeln!(out, "let _table = client_cache.get_or_make_table::<{row_type}>({table_name:?});"); - for (unique_field_ident, unique_field_type_use) in iter_unique_cols(module.typespace_for_generate(), &schema, product_def) { - let unique_field_name = unique_field_ident.deref().to_case(Case::Snake); - let unique_field_type = type_name(module, unique_field_type_use); - writeln!( - out, - "_table.add_unique_constraint::<{unique_field_type}>({unique_field_name:?}, |row| &row.{unique_field_name});", - ); - } - }, - "}", - ); + ); - if table.primary_key.is_some() { - let update_callback_id = table_name_pascalcase.clone() + "UpdateCallbackId"; - write!( - out, - " + if table.primary_key.is_some() { + // If the table has a primary key, implement the `TableWithPrimaryKey` trait + // to expose on-update callbacks. + let update_callback_id = table_name_pascalcase.clone() + "UpdateCallbackId"; + write!( + out, + " pub struct {update_callback_id}(__sdk::CallbackId); impl<'ctx> __sdk::TableWithPrimaryKey for {table_handle}<'ctx> {{ @@ -248,40 +276,22 @@ impl<'ctx> __sdk::TableWithPrimaryKey for {table_handle}<'ctx> {{ }} }} " - ); - } - - out.newline(); - - write!( - out, - " -#[doc(hidden)] -pub(super) fn parse_table_update( - raw_updates: __ws::v2::TableUpdate, -) -> __sdk::Result<__sdk::TableUpdate<{row_type}>> {{ - __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {{ - __sdk::InternalError::failed_parse( - \"TableUpdate<{row_type}>\", - \"TableUpdate\", - ).with_cause(e).into() - }}) -}} -" - ); + ); + } - for (unique_field_ident, unique_field_type_use) in - iter_unique_cols(module.typespace_for_generate(), &schema, product_def) - { - let unique_field_name = unique_field_ident.deref().to_case(Case::Snake); - let unique_field_name_pascalcase = unique_field_name.to_case(Case::Pascal); + // Emit unique index accessors for all of the table's unique fields. + for (unique_field_ident, unique_field_type_use) in + iter_unique_cols(module.typespace_for_generate(), &schema, product_def) + { + let unique_field_name = unique_field_ident.deref().to_case(Case::Snake); + let unique_field_name_pascalcase = unique_field_name.to_case(Case::Pascal); - let unique_constraint = table_name_pascalcase.clone() + &unique_field_name_pascalcase + "Unique"; - let unique_field_type = type_name(module, unique_field_type_use); + let unique_constraint = table_name_pascalcase.clone() + &unique_field_name_pascalcase + "Unique"; + let unique_field_type = type_name(module, unique_field_type_use); - write!( - out, - " + write!( + out, + " /// Access to the `{unique_field_name}` unique index on the table `{table_name}`, /// which allows point queries on the field of the same name /// via the [`{unique_constraint}::find`] method. @@ -312,12 +322,52 @@ pub(super) fn parse_table_update( }} }} " - ); + ); + } + + // TODO: expose non-unique indices. } - implement_query_table_accessor(table, out, &row_type).expect("failed to implement query table accessor"); + // Regardless of event-ness, emit `register_table` and `parse_table_update`. + out.delimited_block( + " +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { +", + |out| { + writeln!(out, "let _table = client_cache.get_or_make_table::<{row_type}>({table_name:?});"); + for (unique_field_ident, unique_field_type_use) in iter_unique_cols(module.typespace_for_generate(), &schema, product_def) { + let unique_field_name = unique_field_ident.deref().to_case(Case::Snake); + let unique_field_type = type_name(module, unique_field_type_use); + writeln!( + out, + "_table.add_unique_constraint::<{unique_field_type}>({unique_field_name:?}, |row| &row.{unique_field_name});", + ); + } + }, + "}", + ); - // TODO: expose non-unique indices. + out.newline(); + + write!( + out, + " +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate<{row_type}>> {{ + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {{ + __sdk::InternalError::failed_parse( + \"TableUpdate<{row_type}>\", + \"TableUpdate\", + ).with_cause(e).into() + }}) +}} +" + ); + + implement_query_table_accessor(table, out, &row_type).expect("failed to implement query table accessor"); OutputFile { filename: table_module_name(&table.name) + ".rs", @@ -728,7 +778,17 @@ impl __sdk::__query_builder::HasIxCols for {struct_name} {{ }} }} }}"# - ) + )?; + + // Event tables cannot be used as lookup tables in semijoins. + if !table.is_event { + writeln!( + out, + "\nimpl __sdk::__query_builder::CanBeLookupTable for {struct_name} {{}}" + )?; + } + + Ok(()) } pub fn implement_query_table_accessor(table: &TableDef, out: &mut impl Write, struct_name: &String) -> fmt::Result { @@ -1324,21 +1384,31 @@ impl __sdk::InModule for DbUpdate {{ ", |out| { for table in iter_tables(module, visibility) { - let with_updates = table - .primary_key - .map(|col| { - let pk_field = table.get_column(col).unwrap().name.deref().to_case(Case::Snake); - format!(".with_updates_by_pk(|row| &row.{pk_field})") - }) - .unwrap_or_default(); - let field_name = table_method_name(&table.name); - writeln!( - out, - "diff.{field_name} = cache.apply_diff_to_table::<{}>({:?}, &self.{field_name}){with_updates};", - type_ref_name(module, table.product_type_ref), - table.name.deref(), - ); + if table.is_event { + // Event tables bypass the client cache entirely. + // We construct an applied diff directly from the inserts, + // which will fire on_insert callbacks without storing rows. + writeln!( + out, + "diff.{field_name} = self.{field_name}.into_event_diff();", + ); + } else { + let with_updates = table + .primary_key + .map(|col| { + let pk_field = table.get_column(col).unwrap().name.deref().to_case(Case::Snake); + format!(".with_updates_by_pk(|row| &row.{pk_field})") + }) + .unwrap_or_default(); + + writeln!( + out, + "diff.{field_name} = cache.apply_diff_to_table::<{}>({:?}, &self.{field_name}){with_updates};", + type_ref_name(module, table.product_type_ref), + table.name.deref(), + ); + } } for view in iter_views(module) { let field_name = table_method_name(&view.name); diff --git a/crates/codegen/src/typescript.rs b/crates/codegen/src/typescript.rs index 59c80dfd8ff..32444a82ab6 100644 --- a/crates/codegen/src/typescript.rs +++ b/crates/codegen/src/typescript.rs @@ -260,6 +260,7 @@ impl Lang for TypeScript { &table.name, iter_indexes(table), iter_constraints(table), + table.is_event, ); out.dedent(1); writeln!(out, "}}, {}Row),", table_name_pascalcase); @@ -269,7 +270,7 @@ impl Lang for TypeScript { let view_name_pascalcase = view.name.deref().to_case(Case::Pascal); writeln!(out, "{}: __table({{", view.name); out.indent(1); - write_table_opts(module, out, type_ref, &view.name, iter::empty(), iter::empty()); + write_table_opts(module, out, type_ref, &view.name, iter::empty(), iter::empty(), false); out.dedent(1); writeln!(out, "}}, {}Row),", view_name_pascalcase); } @@ -688,6 +689,7 @@ fn write_table_opts<'a>( name: &Identifier, 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()); @@ -754,6 +756,9 @@ fn write_table_opts<'a>( } out.dedent(1); writeln!(out, "],"); + if is_event { + writeln!(out, "event: true,"); + } } /// e.g. diff --git a/crates/codegen/tests/snapshots/codegen__codegen_rust.snap b/crates/codegen/tests/snapshots/codegen__codegen_rust.snap index c4889a17bd8..d17548e8238 100644 --- a/crates/codegen/tests/snapshots/codegen__codegen_rust.snap +++ b/crates/codegen/tests/snapshots/codegen__codegen_rust.snap @@ -634,6 +634,8 @@ impl __sdk::__query_builder::HasIxCols for HasSpecialStuff { } } +impl __sdk::__query_builder::CanBeLookupTable for HasSpecialStuff {} + ''' "list_over_age_reducer.rs" = ''' // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE @@ -829,6 +831,8 @@ impl LoggedOutPlayerTableAccess for super::RemoteTables { } pub struct LoggedOutPlayerInsertCallbackId(__sdk::CallbackId); + + pub struct LoggedOutPlayerDeleteCallbackId(__sdk::CallbackId); impl<'ctx> __sdk::Table for LoggedOutPlayerTableHandle<'ctx> { @@ -865,14 +869,6 @@ impl<'ctx> __sdk::Table for LoggedOutPlayerTableHandle<'ctx> { } } -#[doc(hidden)] -pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { - - let _table = client_cache.get_or_make_table::("logged_out_player"); - _table.add_unique_constraint::<__sdk::Identity>("identity", |row| &row.identity); - _table.add_unique_constraint::("player_id", |row| &row.player_id); - _table.add_unique_constraint::("name", |row| &row.name); -} pub struct LoggedOutPlayerUpdateCallbackId(__sdk::CallbackId); impl<'ctx> __sdk::TableWithPrimaryKey for LoggedOutPlayerTableHandle<'ctx> { @@ -890,19 +886,6 @@ impl<'ctx> __sdk::TableWithPrimaryKey for LoggedOutPlayerTableHandle<'ctx> { } } - -#[doc(hidden)] -pub(super) fn parse_table_update( - raw_updates: __ws::v2::TableUpdate, -) -> __sdk::Result<__sdk::TableUpdate> { - __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { - __sdk::InternalError::failed_parse( - "TableUpdate", - "TableUpdate", - ).with_cause(e).into() - }) -} - /// Access to the `identity` unique index on the table `logged_out_player`, /// which allows point queries on the field of the same name /// via the [`LoggedOutPlayerIdentityUnique::find`] method. @@ -993,6 +976,27 @@ pub(super) fn parse_table_update( } } +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + + let _table = client_cache.get_or_make_table::("logged_out_player"); + _table.add_unique_constraint::<__sdk::Identity>("identity", |row| &row.identity); + _table.add_unique_constraint::("player_id", |row| &row.player_id); + _table.add_unique_constraint::("name", |row| &row.name); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse( + "TableUpdate", + "TableUpdate", + ).with_cause(e).into() + }) +} + #[allow(non_camel_case_types)] /// Extension trait for query builder access to the table `Player`. /// @@ -2043,6 +2047,8 @@ impl MyPlayerTableAccess for super::RemoteTables { } pub struct MyPlayerInsertCallbackId(__sdk::CallbackId); + + pub struct MyPlayerDeleteCallbackId(__sdk::CallbackId); impl<'ctx> __sdk::Table for MyPlayerTableHandle<'ctx> { @@ -2220,6 +2226,8 @@ impl PersonTableAccess for super::RemoteTables { } pub struct PersonInsertCallbackId(__sdk::CallbackId); + + pub struct PersonDeleteCallbackId(__sdk::CallbackId); impl<'ctx> __sdk::Table for PersonTableHandle<'ctx> { @@ -2256,12 +2264,6 @@ impl<'ctx> __sdk::Table for PersonTableHandle<'ctx> { } } -#[doc(hidden)] -pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { - - let _table = client_cache.get_or_make_table::("person"); - _table.add_unique_constraint::("id", |row| &row.id); -} pub struct PersonUpdateCallbackId(__sdk::CallbackId); impl<'ctx> __sdk::TableWithPrimaryKey for PersonTableHandle<'ctx> { @@ -2279,19 +2281,6 @@ impl<'ctx> __sdk::TableWithPrimaryKey for PersonTableHandle<'ctx> { } } - -#[doc(hidden)] -pub(super) fn parse_table_update( - raw_updates: __ws::v2::TableUpdate, -) -> __sdk::Result<__sdk::TableUpdate> { - __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { - __sdk::InternalError::failed_parse( - "TableUpdate", - "TableUpdate", - ).with_cause(e).into() - }) -} - /// Access to the `id` unique index on the table `person`, /// which allows point queries on the field of the same name /// via the [`PersonIdUnique::find`] method. @@ -2322,6 +2311,25 @@ pub(super) fn parse_table_update( } } +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + + let _table = client_cache.get_or_make_table::("person"); + _table.add_unique_constraint::("id", |row| &row.id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse( + "TableUpdate", + "TableUpdate", + ).with_cause(e).into() + }) +} + #[allow(non_camel_case_types)] /// Extension trait for query builder access to the table `Person`. /// @@ -2406,6 +2414,8 @@ impl __sdk::__query_builder::HasIxCols for Person { } } +impl __sdk::__query_builder::CanBeLookupTable for Person {} + ''' "pk_multi_identity_type.rs" = ''' // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE @@ -2471,6 +2481,8 @@ impl __sdk::__query_builder::HasIxCols for PkMultiIdentity { } } +impl __sdk::__query_builder::CanBeLookupTable for PkMultiIdentity {} + ''' "player_table.rs" = ''' // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE @@ -2518,6 +2530,8 @@ impl PlayerTableAccess for super::RemoteTables { } pub struct PlayerInsertCallbackId(__sdk::CallbackId); + + pub struct PlayerDeleteCallbackId(__sdk::CallbackId); impl<'ctx> __sdk::Table for PlayerTableHandle<'ctx> { @@ -2554,14 +2568,6 @@ impl<'ctx> __sdk::Table for PlayerTableHandle<'ctx> { } } -#[doc(hidden)] -pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { - - let _table = client_cache.get_or_make_table::("player"); - _table.add_unique_constraint::<__sdk::Identity>("identity", |row| &row.identity); - _table.add_unique_constraint::("player_id", |row| &row.player_id); - _table.add_unique_constraint::("name", |row| &row.name); -} pub struct PlayerUpdateCallbackId(__sdk::CallbackId); impl<'ctx> __sdk::TableWithPrimaryKey for PlayerTableHandle<'ctx> { @@ -2579,19 +2585,6 @@ impl<'ctx> __sdk::TableWithPrimaryKey for PlayerTableHandle<'ctx> { } } - -#[doc(hidden)] -pub(super) fn parse_table_update( - raw_updates: __ws::v2::TableUpdate, -) -> __sdk::Result<__sdk::TableUpdate> { - __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { - __sdk::InternalError::failed_parse( - "TableUpdate", - "TableUpdate", - ).with_cause(e).into() - }) -} - /// Access to the `identity` unique index on the table `player`, /// which allows point queries on the field of the same name /// via the [`PlayerIdentityUnique::find`] method. @@ -2682,6 +2675,27 @@ pub(super) fn parse_table_update( } } +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + + let _table = client_cache.get_or_make_table::("player"); + _table.add_unique_constraint::<__sdk::Identity>("identity", |row| &row.identity); + _table.add_unique_constraint::("player_id", |row| &row.player_id); + _table.add_unique_constraint::("name", |row| &row.name); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse( + "TableUpdate", + "TableUpdate", + ).with_cause(e).into() + }) +} + #[allow(non_camel_case_types)] /// Extension trait for query builder access to the table `Player`. /// @@ -2768,6 +2782,8 @@ impl __sdk::__query_builder::HasIxCols for Player { } } +impl __sdk::__query_builder::CanBeLookupTable for Player {} + ''' "point_type.rs" = ''' // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE @@ -2829,6 +2845,8 @@ impl __sdk::__query_builder::HasIxCols for Point { } } +impl __sdk::__query_builder::CanBeLookupTable for Point {} + ''' "private_table_type.rs" = ''' // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE @@ -2887,6 +2905,8 @@ impl __sdk::__query_builder::HasIxCols for PrivateTable { } } +impl __sdk::__query_builder::CanBeLookupTable for PrivateTable {} + ''' "query_private_reducer.rs" = ''' // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE @@ -3016,6 +3036,8 @@ impl __sdk::__query_builder::HasIxCols for RemoveTable { } } +impl __sdk::__query_builder::CanBeLookupTable for RemoveTable {} + ''' "repeating_test_arg_type.rs" = ''' // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE @@ -3082,6 +3104,8 @@ impl __sdk::__query_builder::HasIxCols for RepeatingTestArg { } } +impl __sdk::__query_builder::CanBeLookupTable for RepeatingTestArg {} + ''' "return_value_procedure.rs" = ''' // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE @@ -3332,6 +3356,8 @@ impl __sdk::__query_builder::HasIxCols for TestA { } } +impl __sdk::__query_builder::CanBeLookupTable for TestA {} + ''' "test_b_type.rs" = ''' // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE @@ -3476,6 +3502,8 @@ impl TestDTableAccess for super::RemoteTables { } pub struct TestDInsertCallbackId(__sdk::CallbackId); + + pub struct TestDDeleteCallbackId(__sdk::CallbackId); impl<'ctx> __sdk::Table for TestDTableHandle<'ctx> { @@ -3605,6 +3633,8 @@ impl __sdk::__query_builder::HasIxCols for TestD { } } +impl __sdk::__query_builder::CanBeLookupTable for TestD {} + ''' "test_e_type.rs" = ''' // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE @@ -3670,6 +3700,8 @@ impl __sdk::__query_builder::HasIxCols for TestE { } } +impl __sdk::__query_builder::CanBeLookupTable for TestE {} + ''' "test_f_table.rs" = ''' // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE @@ -3718,6 +3750,8 @@ impl TestFTableAccess for super::RemoteTables { } pub struct TestFInsertCallbackId(__sdk::CallbackId); + + pub struct TestFDeleteCallbackId(__sdk::CallbackId); impl<'ctx> __sdk::Table for TestFTableHandle<'ctx> { @@ -3847,6 +3881,8 @@ impl __sdk::__query_builder::HasIxCols for TestFoobar { } } +impl __sdk::__query_builder::CanBeLookupTable for TestFoobar {} + ''' "test_reducer.rs" = ''' // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE diff --git a/crates/core/src/subscription/module_subscription_actor.rs b/crates/core/src/subscription/module_subscription_actor.rs index f9d8a4c3663..c3248ae979f 100644 --- a/crates/core/src/subscription/module_subscription_actor.rs +++ b/crates/core/src/subscription/module_subscription_actor.rs @@ -654,6 +654,17 @@ impl ModuleSubscriptions { send_err_msg ); + // V1 clients must not subscribe to event tables. + // Old codegen doesn't understand event tables and would accumulate rows in the client cache. + if query.returns_event_table() { + let _ = send_err_msg( + "Subscribing to event tables requires WebSocket v2. \ + Please upgrade your client SDK and regenerate your module bindings." + .into(), + ); + return Ok((None, false)); + } + let mut_tx = ScopeGuard::::into_inner(mut_tx); let (tx, tx_offset, trapped) = @@ -1312,6 +1323,18 @@ impl ModuleSubscriptions { send_err_msg, (None, false) ); + + // V1 clients must not subscribe to event tables. + // Old codegen doesn't understand event tables and would accumulate rows in the client cache. + if queries.iter().any(|q| q.returns_event_table()) { + send_err_msg( + "Subscribing to event tables requires WebSocket v2. \ + Please upgrade your client SDK and regenerate your module bindings." + .into(), + ); + return Ok((None, false)); + } + let (mut_tx, _) = self.guard_mut_tx(mut_tx, <_>::default()); // We minimize locking so that other clients can add subscriptions concurrently. diff --git a/crates/core/src/subscription/module_subscription_manager.rs b/crates/core/src/subscription/module_subscription_manager.rs index 1e0ebe86e73..4cdacd39ff4 100644 --- a/crates/core/src/subscription/module_subscription_manager.rs +++ b/crates/core/src/subscription/module_subscription_manager.rs @@ -142,6 +142,11 @@ impl Plan { pub fn sql(&self) -> &str { &self.sql } + + /// Does this plan return rows from an event table? + pub fn returns_event_table(&self) -> bool { + self.plans.iter().any(|p| p.returns_event_table()) + } } /// For each client, we hold a handle for sending messages, and we track the queries they are subscribed to. @@ -1405,22 +1410,31 @@ impl SubscriptionManager { updates: &UpdatesRelValue<'_>, metrics: &mut ExecutionMetrics, rlb_pool: &impl RowListBuilderSource, + is_event_table: bool, ) -> TableUpdateRows { - let (deletes, nr_del) = ::encode_list( - rlb_pool.take_row_list_builder(), - updates.deletes.iter(), - ); let (inserts, nr_ins) = ::encode_list( rlb_pool.take_row_list_builder(), updates.inserts.iter(), ); - // TODO: Fix metrics. We only encode once, then we clone for other clients, so this isn't the place - // to report the metrics. - let _num_rows = nr_del + nr_ins; - let num_bytes = deletes.num_bytes() + inserts.num_bytes(); - metrics.bytes_scanned += num_bytes; - metrics.bytes_sent_to_clients += num_bytes; - TableUpdateRows::PersistentTable(ws_v2::PersistentTableRows { inserts, deletes }) + if is_event_table { + // Event tables only have inserts (events); no deletes. + debug_assert!(updates.deletes.is_empty(), "event tables should not produce deletes"); + metrics.bytes_scanned += inserts.num_bytes(); + metrics.bytes_sent_to_clients += inserts.num_bytes(); + TableUpdateRows::EventTable(ws_v2::EventTableRows { events: inserts }) + } else { + let (deletes, nr_del) = ::encode_list( + rlb_pool.take_row_list_builder(), + updates.deletes.iter(), + ); + // TODO: Fix metrics. We only encode once, then we clone for other clients, so this isn't the place + // to report the metrics. + let _num_rows = nr_del + nr_ins; + let num_bytes = deletes.num_bytes() + inserts.num_bytes(); + metrics.bytes_scanned += num_bytes; + metrics.bytes_sent_to_clients += num_bytes; + TableUpdateRows::PersistentTable(ws_v2::PersistentTableRows { inserts, deletes }) + } } let FoldState { updates, errs, metrics } = tables @@ -1468,7 +1482,12 @@ impl SubscriptionManager { } Ok(None) => {} Ok(Some(delta_updates)) => { - let rows = encode_v2_rows(&delta_updates, &mut acc.metrics, bsatn_rlb_pool); + let rows = encode_v2_rows( + &delta_updates, + &mut acc.metrics, + bsatn_rlb_pool, + plan.returns_event_table(), + ); for &(client_id, query_set_id) in qstate.v2_subscriptions.iter() { acc.updates.push(V2ClientUpdate { id: client_id, diff --git a/crates/core/src/subscription/subscription.rs b/crates/core/src/subscription/subscription.rs index 8d24db68a11..b27dd5e1ce1 100644 --- a/crates/core/src/subscription/subscription.rs +++ b/crates/core/src/subscription/subscription.rs @@ -624,7 +624,7 @@ where I: Iterator>, { Ok(get_all_tables(relational_db, tx)? - .filter(|t| t.table_type == StTableType::User && auth.has_read_access(t.table_access)) + .filter(|t| t.table_type == StTableType::User && auth.has_read_access(t.table_access) && !t.is_event) .map(|schema| { let sql = format!("SELECT * FROM {}", schema.table_name); let tx = SchemaViewer::new(tx, auth); @@ -662,7 +662,7 @@ pub(crate) fn legacy_get_all( .get_all_tables(tx)? .iter() .map(Deref::deref) - .filter(|t| t.table_type == StTableType::User && auth.has_read_access(t.table_access)) + .filter(|t| t.table_type == StTableType::User && auth.has_read_access(t.table_access) && !t.is_event) .map(|src| SupportedQuery { kind: query::Supported::Select, expr: QueryExpr::new(src), diff --git a/crates/datastore/src/locking_tx_datastore/datastore.rs b/crates/datastore/src/locking_tx_datastore/datastore.rs index ec63dbcce0e..7741b6e319f 100644 --- a/crates/datastore/src/locking_tx_datastore/datastore.rs +++ b/crates/datastore/src/locking_tx_datastore/datastore.rs @@ -3832,4 +3832,111 @@ mod tests { ); Ok(()) } + + /// Inserting a duplicate primary key within the same transaction should fail for event tables. + #[test] + fn test_event_table_primary_key_enforced_within_tx() -> ResultTest<()> { + let datastore = get_datastore()?; + let mut tx = begin_mut_tx(&datastore); + let mut schema = basic_table_schema_with_indices(basic_indices(), basic_constraints()); + schema.is_event = true; + schema.primary_key = Some(0.into()); // PK on column 0 (id) + let table_id = datastore.create_table_mut_tx(&mut tx, schema)?; + commit(&datastore, tx)?; + + let mut tx = begin_mut_tx(&datastore); + let row1 = u32_str_u32(1, "Alice", 30); + insert(&datastore, &mut tx, table_id, &row1)?; + + // Duplicate PK in same TX should error (unique constraint on col 0). + let row2 = u32_str_u32(1, "Bob", 25); + let result = insert(&datastore, &mut tx, table_id, &row2); + assert!(result.is_err(), "duplicate PK in same TX should be rejected"); + Ok(()) + } + + /// Inserting a duplicate unique column value within the same transaction should fail for event tables. + #[test] + fn test_event_table_unique_constraint_within_tx() -> ResultTest<()> { + let (datastore, tx, table_id) = setup_event_table()?; + commit(&datastore, tx)?; + + let mut tx = begin_mut_tx(&datastore); + let row1 = u32_str_u32(1, "Alice", 30); + insert(&datastore, &mut tx, table_id, &row1)?; + + // Duplicate unique name in same TX should error (unique constraint on col 1). + let row2 = u32_str_u32(2, "Alice", 25); + let result = insert(&datastore, &mut tx, table_id, &row2); + assert!(result.is_err(), "duplicate unique column in same TX should be rejected"); + Ok(()) + } + + /// Btree index lookups should work within a transaction for event tables. + /// We verify this indirectly: if the index works, an update via that index succeeds. + #[test] + fn test_event_table_index_lookup_within_tx() -> ResultTest<()> { + let (datastore, tx, table_id) = setup_event_table()?; + commit(&datastore, tx)?; + + let mut tx = begin_mut_tx(&datastore); + let row1 = u32_str_u32(1, "Alice", 30); + let row2 = u32_str_u32(2, "Bob", 25); + insert(&datastore, &mut tx, table_id, &row1)?; + insert(&datastore, &mut tx, table_id, &row2)?; + + // Update via the btree index on column 0 (id) — this exercises index lookup. + let idx = extract_index_id(&datastore, &tx, &basic_indices()[0])?; + let row1_updated = u32_str_u32(1, "Alice", 31); + update(&datastore, &mut tx, table_id, idx, &row1_updated)?; + + // Verify both rows are visible in the tx. + let rows = all_rows(&datastore, &tx, table_id); + assert_eq!(rows.len(), 2, "should have 2 rows after insert+update"); + assert!(rows.contains(&row1_updated), "updated row should be present"); + assert!(rows.contains(&row2), "other row should be present"); + Ok(()) + } + + /// Auto-increment should generate distinct values within a single transaction for event tables. + #[test] + fn test_event_table_auto_inc_within_tx() -> ResultTest<()> { + let (datastore, tx, table_id) = setup_event_table()?; + commit(&datastore, tx)?; + + let mut tx = begin_mut_tx(&datastore); + // Insert with id=0 to trigger auto_inc on column 0. + let row1 = u32_str_u32(0, "Alice", 30); + let (gen1, _) = insert(&datastore, &mut tx, table_id, &row1)?; + let row2 = u32_str_u32(0, "Bob", 25); + let (gen2, _) = insert(&datastore, &mut tx, table_id, &row2)?; + + // Both auto-incremented ids should be distinct. + assert_ne!(gen1, gen2, "auto_inc should produce distinct values within the same TX"); + Ok(()) + } + + /// Constraints on event tables should reset across transactions (no committed state carryover). + #[test] + fn test_event_table_constraints_reset_across_txs() -> ResultTest<()> { + let (datastore, tx, table_id) = setup_event_table()?; + commit(&datastore, tx)?; + + // TX1: insert row with id=1. + let mut tx1 = begin_mut_tx(&datastore); + let row = u32_str_u32(1, "Alice", 30); + insert(&datastore, &mut tx1, table_id, &row)?; + commit(&datastore, tx1)?; + + // TX2: insert row with same id=1 — should succeed because event tables + // don't carry committed state. + let mut tx2 = begin_mut_tx(&datastore); + let row = u32_str_u32(1, "Bob", 25); + let result = insert(&datastore, &mut tx2, table_id, &row); + assert!( + result.is_ok(), + "same PK in a new TX should succeed for event tables (no committed state)" + ); + Ok(()) + } } diff --git a/crates/physical-plan/src/plan.rs b/crates/physical-plan/src/plan.rs index 92593e293f8..db900c4ac5c 100644 --- a/crates/physical-plan/src/plan.rs +++ b/crates/physical-plan/src/plan.rs @@ -121,6 +121,13 @@ impl ProjectPlan { Self::None(plan) | Self::Name(plan, ..) => plan.reads_from_view(anonymous), } } + + /// Does this plan use an event table as the lookup (rhs) table in a semi-join? + pub fn reads_from_event_table(&self) -> bool { + match self { + Self::None(plan) | Self::Name(plan, ..) => plan.reads_from_event_table(), + } + } } /// Physical plans always terminate with a projection. @@ -228,6 +235,15 @@ impl ProjectListPlan { Self::List(plans, ..) | Self::Agg(plans, ..) => plans.iter().any(|plan| plan.reads_from_view(anonymous)), } } + + /// Does this plan use an event table as the lookup (rhs) table in a semi-join? + pub fn reads_from_event_table(&self) -> bool { + match self { + Self::Limit(plan, _) => plan.reads_from_event_table(), + Self::Name(plans) => plans.iter().any(|plan| plan.reads_from_event_table()), + Self::List(plans, ..) | Self::Agg(plans, ..) => plans.iter().any(|plan| plan.reads_from_event_table()), + } + } } /// Query operators return tuples of rows. @@ -1150,6 +1166,14 @@ impl PhysicalPlan { _ => false, }) } + + /// Does this plan use an event table as the lookup (rhs) table in a semi-join? + pub fn reads_from_event_table(&self) -> bool { + self.any(&|plan| match plan { + Self::IxJoin(join, _) => join.rhs.is_event, + _ => false, + }) + } } /// Scan a table row by row, returning row ids diff --git a/crates/query-builder/src/join.rs b/crates/query-builder/src/join.rs index f72a3942514..e38af5f0248 100644 --- a/crates/query-builder/src/join.rs +++ b/crates/query-builder/src/join.rs @@ -2,7 +2,7 @@ use crate::TableNameStr; use super::{ expr::{format_expr, BoolExpr}, - table::{ColumnRef, HasCols, HasIxCols, Table}, + table::{CanBeLookupTable, ColumnRef, HasCols, HasIxCols, Table}, Query, RawQuery, }; use std::marker::PhantomData; @@ -66,7 +66,7 @@ pub struct RightSemiJoin { } impl Table { - pub fn left_semijoin( + pub fn left_semijoin( self, right: Table, on: impl Fn(&L::IxCols, &R::IxCols) -> IxJoinEq, @@ -80,7 +80,7 @@ impl Table { } } - pub fn right_semijoin( + pub fn right_semijoin( self, right: Table, on: impl Fn(&L::IxCols, &R::IxCols) -> IxJoinEq, @@ -97,7 +97,7 @@ impl Table { } impl super::FromWhere { - pub fn left_semijoin( + pub fn left_semijoin( self, right: Table, on: impl Fn(&L::IxCols, &R::IxCols) -> IxJoinEq, @@ -111,7 +111,7 @@ impl super::FromWhere { } } - pub fn right_semijoin( + pub fn right_semijoin( self, right: Table, on: impl Fn(&L::IxCols, &R::IxCols) -> IxJoinEq, diff --git a/crates/query-builder/src/lib.rs b/crates/query-builder/src/lib.rs index 2621b759487..9b5382967c5 100644 --- a/crates/query-builder/src/lib.rs +++ b/crates/query-builder/src/lib.rs @@ -109,6 +109,8 @@ mod tests { } } } + impl CanBeLookupTable for User {} + impl CanBeLookupTable for Other {} fn norm(s: &str) -> String { s.split_whitespace().collect::>().join(" ") } diff --git a/crates/query-builder/src/table.rs b/crates/query-builder/src/table.rs index e2f126355ac..3d9fb588d93 100644 --- a/crates/query-builder/src/table.rs +++ b/crates/query-builder/src/table.rs @@ -16,6 +16,11 @@ pub trait HasIxCols { fn ix_cols(name: TableNameStr) -> Self::IxCols; } +/// Marker trait for tables that can appear as the right/inner/lookup +/// table in a semi-join. Event tables do NOT implement this trait, +/// preventing them from being used as the lookup side of a join. +pub trait CanBeLookupTable: HasIxCols {} + pub struct Table { pub(super) table_name: TableNameStr, _marker: PhantomData, diff --git a/crates/standalone/src/subcommands/extract_schema.rs b/crates/standalone/src/subcommands/extract_schema.rs index 9b85e57059a..c9b35369957 100644 --- a/crates/standalone/src/subcommands/extract_schema.rs +++ b/crates/standalone/src/subcommands/extract_schema.rs @@ -67,7 +67,7 @@ pub async fn exec(args: &ArgMatches) -> anyhow::Result<()> { let module_def = extract_schema(program_bytes.into(), host_type.into()).await?; - let raw_def = RawModuleDef::V9(module_def.into()); + let raw_def = RawModuleDef::V10(module_def.into()); serde_json::to_writer(std::io::stdout().lock(), &sats::serde::SerdeWrapper(raw_def))?; diff --git a/crates/subscription/src/lib.rs b/crates/subscription/src/lib.rs index 20708a694c2..ca0ef5b63e3 100644 --- a/crates/subscription/src/lib.rs +++ b/crates/subscription/src/lib.rs @@ -340,6 +340,11 @@ impl SubscriptionPlan { self.plan_opt.returns_view_table() } + /// Does this plan return rows from an event table? + pub fn returns_event_table(&self) -> bool { + self.plan_opt.return_table().is_some_and(|schema| schema.is_event) + } + /// The number of columns returned. /// Only relevant if [`Self::is_view`] is true. pub fn num_cols(&self) -> usize { @@ -514,6 +519,10 @@ impl SubscriptionPlan { bail!("Subscriptions require indexes on join columns") } + if plan_opt.reads_from_event_table() { + bail!("Event tables cannot be used as the lookup table in subscription joins") + } + let (table_ids, table_aliases) = table_ids_for_plan(&plan); let fragments = Fragments::compile_from_plan(&plan, &table_aliases, auth)?; diff --git a/demo/Blackholio/client-unity/Assets/Scripts/EntityController.cs b/demo/Blackholio/client-unity/Assets/Scripts/EntityController.cs index 8e23f92a24e..651aa425421 100644 --- a/demo/Blackholio/client-unity/Assets/Scripts/EntityController.cs +++ b/demo/Blackholio/client-unity/Assets/Scripts/EntityController.cs @@ -44,16 +44,17 @@ public virtual void OnEntityUpdated(Entity newVal) public virtual void OnDelete(EventContext context) { - if (context.Event is SpacetimeDB.Event.Reducer reducer && - reducer.ReducerEvent.Reducer is Reducer.ConsumeEntity consume) - { - var consumerId = consume.Request.ConsumerEntityId; - if (GameManager.Entities.TryGetValue(consumerId, out var consumerEntity)) - { - StartCoroutine(DespawnCoroutine(consumerEntity.transform)); - return; - } - } + // TODO: Refactor to Event Tables + // if (context.Event is SpacetimeDB.Event.Reducer reducer && + // reducer.ReducerEvent.Reducer is Reducer.ConsumeEntity consume) + // { + // var consumerId = consume.Request.ConsumerEntityId; + // if (GameManager.Entities.TryGetValue(consumerId, out var consumerEntity)) + // { + // StartCoroutine(DespawnCoroutine(consumerEntity.transform)); + // return; + // } + // } Destroy(gameObject); } diff --git a/demo/Blackholio/client-unity/Assets/Scripts/autogen/Reducers/CircleDecay.g.cs b/demo/Blackholio/client-unity/Assets/Scripts/autogen/Reducers/CircleDecay.g.cs deleted file mode 100644 index 2006c816d75..00000000000 --- a/demo/Blackholio/client-unity/Assets/Scripts/autogen/Reducers/CircleDecay.g.cs +++ /dev/null @@ -1,67 +0,0 @@ -// 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 SpacetimeDB.ClientApi; -using System.Collections.Generic; -using System.Runtime.Serialization; - -namespace SpacetimeDB.Types -{ - public sealed partial class RemoteReducers : RemoteBase - { - public delegate void CircleDecayHandler(ReducerEventContext ctx, SpacetimeDB.Types.CircleDecayTimer timer); - public event CircleDecayHandler? OnCircleDecay; - - public void CircleDecay(SpacetimeDB.Types.CircleDecayTimer timer) - { - conn.InternalCallReducer(new Reducer.CircleDecay(timer)); - } - - public bool InvokeCircleDecay(ReducerEventContext ctx, Reducer.CircleDecay args) - { - if (OnCircleDecay == null) - { - if (InternalOnUnhandledReducerError != null) - { - switch (ctx.Event.Status) - { - case Status.Failed(var reason): InternalOnUnhandledReducerError(ctx, new Exception(reason)); break; - case Status.OutOfEnergy(var _): InternalOnUnhandledReducerError(ctx, new Exception("out of energy")); break; - } - } - return false; - } - OnCircleDecay( - ctx, - args.Timer - ); - return true; - } - } - - public abstract partial class Reducer - { - [SpacetimeDB.Type] - [DataContract] - public sealed partial class CircleDecay : Reducer, IReducerArgs - { - [DataMember(Name = "_timer")] - public CircleDecayTimer Timer; - - public CircleDecay(CircleDecayTimer Timer) - { - this.Timer = Timer; - } - - public CircleDecay() - { - this.Timer = new(); - } - - string IReducerArgs.ReducerName => "circle_decay"; - } - } -} diff --git a/demo/Blackholio/client-unity/Assets/Scripts/autogen/Reducers/CircleDecay.g.cs.meta b/demo/Blackholio/client-unity/Assets/Scripts/autogen/Reducers/CircleDecay.g.cs.meta deleted file mode 100644 index fbedf95edf4..00000000000 --- a/demo/Blackholio/client-unity/Assets/Scripts/autogen/Reducers/CircleDecay.g.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: ac1b2381309d41e4aa05575b5e1181ba -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/demo/Blackholio/client-unity/Assets/Scripts/autogen/Reducers/CircleRecombine.g.cs b/demo/Blackholio/client-unity/Assets/Scripts/autogen/Reducers/CircleRecombine.g.cs deleted file mode 100644 index 1738c237489..00000000000 --- a/demo/Blackholio/client-unity/Assets/Scripts/autogen/Reducers/CircleRecombine.g.cs +++ /dev/null @@ -1,67 +0,0 @@ -// 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 SpacetimeDB.ClientApi; -using System.Collections.Generic; -using System.Runtime.Serialization; - -namespace SpacetimeDB.Types -{ - public sealed partial class RemoteReducers : RemoteBase - { - public delegate void CircleRecombineHandler(ReducerEventContext ctx, SpacetimeDB.Types.CircleRecombineTimer timer); - public event CircleRecombineHandler? OnCircleRecombine; - - public void CircleRecombine(SpacetimeDB.Types.CircleRecombineTimer timer) - { - conn.InternalCallReducer(new Reducer.CircleRecombine(timer)); - } - - public bool InvokeCircleRecombine(ReducerEventContext ctx, Reducer.CircleRecombine args) - { - if (OnCircleRecombine == null) - { - if (InternalOnUnhandledReducerError != null) - { - switch (ctx.Event.Status) - { - case Status.Failed(var reason): InternalOnUnhandledReducerError(ctx, new Exception(reason)); break; - case Status.OutOfEnergy(var _): InternalOnUnhandledReducerError(ctx, new Exception("out of energy")); break; - } - } - return false; - } - OnCircleRecombine( - ctx, - args.Timer - ); - return true; - } - } - - public abstract partial class Reducer - { - [SpacetimeDB.Type] - [DataContract] - public sealed partial class CircleRecombine : Reducer, IReducerArgs - { - [DataMember(Name = "timer")] - public CircleRecombineTimer Timer; - - public CircleRecombine(CircleRecombineTimer Timer) - { - this.Timer = Timer; - } - - public CircleRecombine() - { - this.Timer = new(); - } - - string IReducerArgs.ReducerName => "circle_recombine"; - } - } -} diff --git a/demo/Blackholio/client-unity/Assets/Scripts/autogen/Reducers/CircleRecombine.g.cs.meta b/demo/Blackholio/client-unity/Assets/Scripts/autogen/Reducers/CircleRecombine.g.cs.meta deleted file mode 100644 index 080bb38d655..00000000000 --- a/demo/Blackholio/client-unity/Assets/Scripts/autogen/Reducers/CircleRecombine.g.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: af62a50bbddd30c4691de48550ecfb23 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/demo/Blackholio/client-unity/Assets/Scripts/autogen/Reducers/Connect.g.cs b/demo/Blackholio/client-unity/Assets/Scripts/autogen/Reducers/Connect.g.cs deleted file mode 100644 index ed31c1614f3..00000000000 --- a/demo/Blackholio/client-unity/Assets/Scripts/autogen/Reducers/Connect.g.cs +++ /dev/null @@ -1,48 +0,0 @@ -// 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 SpacetimeDB.ClientApi; -using System.Collections.Generic; -using System.Runtime.Serialization; - -namespace SpacetimeDB.Types -{ - public sealed partial class RemoteReducers : RemoteBase - { - public delegate void ConnectHandler(ReducerEventContext ctx); - public event ConnectHandler? OnConnect; - - public bool InvokeConnect(ReducerEventContext ctx, Reducer.Connect args) - { - if (OnConnect == null) - { - if (InternalOnUnhandledReducerError != null) - { - switch (ctx.Event.Status) - { - case Status.Failed(var reason): InternalOnUnhandledReducerError(ctx, new Exception(reason)); break; - case Status.OutOfEnergy(var _): InternalOnUnhandledReducerError(ctx, new Exception("out of energy")); break; - } - } - return false; - } - OnConnect( - ctx - ); - return true; - } - } - - public abstract partial class Reducer - { - [SpacetimeDB.Type] - [DataContract] - public sealed partial class Connect : Reducer, IReducerArgs - { - string IReducerArgs.ReducerName => "connect"; - } - } -} diff --git a/demo/Blackholio/client-unity/Assets/Scripts/autogen/Reducers/Connect.g.cs.meta b/demo/Blackholio/client-unity/Assets/Scripts/autogen/Reducers/Connect.g.cs.meta deleted file mode 100644 index e4947377fb7..00000000000 --- a/demo/Blackholio/client-unity/Assets/Scripts/autogen/Reducers/Connect.g.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 80093a18a56cbb541a82bdafa8c12ccd -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/demo/Blackholio/client-unity/Assets/Scripts/autogen/Reducers/ConsumeEntity.g.cs.meta b/demo/Blackholio/client-unity/Assets/Scripts/autogen/Reducers/ConsumeEntity.g.cs.meta deleted file mode 100644 index cc8d4c8cc2f..00000000000 --- a/demo/Blackholio/client-unity/Assets/Scripts/autogen/Reducers/ConsumeEntity.g.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: fdd3eba64fff07c4db7d4112a8d1af38 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/demo/Blackholio/client-unity/Assets/Scripts/autogen/Reducers/Disconnect.g.cs b/demo/Blackholio/client-unity/Assets/Scripts/autogen/Reducers/Disconnect.g.cs deleted file mode 100644 index 4a64b0ad0bd..00000000000 --- a/demo/Blackholio/client-unity/Assets/Scripts/autogen/Reducers/Disconnect.g.cs +++ /dev/null @@ -1,48 +0,0 @@ -// 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 SpacetimeDB.ClientApi; -using System.Collections.Generic; -using System.Runtime.Serialization; - -namespace SpacetimeDB.Types -{ - public sealed partial class RemoteReducers : RemoteBase - { - public delegate void DisconnectHandler(ReducerEventContext ctx); - public event DisconnectHandler? OnDisconnect; - - public bool InvokeDisconnect(ReducerEventContext ctx, Reducer.Disconnect args) - { - if (OnDisconnect == null) - { - if (InternalOnUnhandledReducerError != null) - { - switch (ctx.Event.Status) - { - case Status.Failed(var reason): InternalOnUnhandledReducerError(ctx, new Exception(reason)); break; - case Status.OutOfEnergy(var _): InternalOnUnhandledReducerError(ctx, new Exception("out of energy")); break; - } - } - return false; - } - OnDisconnect( - ctx - ); - return true; - } - } - - public abstract partial class Reducer - { - [SpacetimeDB.Type] - [DataContract] - public sealed partial class Disconnect : Reducer, IReducerArgs - { - string IReducerArgs.ReducerName => "disconnect"; - } - } -} diff --git a/demo/Blackholio/client-unity/Assets/Scripts/autogen/Reducers/Disconnect.g.cs.meta b/demo/Blackholio/client-unity/Assets/Scripts/autogen/Reducers/Disconnect.g.cs.meta deleted file mode 100644 index f633768e82a..00000000000 --- a/demo/Blackholio/client-unity/Assets/Scripts/autogen/Reducers/Disconnect.g.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 8e1569d9cfc81fe45bd19a448d7c8777 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/demo/Blackholio/client-unity/Assets/Scripts/autogen/Reducers/MoveAllPlayers.g.cs b/demo/Blackholio/client-unity/Assets/Scripts/autogen/Reducers/MoveAllPlayers.g.cs deleted file mode 100644 index 3dafd3779bd..00000000000 --- a/demo/Blackholio/client-unity/Assets/Scripts/autogen/Reducers/MoveAllPlayers.g.cs +++ /dev/null @@ -1,67 +0,0 @@ -// 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 SpacetimeDB.ClientApi; -using System.Collections.Generic; -using System.Runtime.Serialization; - -namespace SpacetimeDB.Types -{ - public sealed partial class RemoteReducers : RemoteBase - { - public delegate void MoveAllPlayersHandler(ReducerEventContext ctx, SpacetimeDB.Types.MoveAllPlayersTimer timer); - public event MoveAllPlayersHandler? OnMoveAllPlayers; - - public void MoveAllPlayers(SpacetimeDB.Types.MoveAllPlayersTimer timer) - { - conn.InternalCallReducer(new Reducer.MoveAllPlayers(timer)); - } - - public bool InvokeMoveAllPlayers(ReducerEventContext ctx, Reducer.MoveAllPlayers args) - { - if (OnMoveAllPlayers == null) - { - if (InternalOnUnhandledReducerError != null) - { - switch (ctx.Event.Status) - { - case Status.Failed(var reason): InternalOnUnhandledReducerError(ctx, new Exception(reason)); break; - case Status.OutOfEnergy(var _): InternalOnUnhandledReducerError(ctx, new Exception("out of energy")); break; - } - } - return false; - } - OnMoveAllPlayers( - ctx, - args.Timer - ); - return true; - } - } - - public abstract partial class Reducer - { - [SpacetimeDB.Type] - [DataContract] - public sealed partial class MoveAllPlayers : Reducer, IReducerArgs - { - [DataMember(Name = "_timer")] - public MoveAllPlayersTimer Timer; - - public MoveAllPlayers(MoveAllPlayersTimer Timer) - { - this.Timer = Timer; - } - - public MoveAllPlayers() - { - this.Timer = new(); - } - - string IReducerArgs.ReducerName => "move_all_players"; - } - } -} diff --git a/demo/Blackholio/client-unity/Assets/Scripts/autogen/Reducers/MoveAllPlayers.g.cs.meta b/demo/Blackholio/client-unity/Assets/Scripts/autogen/Reducers/MoveAllPlayers.g.cs.meta deleted file mode 100644 index abb234a63c7..00000000000 --- a/demo/Blackholio/client-unity/Assets/Scripts/autogen/Reducers/MoveAllPlayers.g.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: a452ce1270574d34a888a3f16987b74c -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/demo/Blackholio/client-unity/Assets/Scripts/autogen/Reducers/SpawnFood.g.cs b/demo/Blackholio/client-unity/Assets/Scripts/autogen/Reducers/SpawnFood.g.cs deleted file mode 100644 index d7339f061d2..00000000000 --- a/demo/Blackholio/client-unity/Assets/Scripts/autogen/Reducers/SpawnFood.g.cs +++ /dev/null @@ -1,67 +0,0 @@ -// 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 SpacetimeDB.ClientApi; -using System.Collections.Generic; -using System.Runtime.Serialization; - -namespace SpacetimeDB.Types -{ - public sealed partial class RemoteReducers : RemoteBase - { - public delegate void SpawnFoodHandler(ReducerEventContext ctx, SpacetimeDB.Types.SpawnFoodTimer timer); - public event SpawnFoodHandler? OnSpawnFood; - - public void SpawnFood(SpacetimeDB.Types.SpawnFoodTimer timer) - { - conn.InternalCallReducer(new Reducer.SpawnFood(timer)); - } - - public bool InvokeSpawnFood(ReducerEventContext ctx, Reducer.SpawnFood args) - { - if (OnSpawnFood == null) - { - if (InternalOnUnhandledReducerError != null) - { - switch (ctx.Event.Status) - { - case Status.Failed(var reason): InternalOnUnhandledReducerError(ctx, new Exception(reason)); break; - case Status.OutOfEnergy(var _): InternalOnUnhandledReducerError(ctx, new Exception("out of energy")); break; - } - } - return false; - } - OnSpawnFood( - ctx, - args.Timer - ); - return true; - } - } - - public abstract partial class Reducer - { - [SpacetimeDB.Type] - [DataContract] - public sealed partial class SpawnFood : Reducer, IReducerArgs - { - [DataMember(Name = "_timer")] - public SpawnFoodTimer Timer; - - public SpawnFood(SpawnFoodTimer Timer) - { - this.Timer = Timer; - } - - public SpawnFood() - { - this.Timer = new(); - } - - string IReducerArgs.ReducerName => "spawn_food"; - } - } -} diff --git a/demo/Blackholio/client-unity/Assets/Scripts/autogen/Reducers/SpawnFood.g.cs.meta b/demo/Blackholio/client-unity/Assets/Scripts/autogen/Reducers/SpawnFood.g.cs.meta deleted file mode 100644 index 12f56b0e854..00000000000 --- a/demo/Blackholio/client-unity/Assets/Scripts/autogen/Reducers/SpawnFood.g.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 38f8775dc9616e64db7a90f5370de352 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/demo/Blackholio/client-unity/Assets/Scripts/autogen/SpacetimeDBClient.g.cs b/demo/Blackholio/client-unity/Assets/Scripts/autogen/SpacetimeDBClient.g.cs index 1dc447a063a..7f62b8a8f2e 100644 --- a/demo/Blackholio/client-unity/Assets/Scripts/autogen/SpacetimeDBClient.g.cs +++ b/demo/Blackholio/client-unity/Assets/Scripts/autogen/SpacetimeDBClient.g.cs @@ -1,7 +1,7 @@ // 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 cli version 2.0.0 (commit 18e8d1958a9e9cb62ca15cc849d35c1a17f9982c). +// This was generated using spacetimedb cli version 2.0.0 (commit 9e0e81a6aaec6bf3619cfb9f7916743d86ab7ffc). #nullable enable @@ -625,16 +625,9 @@ protected override bool Dispatch(IReducerEventContext context, Reducer reducer) var eventContext = (ReducerEventContext)context; return reducer switch { - Reducer.CircleDecay args => Reducers.InvokeCircleDecay(eventContext, args), - Reducer.CircleRecombine args => Reducers.InvokeCircleRecombine(eventContext, args), - Reducer.Connect args => Reducers.InvokeConnect(eventContext, args), - Reducer.ConsumeEntity args => Reducers.InvokeConsumeEntity(eventContext, args), - Reducer.Disconnect args => Reducers.InvokeDisconnect(eventContext, args), Reducer.EnterGame args => Reducers.InvokeEnterGame(eventContext, args), - Reducer.MoveAllPlayers args => Reducers.InvokeMoveAllPlayers(eventContext, args), Reducer.PlayerSplit args => Reducers.InvokePlayerSplit(eventContext, args), Reducer.Respawn args => Reducers.InvokeRespawn(eventContext, args), - Reducer.SpawnFood args => Reducers.InvokeSpawnFood(eventContext, args), Reducer.Suicide args => Reducers.InvokeSuicide(eventContext, args), Reducer.UpdatePlayerInput args => Reducers.InvokeUpdatePlayerInput(eventContext, args), _ => throw new ArgumentOutOfRangeException("Reducer", $"Unknown reducer {reducer}") diff --git a/demo/Blackholio/client-unreal/Source/client_unreal/Private/ModuleBindings/SpacetimeDBClient.g.cpp b/demo/Blackholio/client-unreal/Source/client_unreal/Private/ModuleBindings/SpacetimeDBClient.g.cpp index cb9ada83974..471fac9e789 100644 --- a/demo/Blackholio/client-unreal/Source/client_unreal/Private/ModuleBindings/SpacetimeDBClient.g.cpp +++ b/demo/Blackholio/client-unreal/Source/client_unreal/Private/ModuleBindings/SpacetimeDBClient.g.cpp @@ -14,48 +14,12 @@ static FReducer DecodeReducer(const FReducerEvent& Event) { const FString& ReducerName = Event.ReducerCall.ReducerName; - if (ReducerName == TEXT("circle_decay")) - { - FCircleDecayArgs Args = UE::SpacetimeDB::Deserialize(Event.ReducerCall.Args); - return FReducer::CircleDecay(Args); - } - - if (ReducerName == TEXT("circle_recombine")) - { - FCircleRecombineArgs Args = UE::SpacetimeDB::Deserialize(Event.ReducerCall.Args); - return FReducer::CircleRecombine(Args); - } - - if (ReducerName == TEXT("connect")) - { - FConnectArgs Args = UE::SpacetimeDB::Deserialize(Event.ReducerCall.Args); - return FReducer::Connect(Args); - } - - if (ReducerName == TEXT("consume_entity")) - { - FConsumeEntityArgs Args = UE::SpacetimeDB::Deserialize(Event.ReducerCall.Args); - return FReducer::ConsumeEntity(Args); - } - - if (ReducerName == TEXT("disconnect")) - { - FDisconnectArgs Args = UE::SpacetimeDB::Deserialize(Event.ReducerCall.Args); - return FReducer::Disconnect(Args); - } - if (ReducerName == TEXT("enter_game")) { FEnterGameArgs Args = UE::SpacetimeDB::Deserialize(Event.ReducerCall.Args); return FReducer::EnterGame(Args); } - if (ReducerName == TEXT("move_all_players")) - { - FMoveAllPlayersArgs Args = UE::SpacetimeDB::Deserialize(Event.ReducerCall.Args); - return FReducer::MoveAllPlayers(Args); - } - if (ReducerName == TEXT("player_split")) { FPlayerSplitArgs Args = UE::SpacetimeDB::Deserialize(Event.ReducerCall.Args); @@ -68,12 +32,6 @@ static FReducer DecodeReducer(const FReducerEvent& Event) return FReducer::Respawn(Args); } - if (ReducerName == TEXT("spawn_food")) - { - FSpawnFoodArgs Args = UE::SpacetimeDB::Deserialize(Event.ReducerCall.Args); - return FReducer::SpawnFood(Args); - } - if (ReducerName == TEXT("suicide")) { FSuicideArgs Args = UE::SpacetimeDB::Deserialize(Event.ReducerCall.Args); @@ -159,34 +117,10 @@ void URemoteTables::Initialize() /**/ } -void USetReducerFlags::CircleDecay(ECallReducerFlags Flag) -{ - FlagMap.Add("CircleDecay", Flag); -} -void USetReducerFlags::CircleRecombine(ECallReducerFlags Flag) -{ - FlagMap.Add("CircleRecombine", Flag); -} -void USetReducerFlags::Connect(ECallReducerFlags Flag) -{ - FlagMap.Add("Connect", Flag); -} -void USetReducerFlags::ConsumeEntity(ECallReducerFlags Flag) -{ - FlagMap.Add("ConsumeEntity", Flag); -} -void USetReducerFlags::Disconnect(ECallReducerFlags Flag) -{ - FlagMap.Add("Disconnect", Flag); -} void USetReducerFlags::EnterGame(ECallReducerFlags Flag) { FlagMap.Add("EnterGame", Flag); } -void USetReducerFlags::MoveAllPlayers(ECallReducerFlags Flag) -{ - FlagMap.Add("MoveAllPlayers", Flag); -} void USetReducerFlags::PlayerSplit(ECallReducerFlags Flag) { FlagMap.Add("PlayerSplit", Flag); @@ -195,10 +129,6 @@ void USetReducerFlags::Respawn(ECallReducerFlags Flag) { FlagMap.Add("Respawn", Flag); } -void USetReducerFlags::SpawnFood(ECallReducerFlags Flag) -{ - FlagMap.Add("SpawnFood", Flag); -} void USetReducerFlags::Suicide(ECallReducerFlags Flag) { FlagMap.Add("Suicide", Flag); @@ -208,226 +138,6 @@ void USetReducerFlags::UpdatePlayerInput(ECallReducerFlags Flag) FlagMap.Add("UpdatePlayerInput", Flag); } -void URemoteReducers::CircleDecay(const FCircleDecayTimerType& Timer) -{ - if (!Conn) - { - UE_LOG(LogTemp, Error, TEXT("SpacetimeDB connection is null")); - return; - } - - Conn->CallReducerTyped(TEXT("circle_decay"), FCircleDecayArgs(Timer), SetCallReducerFlags); -} - -bool URemoteReducers::InvokeCircleDecay(const FReducerEventContext& Context, const UCircleDecayReducer* Args) -{ - if (!OnCircleDecay.IsBound()) - { - // Handle unhandled reducer error - if (InternalOnUnhandledReducerError.IsBound()) - { - // TODO: Check Context.Event.Status for Failed/OutOfEnergy cases - // For now, just broadcast any error - InternalOnUnhandledReducerError.Broadcast(Context, TEXT("No handler registered for CircleDecay")); - } - return false; - } - - OnCircleDecay.Broadcast(Context, Args->Timer); - return true; -} - -bool URemoteReducers::InvokeCircleDecayWithArgs(const FReducerEventContext& Context, const FCircleDecayArgs& Args) -{ - if (!OnCircleDecay.IsBound()) - { - if (InternalOnUnhandledReducerError.IsBound()) - { - InternalOnUnhandledReducerError.Broadcast(Context, TEXT("No handler registered for CircleDecay")); - } - return false; - } - - OnCircleDecay.Broadcast(Context, Args.Timer); - return true; -} - -void URemoteReducers::CircleRecombine(const FCircleRecombineTimerType& Timer) -{ - if (!Conn) - { - UE_LOG(LogTemp, Error, TEXT("SpacetimeDB connection is null")); - return; - } - - Conn->CallReducerTyped(TEXT("circle_recombine"), FCircleRecombineArgs(Timer), SetCallReducerFlags); -} - -bool URemoteReducers::InvokeCircleRecombine(const FReducerEventContext& Context, const UCircleRecombineReducer* Args) -{ - if (!OnCircleRecombine.IsBound()) - { - // Handle unhandled reducer error - if (InternalOnUnhandledReducerError.IsBound()) - { - // TODO: Check Context.Event.Status for Failed/OutOfEnergy cases - // For now, just broadcast any error - InternalOnUnhandledReducerError.Broadcast(Context, TEXT("No handler registered for CircleRecombine")); - } - return false; - } - - OnCircleRecombine.Broadcast(Context, Args->Timer); - return true; -} - -bool URemoteReducers::InvokeCircleRecombineWithArgs(const FReducerEventContext& Context, const FCircleRecombineArgs& Args) -{ - if (!OnCircleRecombine.IsBound()) - { - if (InternalOnUnhandledReducerError.IsBound()) - { - InternalOnUnhandledReducerError.Broadcast(Context, TEXT("No handler registered for CircleRecombine")); - } - return false; - } - - OnCircleRecombine.Broadcast(Context, Args.Timer); - return true; -} - -void URemoteReducers::Connect() -{ - if (!Conn) - { - UE_LOG(LogTemp, Error, TEXT("SpacetimeDB connection is null")); - return; - } - - Conn->CallReducerTyped(TEXT("connect"), FConnectArgs(), SetCallReducerFlags); -} - -bool URemoteReducers::InvokeConnect(const FReducerEventContext& Context, const UConnectReducer* Args) -{ - if (!OnConnect.IsBound()) - { - // Handle unhandled reducer error - if (InternalOnUnhandledReducerError.IsBound()) - { - // TODO: Check Context.Event.Status for Failed/OutOfEnergy cases - // For now, just broadcast any error - InternalOnUnhandledReducerError.Broadcast(Context, TEXT("No handler registered for Connect")); - } - return false; - } - - OnConnect.Broadcast(Context); - return true; -} - -bool URemoteReducers::InvokeConnectWithArgs(const FReducerEventContext& Context, const FConnectArgs& Args) -{ - if (!OnConnect.IsBound()) - { - if (InternalOnUnhandledReducerError.IsBound()) - { - InternalOnUnhandledReducerError.Broadcast(Context, TEXT("No handler registered for Connect")); - } - return false; - } - - OnConnect.Broadcast(Context); - return true; -} - -void URemoteReducers::ConsumeEntity(const FConsumeEntityTimerType& Request) -{ - if (!Conn) - { - UE_LOG(LogTemp, Error, TEXT("SpacetimeDB connection is null")); - return; - } - - Conn->CallReducerTyped(TEXT("consume_entity"), FConsumeEntityArgs(Request), SetCallReducerFlags); -} - -bool URemoteReducers::InvokeConsumeEntity(const FReducerEventContext& Context, const UConsumeEntityReducer* Args) -{ - if (!OnConsumeEntity.IsBound()) - { - // Handle unhandled reducer error - if (InternalOnUnhandledReducerError.IsBound()) - { - // TODO: Check Context.Event.Status for Failed/OutOfEnergy cases - // For now, just broadcast any error - InternalOnUnhandledReducerError.Broadcast(Context, TEXT("No handler registered for ConsumeEntity")); - } - return false; - } - - OnConsumeEntity.Broadcast(Context, Args->Request); - return true; -} - -bool URemoteReducers::InvokeConsumeEntityWithArgs(const FReducerEventContext& Context, const FConsumeEntityArgs& Args) -{ - if (!OnConsumeEntity.IsBound()) - { - if (InternalOnUnhandledReducerError.IsBound()) - { - InternalOnUnhandledReducerError.Broadcast(Context, TEXT("No handler registered for ConsumeEntity")); - } - return false; - } - - OnConsumeEntity.Broadcast(Context, Args.Request); - return true; -} - -void URemoteReducers::Disconnect() -{ - if (!Conn) - { - UE_LOG(LogTemp, Error, TEXT("SpacetimeDB connection is null")); - return; - } - - Conn->CallReducerTyped(TEXT("disconnect"), FDisconnectArgs(), SetCallReducerFlags); -} - -bool URemoteReducers::InvokeDisconnect(const FReducerEventContext& Context, const UDisconnectReducer* Args) -{ - if (!OnDisconnect.IsBound()) - { - // Handle unhandled reducer error - if (InternalOnUnhandledReducerError.IsBound()) - { - // TODO: Check Context.Event.Status for Failed/OutOfEnergy cases - // For now, just broadcast any error - InternalOnUnhandledReducerError.Broadcast(Context, TEXT("No handler registered for Disconnect")); - } - return false; - } - - OnDisconnect.Broadcast(Context); - return true; -} - -bool URemoteReducers::InvokeDisconnectWithArgs(const FReducerEventContext& Context, const FDisconnectArgs& Args) -{ - if (!OnDisconnect.IsBound()) - { - if (InternalOnUnhandledReducerError.IsBound()) - { - InternalOnUnhandledReducerError.Broadcast(Context, TEXT("No handler registered for Disconnect")); - } - return false; - } - - OnDisconnect.Broadcast(Context); - return true; -} - void URemoteReducers::EnterGame(const FString& Name) { if (!Conn) @@ -472,50 +182,6 @@ bool URemoteReducers::InvokeEnterGameWithArgs(const FReducerEventContext& Contex return true; } -void URemoteReducers::MoveAllPlayers(const FMoveAllPlayersTimerType& Timer) -{ - if (!Conn) - { - UE_LOG(LogTemp, Error, TEXT("SpacetimeDB connection is null")); - return; - } - - Conn->CallReducerTyped(TEXT("move_all_players"), FMoveAllPlayersArgs(Timer), SetCallReducerFlags); -} - -bool URemoteReducers::InvokeMoveAllPlayers(const FReducerEventContext& Context, const UMoveAllPlayersReducer* Args) -{ - if (!OnMoveAllPlayers.IsBound()) - { - // Handle unhandled reducer error - if (InternalOnUnhandledReducerError.IsBound()) - { - // TODO: Check Context.Event.Status for Failed/OutOfEnergy cases - // For now, just broadcast any error - InternalOnUnhandledReducerError.Broadcast(Context, TEXT("No handler registered for MoveAllPlayers")); - } - return false; - } - - OnMoveAllPlayers.Broadcast(Context, Args->Timer); - return true; -} - -bool URemoteReducers::InvokeMoveAllPlayersWithArgs(const FReducerEventContext& Context, const FMoveAllPlayersArgs& Args) -{ - if (!OnMoveAllPlayers.IsBound()) - { - if (InternalOnUnhandledReducerError.IsBound()) - { - InternalOnUnhandledReducerError.Broadcast(Context, TEXT("No handler registered for MoveAllPlayers")); - } - return false; - } - - OnMoveAllPlayers.Broadcast(Context, Args.Timer); - return true; -} - void URemoteReducers::PlayerSplit() { if (!Conn) @@ -604,50 +270,6 @@ bool URemoteReducers::InvokeRespawnWithArgs(const FReducerEventContext& Context, return true; } -void URemoteReducers::SpawnFood(const FSpawnFoodTimerType& Timer) -{ - if (!Conn) - { - UE_LOG(LogTemp, Error, TEXT("SpacetimeDB connection is null")); - return; - } - - Conn->CallReducerTyped(TEXT("spawn_food"), FSpawnFoodArgs(Timer), SetCallReducerFlags); -} - -bool URemoteReducers::InvokeSpawnFood(const FReducerEventContext& Context, const USpawnFoodReducer* Args) -{ - if (!OnSpawnFood.IsBound()) - { - // Handle unhandled reducer error - if (InternalOnUnhandledReducerError.IsBound()) - { - // TODO: Check Context.Event.Status for Failed/OutOfEnergy cases - // For now, just broadcast any error - InternalOnUnhandledReducerError.Broadcast(Context, TEXT("No handler registered for SpawnFood")); - } - return false; - } - - OnSpawnFood.Broadcast(Context, Args->Timer); - return true; -} - -bool URemoteReducers::InvokeSpawnFoodWithArgs(const FReducerEventContext& Context, const FSpawnFoodArgs& Args) -{ - if (!OnSpawnFood.IsBound()) - { - if (InternalOnUnhandledReducerError.IsBound()) - { - InternalOnUnhandledReducerError.Broadcast(Context, TEXT("No handler registered for SpawnFood")); - } - return false; - } - - OnSpawnFood.Broadcast(Context, Args.Timer); - return true; -} - void URemoteReducers::Suicide() { if (!Conn) @@ -790,48 +412,12 @@ void UDbConnection::ReducerEvent(const FReducerEvent& Event) // Use hardcoded string matching for reducer dispatching const FString& ReducerName = Event.ReducerCall.ReducerName; - if (ReducerName == TEXT("circle_decay")) - { - FCircleDecayArgs Args = ReducerEvent.Reducer.GetAsCircleDecay(); - Reducers->InvokeCircleDecayWithArgs(Context, Args); - return; - } - if (ReducerName == TEXT("circle_recombine")) - { - FCircleRecombineArgs Args = ReducerEvent.Reducer.GetAsCircleRecombine(); - Reducers->InvokeCircleRecombineWithArgs(Context, Args); - return; - } - if (ReducerName == TEXT("connect")) - { - FConnectArgs Args = ReducerEvent.Reducer.GetAsConnect(); - Reducers->InvokeConnectWithArgs(Context, Args); - return; - } - if (ReducerName == TEXT("consume_entity")) - { - FConsumeEntityArgs Args = ReducerEvent.Reducer.GetAsConsumeEntity(); - Reducers->InvokeConsumeEntityWithArgs(Context, Args); - return; - } - if (ReducerName == TEXT("disconnect")) - { - FDisconnectArgs Args = ReducerEvent.Reducer.GetAsDisconnect(); - Reducers->InvokeDisconnectWithArgs(Context, Args); - return; - } if (ReducerName == TEXT("enter_game")) { FEnterGameArgs Args = ReducerEvent.Reducer.GetAsEnterGame(); Reducers->InvokeEnterGameWithArgs(Context, Args); return; } - if (ReducerName == TEXT("move_all_players")) - { - FMoveAllPlayersArgs Args = ReducerEvent.Reducer.GetAsMoveAllPlayers(); - Reducers->InvokeMoveAllPlayersWithArgs(Context, Args); - return; - } if (ReducerName == TEXT("player_split")) { FPlayerSplitArgs Args = ReducerEvent.Reducer.GetAsPlayerSplit(); @@ -844,12 +430,6 @@ void UDbConnection::ReducerEvent(const FReducerEvent& Event) Reducers->InvokeRespawnWithArgs(Context, Args); return; } - if (ReducerName == TEXT("spawn_food")) - { - FSpawnFoodArgs Args = ReducerEvent.Reducer.GetAsSpawnFood(); - Reducers->InvokeSpawnFoodWithArgs(Context, Args); - return; - } if (ReducerName == TEXT("suicide")) { FSuicideArgs Args = ReducerEvent.Reducer.GetAsSuicide(); diff --git a/demo/Blackholio/client-unreal/Source/client_unreal/Public/ModuleBindings/Reducers/CircleDecay.g.h b/demo/Blackholio/client-unreal/Source/client_unreal/Public/ModuleBindings/Reducers/CircleDecay.g.h deleted file mode 100644 index d2f2106c566..00000000000 --- a/demo/Blackholio/client-unreal/Source/client_unreal/Public/ModuleBindings/Reducers/CircleDecay.g.h +++ /dev/null @@ -1,54 +0,0 @@ -// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE -// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. - -#pragma once -#include "CoreMinimal.h" -#include "BSATN/UESpacetimeDB.h" -#include "ModuleBindings/ReducerBase.g.h" -#include "ModuleBindings/Types/CircleDecayTimerType.g.h" -#include "CircleDecay.g.generated.h" - -// Reducer arguments struct for CircleDecay -USTRUCT(BlueprintType) -struct CLIENT_UNREAL_API FCircleDecayArgs -{ - GENERATED_BODY() - - UPROPERTY(BlueprintReadWrite, Category="SpacetimeDB") - FCircleDecayTimerType Timer; - - FCircleDecayArgs() = default; - - FCircleDecayArgs(const FCircleDecayTimerType& InTimer) - : Timer(InTimer) - {} - - - FORCEINLINE bool operator==(const FCircleDecayArgs& Other) const - { - return Timer == Other.Timer; - } - FORCEINLINE bool operator!=(const FCircleDecayArgs& Other) const - { - return !(*this == Other); - } -}; - -namespace UE::SpacetimeDB -{ - UE_SPACETIMEDB_STRUCT(FCircleDecayArgs, Timer); -} - -// Reducer class for internal dispatching -UCLASS(BlueprintType) -class CLIENT_UNREAL_API UCircleDecayReducer : public UReducerBase -{ - GENERATED_BODY() - -public: - UPROPERTY(BlueprintReadOnly, Category="SpacetimeDB") - FCircleDecayTimerType Timer; - -}; - - diff --git a/demo/Blackholio/client-unreal/Source/client_unreal/Public/ModuleBindings/Reducers/CircleRecombine.g.h b/demo/Blackholio/client-unreal/Source/client_unreal/Public/ModuleBindings/Reducers/CircleRecombine.g.h deleted file mode 100644 index 411925d3334..00000000000 --- a/demo/Blackholio/client-unreal/Source/client_unreal/Public/ModuleBindings/Reducers/CircleRecombine.g.h +++ /dev/null @@ -1,54 +0,0 @@ -// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE -// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. - -#pragma once -#include "CoreMinimal.h" -#include "BSATN/UESpacetimeDB.h" -#include "ModuleBindings/ReducerBase.g.h" -#include "ModuleBindings/Types/CircleRecombineTimerType.g.h" -#include "CircleRecombine.g.generated.h" - -// Reducer arguments struct for CircleRecombine -USTRUCT(BlueprintType) -struct CLIENT_UNREAL_API FCircleRecombineArgs -{ - GENERATED_BODY() - - UPROPERTY(BlueprintReadWrite, Category="SpacetimeDB") - FCircleRecombineTimerType Timer; - - FCircleRecombineArgs() = default; - - FCircleRecombineArgs(const FCircleRecombineTimerType& InTimer) - : Timer(InTimer) - {} - - - FORCEINLINE bool operator==(const FCircleRecombineArgs& Other) const - { - return Timer == Other.Timer; - } - FORCEINLINE bool operator!=(const FCircleRecombineArgs& Other) const - { - return !(*this == Other); - } -}; - -namespace UE::SpacetimeDB -{ - UE_SPACETIMEDB_STRUCT(FCircleRecombineArgs, Timer); -} - -// Reducer class for internal dispatching -UCLASS(BlueprintType) -class CLIENT_UNREAL_API UCircleRecombineReducer : public UReducerBase -{ - GENERATED_BODY() - -public: - UPROPERTY(BlueprintReadOnly, Category="SpacetimeDB") - FCircleRecombineTimerType Timer; - -}; - - diff --git a/demo/Blackholio/client-unreal/Source/client_unreal/Public/ModuleBindings/Reducers/Connect.g.h b/demo/Blackholio/client-unreal/Source/client_unreal/Public/ModuleBindings/Reducers/Connect.g.h deleted file mode 100644 index 3f4aa4d29d0..00000000000 --- a/demo/Blackholio/client-unreal/Source/client_unreal/Public/ModuleBindings/Reducers/Connect.g.h +++ /dev/null @@ -1,43 +0,0 @@ -// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE -// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. - -#pragma once -#include "CoreMinimal.h" -#include "BSATN/UESpacetimeDB.h" -#include "ModuleBindings/ReducerBase.g.h" -#include "Connect.g.generated.h" - -// Reducer arguments struct for Connect -USTRUCT(BlueprintType) -struct CLIENT_UNREAL_API FConnectArgs -{ - GENERATED_BODY() - - FConnectArgs() = default; - - - FORCEINLINE bool operator==(const FConnectArgs& Other) const - { - return true; - } - FORCEINLINE bool operator!=(const FConnectArgs& Other) const - { - return !(*this == Other); - } -}; - -namespace UE::SpacetimeDB -{ - UE_SPACETIMEDB_STRUCT_EMPTY(FConnectArgs); -} - -// Reducer class for internal dispatching -UCLASS(BlueprintType) -class CLIENT_UNREAL_API UConnectReducer : public UReducerBase -{ - GENERATED_BODY() - -public: -}; - - diff --git a/demo/Blackholio/client-unreal/Source/client_unreal/Public/ModuleBindings/Reducers/ConsumeEntity.g.h b/demo/Blackholio/client-unreal/Source/client_unreal/Public/ModuleBindings/Reducers/ConsumeEntity.g.h deleted file mode 100644 index c51fb1909da..00000000000 --- a/demo/Blackholio/client-unreal/Source/client_unreal/Public/ModuleBindings/Reducers/ConsumeEntity.g.h +++ /dev/null @@ -1,54 +0,0 @@ -// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE -// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. - -#pragma once -#include "CoreMinimal.h" -#include "BSATN/UESpacetimeDB.h" -#include "ModuleBindings/ReducerBase.g.h" -#include "ModuleBindings/Types/ConsumeEntityTimerType.g.h" -#include "ConsumeEntity.g.generated.h" - -// Reducer arguments struct for ConsumeEntity -USTRUCT(BlueprintType) -struct CLIENT_UNREAL_API FConsumeEntityArgs -{ - GENERATED_BODY() - - UPROPERTY(BlueprintReadWrite, Category="SpacetimeDB") - FConsumeEntityTimerType Request; - - FConsumeEntityArgs() = default; - - FConsumeEntityArgs(const FConsumeEntityTimerType& InRequest) - : Request(InRequest) - {} - - - FORCEINLINE bool operator==(const FConsumeEntityArgs& Other) const - { - return Request == Other.Request; - } - FORCEINLINE bool operator!=(const FConsumeEntityArgs& Other) const - { - return !(*this == Other); - } -}; - -namespace UE::SpacetimeDB -{ - UE_SPACETIMEDB_STRUCT(FConsumeEntityArgs, Request); -} - -// Reducer class for internal dispatching -UCLASS(BlueprintType) -class CLIENT_UNREAL_API UConsumeEntityReducer : public UReducerBase -{ - GENERATED_BODY() - -public: - UPROPERTY(BlueprintReadOnly, Category="SpacetimeDB") - FConsumeEntityTimerType Request; - -}; - - diff --git a/demo/Blackholio/client-unreal/Source/client_unreal/Public/ModuleBindings/Reducers/Disconnect.g.h b/demo/Blackholio/client-unreal/Source/client_unreal/Public/ModuleBindings/Reducers/Disconnect.g.h deleted file mode 100644 index 1149cdb400e..00000000000 --- a/demo/Blackholio/client-unreal/Source/client_unreal/Public/ModuleBindings/Reducers/Disconnect.g.h +++ /dev/null @@ -1,43 +0,0 @@ -// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE -// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. - -#pragma once -#include "CoreMinimal.h" -#include "BSATN/UESpacetimeDB.h" -#include "ModuleBindings/ReducerBase.g.h" -#include "Disconnect.g.generated.h" - -// Reducer arguments struct for Disconnect -USTRUCT(BlueprintType) -struct CLIENT_UNREAL_API FDisconnectArgs -{ - GENERATED_BODY() - - FDisconnectArgs() = default; - - - FORCEINLINE bool operator==(const FDisconnectArgs& Other) const - { - return true; - } - FORCEINLINE bool operator!=(const FDisconnectArgs& Other) const - { - return !(*this == Other); - } -}; - -namespace UE::SpacetimeDB -{ - UE_SPACETIMEDB_STRUCT_EMPTY(FDisconnectArgs); -} - -// Reducer class for internal dispatching -UCLASS(BlueprintType) -class CLIENT_UNREAL_API UDisconnectReducer : public UReducerBase -{ - GENERATED_BODY() - -public: -}; - - diff --git a/demo/Blackholio/client-unreal/Source/client_unreal/Public/ModuleBindings/Reducers/MoveAllPlayers.g.h b/demo/Blackholio/client-unreal/Source/client_unreal/Public/ModuleBindings/Reducers/MoveAllPlayers.g.h deleted file mode 100644 index 421c680f6ee..00000000000 --- a/demo/Blackholio/client-unreal/Source/client_unreal/Public/ModuleBindings/Reducers/MoveAllPlayers.g.h +++ /dev/null @@ -1,54 +0,0 @@ -// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE -// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. - -#pragma once -#include "CoreMinimal.h" -#include "BSATN/UESpacetimeDB.h" -#include "ModuleBindings/ReducerBase.g.h" -#include "ModuleBindings/Types/MoveAllPlayersTimerType.g.h" -#include "MoveAllPlayers.g.generated.h" - -// Reducer arguments struct for MoveAllPlayers -USTRUCT(BlueprintType) -struct CLIENT_UNREAL_API FMoveAllPlayersArgs -{ - GENERATED_BODY() - - UPROPERTY(BlueprintReadWrite, Category="SpacetimeDB") - FMoveAllPlayersTimerType Timer; - - FMoveAllPlayersArgs() = default; - - FMoveAllPlayersArgs(const FMoveAllPlayersTimerType& InTimer) - : Timer(InTimer) - {} - - - FORCEINLINE bool operator==(const FMoveAllPlayersArgs& Other) const - { - return Timer == Other.Timer; - } - FORCEINLINE bool operator!=(const FMoveAllPlayersArgs& Other) const - { - return !(*this == Other); - } -}; - -namespace UE::SpacetimeDB -{ - UE_SPACETIMEDB_STRUCT(FMoveAllPlayersArgs, Timer); -} - -// Reducer class for internal dispatching -UCLASS(BlueprintType) -class CLIENT_UNREAL_API UMoveAllPlayersReducer : public UReducerBase -{ - GENERATED_BODY() - -public: - UPROPERTY(BlueprintReadOnly, Category="SpacetimeDB") - FMoveAllPlayersTimerType Timer; - -}; - - diff --git a/demo/Blackholio/client-unreal/Source/client_unreal/Public/ModuleBindings/Reducers/SpawnFood.g.h b/demo/Blackholio/client-unreal/Source/client_unreal/Public/ModuleBindings/Reducers/SpawnFood.g.h deleted file mode 100644 index f3aaaa6c777..00000000000 --- a/demo/Blackholio/client-unreal/Source/client_unreal/Public/ModuleBindings/Reducers/SpawnFood.g.h +++ /dev/null @@ -1,54 +0,0 @@ -// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE -// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. - -#pragma once -#include "CoreMinimal.h" -#include "BSATN/UESpacetimeDB.h" -#include "ModuleBindings/ReducerBase.g.h" -#include "ModuleBindings/Types/SpawnFoodTimerType.g.h" -#include "SpawnFood.g.generated.h" - -// Reducer arguments struct for SpawnFood -USTRUCT(BlueprintType) -struct CLIENT_UNREAL_API FSpawnFoodArgs -{ - GENERATED_BODY() - - UPROPERTY(BlueprintReadWrite, Category="SpacetimeDB") - FSpawnFoodTimerType Timer; - - FSpawnFoodArgs() = default; - - FSpawnFoodArgs(const FSpawnFoodTimerType& InTimer) - : Timer(InTimer) - {} - - - FORCEINLINE bool operator==(const FSpawnFoodArgs& Other) const - { - return Timer == Other.Timer; - } - FORCEINLINE bool operator!=(const FSpawnFoodArgs& Other) const - { - return !(*this == Other); - } -}; - -namespace UE::SpacetimeDB -{ - UE_SPACETIMEDB_STRUCT(FSpawnFoodArgs, Timer); -} - -// Reducer class for internal dispatching -UCLASS(BlueprintType) -class CLIENT_UNREAL_API USpawnFoodReducer : public UReducerBase -{ - GENERATED_BODY() - -public: - UPROPERTY(BlueprintReadOnly, Category="SpacetimeDB") - FSpawnFoodTimerType Timer; - -}; - - diff --git a/demo/Blackholio/client-unreal/Source/client_unreal/Public/ModuleBindings/SpacetimeDBClient.g.h b/demo/Blackholio/client-unreal/Source/client_unreal/Public/ModuleBindings/SpacetimeDBClient.g.h index 8092d9eb856..0ba004f7427 100644 --- a/demo/Blackholio/client-unreal/Source/client_unreal/Public/ModuleBindings/SpacetimeDBClient.g.h +++ b/demo/Blackholio/client-unreal/Source/client_unreal/Public/ModuleBindings/SpacetimeDBClient.g.h @@ -1,7 +1,7 @@ // 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 cli version 1.12.0 (commit 5da121d888d60ce178c202e54816252d2e4d4451). +// This was generated using spacetimedb cli version 2.0.0 (commit 9e0e81a6aaec6bf3619cfb9f7916743d86ab7ffc). #pragma once #include "CoreMinimal.h" @@ -13,24 +13,12 @@ #include "Connection/Subscription.h" #include "Kismet/BlueprintFunctionLibrary.h" #include "ModuleBindings/ReducerBase.g.h" -#include "ModuleBindings/Reducers/CircleDecay.g.h" -#include "ModuleBindings/Reducers/CircleRecombine.g.h" -#include "ModuleBindings/Reducers/Connect.g.h" -#include "ModuleBindings/Reducers/ConsumeEntity.g.h" -#include "ModuleBindings/Reducers/Disconnect.g.h" #include "ModuleBindings/Reducers/EnterGame.g.h" -#include "ModuleBindings/Reducers/MoveAllPlayers.g.h" #include "ModuleBindings/Reducers/PlayerSplit.g.h" #include "ModuleBindings/Reducers/Respawn.g.h" -#include "ModuleBindings/Reducers/SpawnFood.g.h" #include "ModuleBindings/Reducers/Suicide.g.h" #include "ModuleBindings/Reducers/UpdatePlayerInput.g.h" -#include "ModuleBindings/Types/CircleDecayTimerType.g.h" -#include "ModuleBindings/Types/CircleRecombineTimerType.g.h" -#include "ModuleBindings/Types/ConsumeEntityTimerType.g.h" #include "ModuleBindings/Types/DbVector2Type.g.h" -#include "ModuleBindings/Types/MoveAllPlayersTimerType.g.h" -#include "ModuleBindings/Types/SpawnFoodTimerType.g.h" #include "Types/Builtins.h" #include "SpacetimeDBClient.g.generated.h" @@ -123,16 +111,9 @@ class CLIENT_UNREAL_API UContextBaseBpLib : public UBlueprintFunctionLibrary UENUM(BlueprintType, Category = "SpacetimeDB") enum class EReducerTag : uint8 { - CircleDecay, - CircleRecombine, - Connect, - ConsumeEntity, - Disconnect, EnterGame, - MoveAllPlayers, PlayerSplit, Respawn, - SpawnFood, Suicide, UpdatePlayerInput }; @@ -146,7 +127,7 @@ struct CLIENT_UNREAL_API FReducer UPROPERTY(BlueprintReadOnly, Category = "SpacetimeDB") EReducerTag Tag = static_cast(0); - TVariant Data; + TVariant Data; // Optional metadata UPROPERTY(BlueprintReadOnly, Category = "SpacetimeDB") @@ -154,86 +135,6 @@ struct CLIENT_UNREAL_API FReducer uint32 ReducerId = 0; uint32 RequestId = 0; - static FReducer CircleDecay(const FCircleDecayArgs& Value) - { - FReducer Out; - Out.Tag = EReducerTag::CircleDecay; - Out.Data.Set(Value); - Out.ReducerName = TEXT("circle_decay"); - return Out; - } - - FORCEINLINE bool IsCircleDecay() const { return Tag == EReducerTag::CircleDecay; } - FORCEINLINE FCircleDecayArgs GetAsCircleDecay() const - { - ensureMsgf(IsCircleDecay(), TEXT("Reducer does not hold CircleDecay!")); - return Data.Get(); - } - - static FReducer CircleRecombine(const FCircleRecombineArgs& Value) - { - FReducer Out; - Out.Tag = EReducerTag::CircleRecombine; - Out.Data.Set(Value); - Out.ReducerName = TEXT("circle_recombine"); - return Out; - } - - FORCEINLINE bool IsCircleRecombine() const { return Tag == EReducerTag::CircleRecombine; } - FORCEINLINE FCircleRecombineArgs GetAsCircleRecombine() const - { - ensureMsgf(IsCircleRecombine(), TEXT("Reducer does not hold CircleRecombine!")); - return Data.Get(); - } - - static FReducer Connect(const FConnectArgs& Value) - { - FReducer Out; - Out.Tag = EReducerTag::Connect; - Out.Data.Set(Value); - Out.ReducerName = TEXT("connect"); - return Out; - } - - FORCEINLINE bool IsConnect() const { return Tag == EReducerTag::Connect; } - FORCEINLINE FConnectArgs GetAsConnect() const - { - ensureMsgf(IsConnect(), TEXT("Reducer does not hold Connect!")); - return Data.Get(); - } - - static FReducer ConsumeEntity(const FConsumeEntityArgs& Value) - { - FReducer Out; - Out.Tag = EReducerTag::ConsumeEntity; - Out.Data.Set(Value); - Out.ReducerName = TEXT("consume_entity"); - return Out; - } - - FORCEINLINE bool IsConsumeEntity() const { return Tag == EReducerTag::ConsumeEntity; } - FORCEINLINE FConsumeEntityArgs GetAsConsumeEntity() const - { - ensureMsgf(IsConsumeEntity(), TEXT("Reducer does not hold ConsumeEntity!")); - return Data.Get(); - } - - static FReducer Disconnect(const FDisconnectArgs& Value) - { - FReducer Out; - Out.Tag = EReducerTag::Disconnect; - Out.Data.Set(Value); - Out.ReducerName = TEXT("disconnect"); - return Out; - } - - FORCEINLINE bool IsDisconnect() const { return Tag == EReducerTag::Disconnect; } - FORCEINLINE FDisconnectArgs GetAsDisconnect() const - { - ensureMsgf(IsDisconnect(), TEXT("Reducer does not hold Disconnect!")); - return Data.Get(); - } - static FReducer EnterGame(const FEnterGameArgs& Value) { FReducer Out; @@ -250,22 +151,6 @@ struct CLIENT_UNREAL_API FReducer return Data.Get(); } - static FReducer MoveAllPlayers(const FMoveAllPlayersArgs& Value) - { - FReducer Out; - Out.Tag = EReducerTag::MoveAllPlayers; - Out.Data.Set(Value); - Out.ReducerName = TEXT("move_all_players"); - return Out; - } - - FORCEINLINE bool IsMoveAllPlayers() const { return Tag == EReducerTag::MoveAllPlayers; } - FORCEINLINE FMoveAllPlayersArgs GetAsMoveAllPlayers() const - { - ensureMsgf(IsMoveAllPlayers(), TEXT("Reducer does not hold MoveAllPlayers!")); - return Data.Get(); - } - static FReducer PlayerSplit(const FPlayerSplitArgs& Value) { FReducer Out; @@ -298,22 +183,6 @@ struct CLIENT_UNREAL_API FReducer return Data.Get(); } - static FReducer SpawnFood(const FSpawnFoodArgs& Value) - { - FReducer Out; - Out.Tag = EReducerTag::SpawnFood; - Out.Data.Set(Value); - Out.ReducerName = TEXT("spawn_food"); - return Out; - } - - FORCEINLINE bool IsSpawnFood() const { return Tag == EReducerTag::SpawnFood; } - FORCEINLINE FSpawnFoodArgs GetAsSpawnFood() const - { - ensureMsgf(IsSpawnFood(), TEXT("Reducer does not hold SpawnFood!")); - return Data.Get(); - } - static FReducer Suicide(const FSuicideArgs& Value) { FReducer Out; @@ -351,26 +220,12 @@ struct CLIENT_UNREAL_API FReducer if (Tag != Other.Tag || ReducerId != Other.ReducerId || RequestId != Other.RequestId || ReducerName != Other.ReducerName) return false; switch (Tag) { - case EReducerTag::CircleDecay: - return GetAsCircleDecay() == Other.GetAsCircleDecay(); - case EReducerTag::CircleRecombine: - return GetAsCircleRecombine() == Other.GetAsCircleRecombine(); - case EReducerTag::Connect: - return GetAsConnect() == Other.GetAsConnect(); - case EReducerTag::ConsumeEntity: - return GetAsConsumeEntity() == Other.GetAsConsumeEntity(); - case EReducerTag::Disconnect: - return GetAsDisconnect() == Other.GetAsDisconnect(); case EReducerTag::EnterGame: return GetAsEnterGame() == Other.GetAsEnterGame(); - case EReducerTag::MoveAllPlayers: - return GetAsMoveAllPlayers() == Other.GetAsMoveAllPlayers(); case EReducerTag::PlayerSplit: return GetAsPlayerSplit() == Other.GetAsPlayerSplit(); case EReducerTag::Respawn: return GetAsRespawn() == Other.GetAsRespawn(); - case EReducerTag::SpawnFood: - return GetAsSpawnFood() == Other.GetAsSpawnFood(); case EReducerTag::Suicide: return GetAsSuicide() == Other.GetAsSuicide(); case EReducerTag::UpdatePlayerInput: @@ -388,71 +243,6 @@ class CLIENT_UNREAL_API UReducerBpLib : public UBlueprintFunctionLibrary private: - UFUNCTION(BlueprintCallable, Category = "SpacetimeDB|Reducer") - static FReducer CircleDecay(const FCircleDecayArgs& Value) { - return FReducer::CircleDecay(Value); - } - - UFUNCTION(BlueprintPure, Category = "SpacetimeDB|Reducer") - static bool IsCircleDecay(const FReducer& Reducer) { return Reducer.IsCircleDecay(); } - - UFUNCTION(BlueprintPure, Category = "SpacetimeDB|Reducer") - static FCircleDecayArgs GetAsCircleDecay(const FReducer& Reducer) { - return Reducer.GetAsCircleDecay(); - } - - UFUNCTION(BlueprintCallable, Category = "SpacetimeDB|Reducer") - static FReducer CircleRecombine(const FCircleRecombineArgs& Value) { - return FReducer::CircleRecombine(Value); - } - - UFUNCTION(BlueprintPure, Category = "SpacetimeDB|Reducer") - static bool IsCircleRecombine(const FReducer& Reducer) { return Reducer.IsCircleRecombine(); } - - UFUNCTION(BlueprintPure, Category = "SpacetimeDB|Reducer") - static FCircleRecombineArgs GetAsCircleRecombine(const FReducer& Reducer) { - return Reducer.GetAsCircleRecombine(); - } - - UFUNCTION(BlueprintCallable, Category = "SpacetimeDB|Reducer") - static FReducer Connect(const FConnectArgs& Value) { - return FReducer::Connect(Value); - } - - UFUNCTION(BlueprintPure, Category = "SpacetimeDB|Reducer") - static bool IsConnect(const FReducer& Reducer) { return Reducer.IsConnect(); } - - UFUNCTION(BlueprintPure, Category = "SpacetimeDB|Reducer") - static FConnectArgs GetAsConnect(const FReducer& Reducer) { - return Reducer.GetAsConnect(); - } - - UFUNCTION(BlueprintCallable, Category = "SpacetimeDB|Reducer") - static FReducer ConsumeEntity(const FConsumeEntityArgs& Value) { - return FReducer::ConsumeEntity(Value); - } - - UFUNCTION(BlueprintPure, Category = "SpacetimeDB|Reducer") - static bool IsConsumeEntity(const FReducer& Reducer) { return Reducer.IsConsumeEntity(); } - - UFUNCTION(BlueprintPure, Category = "SpacetimeDB|Reducer") - static FConsumeEntityArgs GetAsConsumeEntity(const FReducer& Reducer) { - return Reducer.GetAsConsumeEntity(); - } - - UFUNCTION(BlueprintCallable, Category = "SpacetimeDB|Reducer") - static FReducer Disconnect(const FDisconnectArgs& Value) { - return FReducer::Disconnect(Value); - } - - UFUNCTION(BlueprintPure, Category = "SpacetimeDB|Reducer") - static bool IsDisconnect(const FReducer& Reducer) { return Reducer.IsDisconnect(); } - - UFUNCTION(BlueprintPure, Category = "SpacetimeDB|Reducer") - static FDisconnectArgs GetAsDisconnect(const FReducer& Reducer) { - return Reducer.GetAsDisconnect(); - } - UFUNCTION(BlueprintCallable, Category = "SpacetimeDB|Reducer") static FReducer EnterGame(const FEnterGameArgs& Value) { return FReducer::EnterGame(Value); @@ -466,19 +256,6 @@ class CLIENT_UNREAL_API UReducerBpLib : public UBlueprintFunctionLibrary return Reducer.GetAsEnterGame(); } - UFUNCTION(BlueprintCallable, Category = "SpacetimeDB|Reducer") - static FReducer MoveAllPlayers(const FMoveAllPlayersArgs& Value) { - return FReducer::MoveAllPlayers(Value); - } - - UFUNCTION(BlueprintPure, Category = "SpacetimeDB|Reducer") - static bool IsMoveAllPlayers(const FReducer& Reducer) { return Reducer.IsMoveAllPlayers(); } - - UFUNCTION(BlueprintPure, Category = "SpacetimeDB|Reducer") - static FMoveAllPlayersArgs GetAsMoveAllPlayers(const FReducer& Reducer) { - return Reducer.GetAsMoveAllPlayers(); - } - UFUNCTION(BlueprintCallable, Category = "SpacetimeDB|Reducer") static FReducer PlayerSplit(const FPlayerSplitArgs& Value) { return FReducer::PlayerSplit(Value); @@ -505,19 +282,6 @@ class CLIENT_UNREAL_API UReducerBpLib : public UBlueprintFunctionLibrary return Reducer.GetAsRespawn(); } - UFUNCTION(BlueprintCallable, Category = "SpacetimeDB|Reducer") - static FReducer SpawnFood(const FSpawnFoodArgs& Value) { - return FReducer::SpawnFood(Value); - } - - UFUNCTION(BlueprintPure, Category = "SpacetimeDB|Reducer") - static bool IsSpawnFood(const FReducer& Reducer) { return Reducer.IsSpawnFood(); } - - UFUNCTION(BlueprintPure, Category = "SpacetimeDB|Reducer") - static FSpawnFoodArgs GetAsSpawnFood(const FReducer& Reducer) { - return Reducer.GetAsSpawnFood(); - } - UFUNCTION(BlueprintCallable, Category = "SpacetimeDB|Reducer") static FReducer Suicide(const FSuicideArgs& Value) { return FReducer::Suicide(Value); @@ -923,27 +687,13 @@ class CLIENT_UNREAL_API USetReducerFlags : public USetReducerFlagsBase GENERATED_BODY() public: - UFUNCTION(BlueprintCallable, Category = "SpacetimeDB") - void CircleDecay(ECallReducerFlags Flag); - UFUNCTION(BlueprintCallable, Category = "SpacetimeDB") - void CircleRecombine(ECallReducerFlags Flag); - UFUNCTION(BlueprintCallable, Category = "SpacetimeDB") - void Connect(ECallReducerFlags Flag); - UFUNCTION(BlueprintCallable, Category = "SpacetimeDB") - void ConsumeEntity(ECallReducerFlags Flag); - UFUNCTION(BlueprintCallable, Category = "SpacetimeDB") - void Disconnect(ECallReducerFlags Flag); UFUNCTION(BlueprintCallable, Category = "SpacetimeDB") void EnterGame(ECallReducerFlags Flag); UFUNCTION(BlueprintCallable, Category = "SpacetimeDB") - void MoveAllPlayers(ECallReducerFlags Flag); - UFUNCTION(BlueprintCallable, Category = "SpacetimeDB") void PlayerSplit(ECallReducerFlags Flag); UFUNCTION(BlueprintCallable, Category = "SpacetimeDB") void Respawn(ECallReducerFlags Flag); UFUNCTION(BlueprintCallable, Category = "SpacetimeDB") - void SpawnFood(ECallReducerFlags Flag); - UFUNCTION(BlueprintCallable, Category = "SpacetimeDB") void Suicide(ECallReducerFlags Flag); UFUNCTION(BlueprintCallable, Category = "SpacetimeDB") void UpdatePlayerInput(ECallReducerFlags Flag); @@ -984,74 +734,6 @@ class CLIENT_UNREAL_API URemoteReducers : public UObject public: - DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams( - FCircleDecayHandler, - const FReducerEventContext&, Context, - const FCircleDecayTimerType&, Timer - ); - UPROPERTY(BlueprintAssignable, Category="SpacetimeDB") - FCircleDecayHandler OnCircleDecay; - - UFUNCTION(BlueprintCallable, Category="SpacetimeDB") - void CircleDecay(const FCircleDecayTimerType& Timer); - - bool InvokeCircleDecay(const FReducerEventContext& Context, const UCircleDecayReducer* Args); - bool InvokeCircleDecayWithArgs(const FReducerEventContext& Context, const FCircleDecayArgs& Args); - - DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams( - FCircleRecombineHandler, - const FReducerEventContext&, Context, - const FCircleRecombineTimerType&, Timer - ); - UPROPERTY(BlueprintAssignable, Category="SpacetimeDB") - FCircleRecombineHandler OnCircleRecombine; - - UFUNCTION(BlueprintCallable, Category="SpacetimeDB") - void CircleRecombine(const FCircleRecombineTimerType& Timer); - - bool InvokeCircleRecombine(const FReducerEventContext& Context, const UCircleRecombineReducer* Args); - bool InvokeCircleRecombineWithArgs(const FReducerEventContext& Context, const FCircleRecombineArgs& Args); - - DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam( - FConnectHandler, - const FReducerEventContext&, Context - ); - UPROPERTY(BlueprintAssignable, Category="SpacetimeDB") - FConnectHandler OnConnect; - - UFUNCTION(BlueprintCallable, Category="SpacetimeDB") - void Connect(); - - bool InvokeConnect(const FReducerEventContext& Context, const UConnectReducer* Args); - bool InvokeConnectWithArgs(const FReducerEventContext& Context, const FConnectArgs& Args); - - DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams( - FConsumeEntityHandler, - const FReducerEventContext&, Context, - const FConsumeEntityTimerType&, Request - ); - UPROPERTY(BlueprintAssignable, Category="SpacetimeDB") - FConsumeEntityHandler OnConsumeEntity; - - UFUNCTION(BlueprintCallable, Category="SpacetimeDB") - void ConsumeEntity(const FConsumeEntityTimerType& Request); - - bool InvokeConsumeEntity(const FReducerEventContext& Context, const UConsumeEntityReducer* Args); - bool InvokeConsumeEntityWithArgs(const FReducerEventContext& Context, const FConsumeEntityArgs& Args); - - DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam( - FDisconnectHandler, - const FReducerEventContext&, Context - ); - UPROPERTY(BlueprintAssignable, Category="SpacetimeDB") - FDisconnectHandler OnDisconnect; - - UFUNCTION(BlueprintCallable, Category="SpacetimeDB") - void Disconnect(); - - bool InvokeDisconnect(const FReducerEventContext& Context, const UDisconnectReducer* Args); - bool InvokeDisconnectWithArgs(const FReducerEventContext& Context, const FDisconnectArgs& Args); - DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams( FEnterGameHandler, const FReducerEventContext&, Context, @@ -1066,20 +748,6 @@ class CLIENT_UNREAL_API URemoteReducers : public UObject bool InvokeEnterGame(const FReducerEventContext& Context, const UEnterGameReducer* Args); bool InvokeEnterGameWithArgs(const FReducerEventContext& Context, const FEnterGameArgs& Args); - DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams( - FMoveAllPlayersHandler, - const FReducerEventContext&, Context, - const FMoveAllPlayersTimerType&, Timer - ); - UPROPERTY(BlueprintAssignable, Category="SpacetimeDB") - FMoveAllPlayersHandler OnMoveAllPlayers; - - UFUNCTION(BlueprintCallable, Category="SpacetimeDB") - void MoveAllPlayers(const FMoveAllPlayersTimerType& Timer); - - bool InvokeMoveAllPlayers(const FReducerEventContext& Context, const UMoveAllPlayersReducer* Args); - bool InvokeMoveAllPlayersWithArgs(const FReducerEventContext& Context, const FMoveAllPlayersArgs& Args); - DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam( FPlayerSplitHandler, const FReducerEventContext&, Context @@ -1106,20 +774,6 @@ class CLIENT_UNREAL_API URemoteReducers : public UObject bool InvokeRespawn(const FReducerEventContext& Context, const URespawnReducer* Args); bool InvokeRespawnWithArgs(const FReducerEventContext& Context, const FRespawnArgs& Args); - DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams( - FSpawnFoodHandler, - const FReducerEventContext&, Context, - const FSpawnFoodTimerType&, Timer - ); - UPROPERTY(BlueprintAssignable, Category="SpacetimeDB") - FSpawnFoodHandler OnSpawnFood; - - UFUNCTION(BlueprintCallable, Category="SpacetimeDB") - void SpawnFood(const FSpawnFoodTimerType& Timer); - - bool InvokeSpawnFood(const FReducerEventContext& Context, const USpawnFoodReducer* Args); - bool InvokeSpawnFoodWithArgs(const FReducerEventContext& Context, const FSpawnFoodArgs& Args); - DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam( FSuicideHandler, const FReducerEventContext&, Context diff --git a/docs/docs/00200-core-concepts/00300-tables/00550-event-tables.md b/docs/docs/00200-core-concepts/00300-tables/00550-event-tables.md new file mode 100644 index 00000000000..a0d381d16aa --- /dev/null +++ b/docs/docs/00200-core-concepts/00300-tables/00550-event-tables.md @@ -0,0 +1,209 @@ +--- +title: Event Tables +slug: /tables/event-tables +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +In many applications, particularly games and real-time systems, modules need to notify clients about things that happened without storing that information permanently. A combat system might need to tell clients "entity X took 50 damage" so they can display a floating damage number, but there is no reason to keep that record in the database after the moment has passed. + +Event tables provide exactly this capability. An event table is a table whose rows are inserted and then immediately deleted by the database: they exist only for the duration of the transaction that created them. When the transaction commits, the rows are broadcast to subscribed clients and then deleted from the table. Between transactions, the table is always empty. + +From the module's perspective, event tables behave like regular tables during a reducer's execution. You insert rows, query them, and apply constraints just as you would with any other table. The difference is purely in what happens after the transaction completes: rather than merging the rows into the committed database state, SpacetimeDB publishes them to subscribers and deletes them from the table. The inserts are still recorded in the commitlog, so a full history of events is preserved. + +## Defining an Event Table + +To declare a table as an event table, add the `event` attribute to the table definition. Event tables support all the same column types, constraints, indexes, and auto-increment fields as regular tables. + + + + +```typescript +const damageEvent = table({ + public: true, + event: true, +}, { + entity_id: t.identity(), + damage: t.u32(), + source: t.string(), +}); +``` + + + + +```csharp +[SpacetimeDB.Table(Public = true, Event = true)] +public partial struct DamageEvent +{ + public Identity EntityId; + public uint Damage; + public string Source; +} +``` + + + + +```rust +#[spacetimedb::table(name = damage_event, public, event)] +pub struct DamageEvent { + pub entity_id: Identity, + pub damage: u32, + pub source: String, +} +``` + + + + +:::note Changing the event flag +Once a table has been published as an event table (or a regular table), the `event` flag cannot be changed in a subsequent module update. Attempting to convert a regular table to an event table or vice versa will produce a migration error. +::: + +## Publishing Events + +To publish an event, simply insert a row into the event table from within a reducer. The insertion works exactly like inserting into a regular table. The row is visible within the current transaction and can be queried or used in constraints. When the transaction commits successfully, the row is broadcast to all subscribed clients. If the reducer panics or the transaction is rolled back, no events are sent. + + + + +```typescript +export const attack = spacetimedb.reducer( + { target_id: t.identity(), damage: t.u32() }, + (ctx, { target_id, damage }) => { + // Game logic... + + // Publish the event + ctx.db.damageEvent.insert({ + entity_id: target_id, + damage, + source: "melee_attack", + }); + } +); +``` + + + + +```csharp +[SpacetimeDB.Reducer] +public static void Attack(ReducerContext ctx, Identity targetId, uint damage) +{ + // Game logic... + + // Publish the event + ctx.Db.DamageEvent.Insert(new DamageEvent + { + EntityId = targetId, + Damage = damage, + Source = "melee_attack" + }); +} +``` + + + + +```rust +#[spacetimedb::reducer] +fn attack(ctx: &ReducerContext, target_id: Identity, damage: u32) { + // Game logic... + + // Publish the event + ctx.db.damage_event().insert(DamageEvent { + entity_id: target_id, + damage, + source: "melee_attack".to_string(), + }); +} +``` + + + + +Because events are just table inserts, you can publish the same event type from any number of reducers. A `DamageEvent` might be inserted by a melee attack reducer, a spell reducer, and an environmental hazard reducer and clients receive the same event regardless of what triggered it. + +## Constraints and Indexes + +Primary keys, unique constraints, indexes, sequences, and auto-increment columns all work on event tables. The key difference is that these constraints are enforced only within a single transaction and reset between transactions. + +For example, if an event table has a primary key column, inserting two rows with the same primary key within the same transaction will produce an error, just as it would for a regular table. However, inserting a row with primary key `1` in one transaction and another row with primary key `1` in a later transaction will both succeed, because the table is empty at the start of each transaction. + +This behavior follows naturally from the fact that event table rows are never merged into the committed state. Each transaction begins with an empty table. + +## Subscribing to Events + +On the client side, event tables are subscribed to in the same way as regular tables. The important difference is that event table rows are never stored in the client cache. Calling `count()` on an event table always returns 0, and `iter()` always yields no rows. Instead, you observe events through `on_insert` callbacks, which fire for each row that was inserted during the transaction. + +Because event table rows are ephemeral, only `on_insert` callbacks are available. There are no `on_delete`, `on_update`, or `on_before_delete` callbacks, since rows are never present in the client state to be deleted or updated. + + + + +```typescript +conn.db.damageEvent.onInsert((ctx, event) => { + console.log(`Entity ${event.entityId} took ${event.damage} damage from ${event.source}`); +}); +``` + + + + +```csharp +conn.Db.DamageEvent.OnInsert += (ctx, damageEvent) => +{ + Debug.Log($"Entity {damageEvent.EntityId} took {damageEvent.Damage} damage from {damageEvent.Source}"); +}; +``` + + + + +```rust +conn.db.damage_event().on_insert(|ctx, event| { + println!("Entity {} took {} damage from {}", event.entity_id, event.damage, event.source); +}); +``` + + + + +## How It Works + +Conceptually, every insert into an event table is a **noop**: an insert paired with an automatic delete. The result is that the table state never changes; it is always the empty set. This model has several consequences for how SpacetimeDB handles event tables internally. + +**Wire format.** Event tables require the v2 WebSocket protocol. Clients connected via the v1 protocol that attempt to subscribe to an event table will receive an error message directing them to upgrade. + +:::tip Migrating from reducer callbacks +If you previously used `ctx.reducers.on_()` callbacks to receive transient data, event tables are the recommended replacement. Define an event table with the fields you want to publish, insert a row in your reducer, and register an `on_insert` callback on the client via `ctx.db.().on_insert(...)`. See the [migration guide](/how-to/migrating-to-2-0) for details. +::: + +## Row-Level Security + +Row-level security applies to event tables with the same semantics as regular tables. This means you can use RLS rules to control which clients receive which events based on their identity. For example, you could restrict a `DamageEvent` so that only clients whose identity matches the `entity_id` field receive the event, preventing players from seeing damage dealt to other players. + +## Current Limitations + +Event tables are fully functional for the use cases described above, but a few capabilities are intentionally restricted for the initial release: + +- **Subscription joins.** Event tables cannot currently be used as the lookup (right/inner) table in a subscription join. While this is well-defined (the noop semantics make joined results behave as event tables too), it is restricted for ease of implementation and will be relaxed in a future release. +- **Views.** Event tables cannot currently be accessed within view functions. Although the proposal defines clear semantics for this (event-table-ness is "infectious," meaning a view that joins on an event table itself becomes an event table), this is deferred to a future release. + +## Use Cases + +Event tables are well-suited to any situation where the module needs to notify clients about something that happened without storing a permanent record: + +- **Combat and damage events.** Floating damage numbers, hit indicators, and kill notifications. +- **Chat messages.** Real-time chat where messages are displayed on arrival but don't need server-side persistence. +- **Notifications.** Transient UI messages like "Player joined", "Achievement unlocked", or "Trade completed". +- **Sound and visual effects.** Triggering client-side effects such as explosions, particles, or audio cues at the right moment. +- **Telemetry and debugging.** Streaming diagnostic data to a connected developer client without accumulating it in the database. + +## Next Steps + +- Learn about [Tables](/tables) for persistent data storage +- Explore [Schedule Tables](/tables/schedule-tables) for time-triggered actions +- See [Row-Level Security](/tables/access-permissions) for controlling data visibility diff --git a/docs/docs/00300-resources/00100-how-to/00600-migrating-to-2.0.md b/docs/docs/00300-resources/00100-how-to/00600-migrating-to-2.0.md new file mode 100644 index 00000000000..089169b5aee --- /dev/null +++ b/docs/docs/00300-resources/00100-how-to/00600-migrating-to-2.0.md @@ -0,0 +1,217 @@ +--- +title: Migrating from 1.0 to 2.0 +slug: /how-to/migrating-to-2-0 +--- + +# Migrating from SpacetimeDB 1.0 to 2.0 + +This guide covers the breaking changes between SpacetimeDB 1.0 and 2.0 and how to update your code. + +## Overview of Changes + +SpacetimeDB 2.0 introduces a new WebSocket protocol (v2) and SDK with several breaking changes aimed at simplifying the programming model and improving security: + +1. **Reducer callbacks removed** -- replaced by event tables and per-call `_then()` callbacks +2. **`light_mode` removed** -- no longer necessary since reducer events are no longer broadcast +3. **`CallReducerFlags` removed** -- `NoSuccessNotify` and `set_reducer_flags()` are gone +4. **Event tables introduced** -- a new table type for publishing transient events to subscribers + +## Reducer Callbacks + +### What changed + +In 1.0, you could register global callbacks on reducers that would fire whenever *any* client called that reducer and you were subscribed to affected rows: + +```rust +// 1.0 -- REMOVED in 2.0 +conn.reducers.on_insert_one_u_8(|ctx, arg| { + println!("Someone called insert_one_u_8 with arg: {}", arg); +}); +``` + +In 2.0, global reducer callbacks no longer exist. The server does not broadcast reducer argument data to other clients. Instead, you have two options: + +### Option A: Per-call result callbacks (`_then()`) + +If you only need to know the result of a reducer *you* called, use the `_then()` variant: + +```rust +// 2.0 -- per-call callback +ctx.reducers.insert_one_u_8_then(42, |ctx, result| { + match result { + Ok(Ok(())) => println!("Reducer succeeded"), + Ok(Err(err)) => println!("Reducer failed: {err}"), + Err(internal) => println!("Internal error: {internal:?}"), + } +}).unwrap(); +``` + +The fire-and-forget form still works: + +```rust +// 2.0 -- fire and forget (unchanged) +ctx.reducers.insert_one_u_8(42).unwrap(); +``` + +### Option B: Event tables (recommended for most use cases) + +If you need *other* clients to observe that something happened (the primary use case for 1.0 reducer callbacks), create an event table and insert into it from your reducer. + +**Server (module) -- before:** +```rust +// 1.0 server -- reducer args were automatically broadcast +#[reducer] +fn deal_damage(ctx: &ReducerContext, target: Identity, amount: u32) { + // update game state... +} +``` + +**Server (module) -- after:** +```rust +// 2.0 server -- explicitly publish events via an event table +#[spacetimedb::table(name = damage_event, public, event)] +pub struct DamageEvent { + pub target: Identity, + pub amount: u32, +} + +#[reducer] +fn deal_damage(ctx: &ReducerContext, target: Identity, amount: u32) { + // update game state... + ctx.db.damage_event().insert(DamageEvent { target, amount }); +} +``` + +**Client -- before:** +```rust +// 1.0 client -- global reducer callback +conn.reducers.on_deal_damage(|ctx, target, amount| { + play_damage_animation(target, amount); +}); +``` + +**Client -- after:** +```rust +// 2.0 client -- event table callback +conn.db.damage_event().on_insert(|ctx, event| { + play_damage_animation(event.target, event.amount); +}); +``` + +### Why event tables are better + +- **Security**: You control exactly what data is published. In 1.0, reducer arguments were broadcast to any subscriber of affected rows, which could accidentally leak sensitive data. +- **Flexibility**: Multiple reducers can insert the same event type. In 1.0, events were tied 1:1 to a specific reducer. +- **Transactional**: Events are only published if the transaction commits. In 1.0, workarounds using scheduled reducers were not transactional. +- **Row-level security**: RLS rules apply to event tables, so you can control which clients see which events. +- **Queryable**: Event tables are subscribed to with standard SQL, and can be filtered per-client. + +### Event table details + +- Event tables are always empty outside of a transaction. They don't accumulate rows. +- On the client, `count()` always returns 0 and `iter()` is always empty. +- Only `on_insert` callbacks are generated (no `on_delete` or `on_update`). +- The `event` keyword in `#[table(..., event)]` marks the table as transient. +- Event tables must be subscribed to explicitly (they are excluded from `SELECT * FROM *`). + +## Light Mode + +### What changed + +In 1.0, `light_mode` prevented the server from sending reducer event data to a client (unless that client was the caller): + +```rust +// 1.0 -- REMOVED in 2.0 +DbConnection::builder() + .with_light_mode(true) + // ... +``` + +In 2.0, the server never broadcasts reducer argument data to any client, so `light_mode` is no longer necessary. Simply remove the call: + +```rust +// 2.0 +DbConnection::builder() + .with_uri(uri) + .with_module_name(name) + // no with_light_mode needed + .build() +``` + +## CallReducerFlags + +### What changed + +In 1.0, you could suppress success notifications for individual reducer calls: + +```rust +// 1.0 -- REMOVED in 2.0 +ctx.set_reducer_flags(CallReducerFlags::NoSuccessNotify); +ctx.reducers.my_reducer(args).unwrap(); +``` + +In 2.0, the success notification is lightweight (just `request_id` and `timestamp`, no reducer args or full event data), so there is no need to suppress it. Remove any `set_reducer_flags` calls and `CallReducerFlags` imports. + +## Event Type Changes + +### What changed + +In 1.0, table callbacks received `Event::Reducer` with full reducer information when a reducer caused a table change. Non-callers could also receive `Event::UnknownTransaction`. + +In 2.0, the event model is simplified: + +- **The caller** sees `Event::Reducer` with `ReducerEvent { timestamp, status, reducer }` in response to their own reducer calls. +- **Other clients** see `Event::Transaction` (no reducer details). +- `Event::UnknownTransaction` is removed. + +```rust +// 2.0 -- checking who caused a table change +conn.db.my_table().on_insert(|ctx, row| { + match &ctx.event { + Event::Reducer(reducer_event) => { + // This client called the reducer that caused this insert. + println!("Our reducer: {:?}", reducer_event.reducer); + } + Event::Transaction => { + // Another client's action caused this insert. + } + _ => {} + } +}); +``` + +## Subscription API + +The subscription API is largely unchanged: + +```rust +// 2.0 -- same as 1.0 +ctx.subscription_builder() + .on_applied(|ctx| { /* ... */ }) + .on_error(|ctx, error| { /* ... */ }) + .subscribe(["SELECT * FROM my_table"]); +``` + +Note that subscribing to event tables requires an explicit query: + +```rust +// Event tables are excluded from SELECT * FROM *, so subscribe explicitly: +ctx.subscription_builder() + .on_applied(|ctx| { /* ... */ }) + .subscribe(["SELECT * FROM damage_event"]); +``` + +## Quick Migration Checklist + +- [ ] Remove all `ctx.reducers.on_()` calls + - Replace with `_then()` callbacks for your own reducer calls + - Replace with event tables + `on_insert` for cross-client notifications +- [ ] Remove `with_light_mode()` from `DbConnectionBuilder` +- [ ] Remove `set_reducer_flags()` calls and `CallReducerFlags` imports +- [ ] Remove `unstable::CallReducerFlags` from imports +- [ ] Update `Event::UnknownTransaction` matches to `Event::Transaction` +- [ ] For each reducer whose args you were observing from other clients: + 1. Create an `#[table(..., event)]` on the server + 2. Insert into it from the reducer + 3. Subscribe to it on the client + 4. Use `on_insert` instead of the old reducer callback diff --git a/docs/event-tables-status.md b/docs/event-tables-status.md new file mode 100644 index 00000000000..6928918679c --- /dev/null +++ b/docs/event-tables-status.md @@ -0,0 +1,193 @@ +# Event Tables Implementation Status + +Tracking progress against the [event tables proposal](../SpacetimeDBPrivate/proposals/00XX-event-tables.md). + +**Branches:** +- `tyler/impl-event-tables` -- full implementation (rebased on `jlarabie/csharp-sdk-ws-v2-rebase2`) +- `tyler/event-tables-datastore` -- datastore-only subset (off `master`, PR [#4251](https://github.com/clockworklabs/SpacetimeDB/pull/4251)) + +## Implemented + +### Server: Module-side (`#[table(..., event)]`) + +- [x] `event` attribute on `#[table]` macro (`crates/bindings-macro/src/table.rs`) +- [x] `is_event` field on `TableSchema` propagated through schema validation (`crates/schema/`) +- [x] `RawModuleDefV10` includes `is_event` in table definitions (`crates/lib/src/db/raw_def/v10.rs`). Note: V9 does not support event tables. +- [x] Event table rows are recorded as inserts in `tx_data` at commit time but NOT merged into committed state (`crates/datastore/src/locking_tx_datastore/committed_state.rs`) +- [x] Commitlog replay treats event table inserts as noops -- `replay_insert()` returns early when `schema.is_event` (`committed_state.rs`) +- [x] Event tables function as normal tables during reducer execution (insert/delete/update within a transaction) + +### Server: Datastore Unit Tests (PR #4251) + +- [x] Insert + delete in same tx cancels out -- no TxData entry, no committed state (`test_event_table_insert_delete_noop`) +- [x] Update (delete + re-insert) leaves only the final row in TxData, nothing in committed state (`test_event_table_update_only_final_row`) +- [x] Bare insert records in TxData but not committed state (`test_event_table_insert_records_txdata_not_committed_state`) +- [x] `replay_insert()` is a no-op for event tables (`test_event_table_replay_ignores_inserts`) + +### Server: Migration Validation (PR #4251) + +- [x] `ChangeTableEventFlag` error variant in `auto_migrate.rs` prevents changing `is_event` between module versions +- [x] Tests: non-event -> event and event -> non-event both rejected; same flag accepted (`test_change_event_flag_rejected`, `test_same_event_flag_accepted`) + +### Server: Subscriptions & Query Engine + +- [x] `SELECT * FROM *` excludes event tables (`crates/core/src/subscription/subscription.rs`) +- [x] `CanBeLookupTable` trait: event tables do NOT implement it, preventing use as the right/lookup table in view semijoins (`crates/bindings-macro/src/table.rs`, `crates/query-builder/src/table.rs`) +- [x] `reads_from_event_table()` check in `SubscriptionPlan::compile()` rejects event tables as lookup tables in subscription joins (`crates/subscription/src/lib.rs`). The proposal notes that joining on event tables is well-defined (noops), but it is restricted for now for ease of implementation. +- [x] View definitions cannot select from event tables at runtime (`crates/core/src/host/wasm_common/module_host_actor.rs`). The proposal says to disallow event table access in the query builder entirely for now. + +### Server: V1 Protocol Compatibility + +- [x] V1 WebSocket subscriptions to event tables are rejected with a clear error message directing developers to upgrade (`crates/core/src/subscription/module_subscription_actor.rs`) + - Enforced in all three V1 paths: `SubscribeSingle`, `SubscribeMulti`, and legacy `Subscribe` +- [x] `returns_event_table()` methods on `SubscriptionPlan` and `Plan` for detecting event table subscriptions + +### Server: V2 Subscription Path + +- [x] Event tables work correctly through the v2 subscription evaluation path -- verified end-to-end with v2 client subscribing, calling reducer, receiving `on_insert` callback +- [x] Merged `jsdt/ws-v2` (server-side v2 protocol) and `phoebe/rust-sdk-ws-v2` (client-side v2 SDK) into `tyler/impl-event-tables` + +### Client: Rust SDK + +- [x] `EventTable` trait and `TableHandle` implementation that skips client cache storage (`sdks/rust/src/table.rs`) +- [x] `is_event` flag on `TableMetadata` in client cache (`sdks/rust/src/client_cache.rs`) +- [x] Codegen generates `EventTable` impl (insert-only, no delete callbacks) for event tables (`crates/codegen/src/rust.rs`). The proposal says to generate both `on_insert` and `on_delete` (see Deferred section below). +- [x] `on_insert` callbacks fire for event table rows; `count()` and `iter()` always return empty +- [x] `spacetime_module.rs` exposes `is_event` in generated `SpacetimeModule` trait +- [x] SDK uses v2 WebSocket protocol by default (`ws::v2::BIN_PROTOCOL` in `sdks/rust/src/websocket.rs`) + +### Reducer Callback Deprecation (2.0 -- primary goal of proposal) + +The proposal's primary motivation is deprecating reducer event callbacks and replacing them with event tables. This is implemented in the v2 SDK: + +- [x] V2 server does not publish `ReducerEvent` messages to clients (commit `fd3ef210f`) +- [x] V2 codegen does not generate `ctx.reducers.on_()` callbacks; replaced with `_then()` pattern for per-call result callbacks +- [x] `CallReducerFlags` / `NoSuccessNotify` removed from v2 SDK (proposal recommends deprecation) + +### Compile-time Checks (trybuild) + +- [x] Event tables cannot be the lookup (right) table in `left_semijoin` (`crates/bindings/tests/ui/views.rs`) +- [x] Event tables cannot be the lookup (right) table in `right_semijoin` +- [x] Event tables CAN be the left/outer table in a semijoin (positive compile test -- would still be blocked at runtime by the view validation check) + +### Integration Tests + +All event table integration tests are active and passing on the v2 SDK: + +- [x] Basic event table test: `on_insert` fires, row values match, cache stays empty (`event-table`) +- [x] Multiple events in one reducer test: 3 inserts in one reducer all arrive as callbacks (`multiple-events`) +- [x] Events don't persist across transactions test: event count doesn't grow after a subsequent noop reducer (`events-dont-persist`) + +The V1 rejection test (`exec_v1_rejects_event_table`) exists in the test client source but is not wired up in `test.rs`, since the SDK now exclusively uses v2. The V1 rejection server logic remains in place for any V1 clients that may connect. + +Test module: `modules/sdk-test-event-table/src/lib.rs` +Test client: `sdks/rust/tests/event-table-client/src/main.rs` +Test harness: `sdks/rust/tests/test.rs` (`event_table_tests` module) + +### Proposal Document + +- [x] WebSocket Protocol Compatibility section added to proposal (`SpacetimeDBPrivate/proposals/00XX-event-tables.md`) + +## Deferred + +Items that are well-defined and implementable but deliberately deferred per the proposal. + +### `on_delete` Codegen + +The proposal says to generate both `on_insert` and `on_delete` for event tables. Since the server only sends inserts (the optimization described in the proposal), the client would need to synthesize `on_delete` from `on_insert` since every event table row is a noop. This is deferred for now and can be added later without breaking changes. See proposal section "Client-side API". + +### Event Tables in Subscription Joins + +The proposal notes that using event tables as the right/inner/lookup table in subscription joins is well-defined (noops make it so), and that event-table-ness is "infectious" -- joined results behave as event tables too. Currently blocked by the `reads_from_event_table()` check in `SubscriptionPlan::compile()` and the `CanBeLookupTable` compile-time trait. This restriction exists for ease of implementation and can be relaxed later. + +### Event Tables in Views + +The proposal says event tables MAY be accessible in view functions but cannot return rows. Currently disallowed both at compile time (via `CanBeLookupTable`) and runtime (view validation check). The proposal suggests this will be allowed in the future, potentially with implicit "infectious" event-table-ness for views that join on event tables. + +## Not Yet Implemented + +### Server: Constraint/Index Tests on Event Tables + +- [x] Primary key enforced within single transaction (`test_event_table_primary_key_enforced_within_tx`) +- [x] Unique constraint enforced within single transaction (`test_event_table_unique_constraint_within_tx`) +- [x] Btree index lookup works within transaction (`test_event_table_index_lookup_within_tx`) +- [x] Auto-increment generates distinct values within transaction (`test_event_table_auto_inc_within_tx`) +- [x] Constraints reset across transactions -- no committed state carryover (`test_event_table_constraints_reset_across_txs`) + +### Server: Module-side for Other Languages + +- [x] TypeScript module bindings: `event` option on `table()` function (`crates/bindings-typescript/src/lib/table.ts`) +- [x] C# module bindings: `[Table(Event = true)]` attribute + `RawModuleDefV10` support (already implemented) + +### Client: TypeScript SDK + +- [x] `isEvent` property on `UntypedTableDef` (`crates/bindings-typescript/src/lib/table.ts`) +- [x] Event table cache bypass: `on_insert` callbacks fire, rows not stored (`crates/bindings-typescript/src/sdk/table_cache.ts`) +- [x] TypeScript codegen emits `event: true` for event tables (`crates/codegen/src/typescript.rs`) + +### Client: C# SDK + +- [x] `IsEventTable` flag on `RemoteTableHandle` (`sdks/csharp/src/Table.cs`) +- [x] Event table rows parsed from `EventTableRows` wire format (Parse/ParseInsertOnly/ParseDeleteOnly) +- [x] Event tables skip cache storage in `Apply()`, fire only `OnInsert` in `PostApply()` +- [x] C# codegen passes `isEventTable: true` to constructor (`crates/codegen/src/csharp.rs`) + +### Not Yet Implemented + +- [ ] RLS (Row-Level Security) on event tables -- proposal says RLS should apply with same semantics; needs integration test +- [ ] C++ SDK: `EventTable` support and client codegen + +### Documentation + +- [ ] Migration guide from reducer callbacks to event tables (proposal includes examples in "Reducer Callback Compatibility" section but no standalone guide exists) + +### Proposal: Future Work Items (not blocking 2.0) + +- [ ] `#[table]` attribute on reducer functions (auto-generate event table from reducer args) -- proposal "Reducer Event Table" section +- [ ] `ctx.on.()` convenience syntax on client -- proposal ".on Syntax" section +- [ ] Event tables in views / infectious event-table-ness for joined views -- proposal "Views" section +- [ ] TTL tables as generalization of event tables (`ttl = Duration::ZERO`) -- proposal "TTLs, Temporal Filters..." section +- [ ] Temporal filters -- proposal "TTLs, Temporal Filters..." section +- [ ] Light mode deprecation (2.0) -- proposal "Light mode deprecation" section + +## Known Issues + +### V2 SDK Test Stability + +The broader (non-event-table) SDK test suite has intermittent failures when running on the v2 protocol branches. These manifest as: +- `subscribe_all_select_star` and `fail_reducer` intermittent failures in Rust SDK tests +- Test timeouts under parallel execution + +These are pre-existing issues in the v2 WebSocket implementation, not caused by event tables. The event table tests themselves pass reliably. + +### `request_id` Bug (Fixed) + +`sdks/rust/src/db_connection.rs:383` had `request_id: 0` hardcoded instead of using the generated `request_id` variable. This caused "Reducer result for unknown request_id 0" errors for all reducer calls. Fixed in commit `43d84a277` on this branch and `f2f385a28` on `phoebe/rust-sdk-ws-v2`. + +## Key Files + +| Area | File | +|------|------| +| Table macro | `crates/bindings-macro/src/table.rs` | +| Schema | `crates/schema/src/schema.rs` (`is_event` field) | +| Migration validation | `crates/schema/src/auto_migrate.rs` (`ChangeTableEventFlag`) | +| Committed state | `crates/datastore/src/locking_tx_datastore/committed_state.rs` | +| Datastore unit tests | `crates/datastore/src/locking_tx_datastore/datastore.rs` | +| Subscription filtering | `crates/core/src/subscription/subscription.rs` | +| V1 rejection | `crates/core/src/subscription/module_subscription_actor.rs` | +| Plan helpers | `crates/core/src/subscription/module_subscription_manager.rs`, `crates/subscription/src/lib.rs` | +| Query builder | `crates/query-builder/src/table.rs` (`CanBeLookupTable`) | +| Physical plan | `crates/physical-plan/src/plan.rs` (`reads_from_event_table`) | +| View validation | `crates/core/src/host/wasm_common/module_host_actor.rs` | +| Rust codegen | `crates/codegen/src/rust.rs` | +| TypeScript codegen | `crates/codegen/src/typescript.rs` | +| C# codegen | `crates/codegen/src/csharp.rs` | +| Rust SDK | `sdks/rust/src/table.rs`, `sdks/rust/src/client_cache.rs` | +| TypeScript SDK | `crates/bindings-typescript/src/sdk/table_cache.ts`, `crates/bindings-typescript/src/sdk/db_connection_impl.ts` | +| TypeScript bindings | `crates/bindings-typescript/src/lib/table.ts` | +| C# SDK | `sdks/csharp/src/Table.cs` | +| Test module | `modules/sdk-test-event-table/src/lib.rs` | +| Test client | `sdks/rust/tests/event-table-client/src/main.rs` | +| Test harness | `sdks/rust/tests/test.rs` (`event_table_tests` module) | +| Compile tests | `crates/bindings/tests/ui/views.rs` | +| Proposal | `../SpacetimeDBPrivate/proposals/00XX-event-tables.md` | diff --git a/modules/sdk-test-event-table/.cargo/config.toml b/modules/sdk-test-event-table/.cargo/config.toml new file mode 100644 index 00000000000..f4e8c002fc2 --- /dev/null +++ b/modules/sdk-test-event-table/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +target = "wasm32-unknown-unknown" diff --git a/modules/sdk-test-event-table/Cargo.toml b/modules/sdk-test-event-table/Cargo.toml new file mode 100644 index 00000000000..3dd8b22dbe4 --- /dev/null +++ b/modules/sdk-test-event-table/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "sdk-test-event-table-module" +version = "0.1.0" +edition.workspace = true + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true diff --git a/modules/sdk-test-event-table/src/lib.rs b/modules/sdk-test-event-table/src/lib.rs new file mode 100644 index 00000000000..05181098b52 --- /dev/null +++ b/modules/sdk-test-event-table/src/lib.rs @@ -0,0 +1,32 @@ +use spacetimedb::{ReducerContext, Table}; + +#[spacetimedb::table(name = test_event, public, event)] +pub struct TestEvent { + pub name: String, + pub value: u64, +} + +#[spacetimedb::reducer] +pub fn emit_test_event(ctx: &ReducerContext, name: String, value: u64) { + ctx.db.test_event().insert(TestEvent { name, value }); +} + +#[spacetimedb::reducer] +pub fn emit_multiple_test_events(ctx: &ReducerContext) { + ctx.db.test_event().insert(TestEvent { + name: "a".to_string(), + value: 1, + }); + ctx.db.test_event().insert(TestEvent { + name: "b".to_string(), + value: 2, + }); + ctx.db.test_event().insert(TestEvent { + name: "c".to_string(), + value: 3, + }); +} + +/// A no-op reducer that lets us observe a subsequent transaction. +#[spacetimedb::reducer] +pub fn noop(_ctx: &ReducerContext) {} diff --git a/sdks/csharp/examples~/regression-tests/client/Program.cs b/sdks/csharp/examples~/regression-tests/client/Program.cs index 3055bb8af42..61740321737 100644 --- a/sdks/csharp/examples~/regression-tests/client/Program.cs +++ b/sdks/csharp/examples~/regression-tests/client/Program.cs @@ -16,6 +16,8 @@ const string THROW_ERROR_MESSAGE = "this is an error"; const uint UPDATED_WHERE_TEST_VALUE = 42; const string UPDATED_WHERE_TEST_NAME = "this_name_was_updated"; +const string EXPECTED_TEST_EVENT_NAME = "hello"; +const ulong EXPECTED_TEST_EVENT_VALUE = 42; DbConnection ConnectToDB() { @@ -51,6 +53,7 @@ DbConnection ConnectToDB() uint waiting = 0; var applied = false; SubscriptionHandle? handle = null; +uint testEventInsertCount = 0; void OnConnected(DbConnection conn, Identity identity, string authToken) { @@ -72,6 +75,7 @@ void OnConnected(DbConnection conn, Identity identity, string authToken) .AddQuery(qb => qb.From.NullStringNonnullable().Build()) .AddQuery(qb => qb.From.NullStringNullable().Build()) .AddQuery(qb => qb.From.MyLog().Build()) + .AddQuery(qb => qb.From.TestEvent().Build()) .AddQuery(qb => qb.From.Admins().Build()) .AddQuery(qb => qb.From.NullableVecView().Build()) .AddQuery(qb => qb.From.WhereTest().Where(c => c.Value.Gt(10)).Build()) @@ -226,6 +230,58 @@ string name ValidateWhereSubscription(ctx, UPDATED_WHERE_TEST_NAME); ValidateWhereTestViews(ctx, UPDATED_WHERE_TEST_VALUE, UPDATED_WHERE_TEST_NAME); }; + + conn.Db.TestEvent.OnInsert += (EventContext ctx, TestEvent row) => + { + Log.Info($"Got TestEvent.OnInsert callback: {row.Name} / {row.Value}"); + testEventInsertCount++; + Debug.Assert( + row.Name == EXPECTED_TEST_EVENT_NAME, + $"Expected TestEvent.Name == {EXPECTED_TEST_EVENT_NAME}, got {row.Name}" + ); + Debug.Assert( + row.Value == EXPECTED_TEST_EVENT_VALUE, + $"Expected TestEvent.Value == {EXPECTED_TEST_EVENT_VALUE}, got {row.Value}" + ); + Debug.Assert( + ctx.Db.TestEvent.Count == 0, + $"Event table should not persist rows. Count was {ctx.Db.TestEvent.Count}" + ); + Debug.Assert( + !ctx.Db.TestEvent.Iter().Any(), + "Event table iterator should be empty after event delivery" + ); + }; + + conn.Reducers.OnEmitTestEvent += (ReducerEventContext ctx, string name, ulong value) => + { + Log.Info("Got EmitTestEvent callback"); + waiting--; + Debug.Assert( + ctx.Event.Status is Status.Committed, + $"EmitTestEvent should commit, got {ctx.Event.Status}" + ); + Debug.Assert(name == EXPECTED_TEST_EVENT_NAME, $"Expected name={EXPECTED_TEST_EVENT_NAME}, got {name}"); + Debug.Assert(value == EXPECTED_TEST_EVENT_VALUE, $"Expected value={EXPECTED_TEST_EVENT_VALUE}, got {value}"); + }; + + conn.Reducers.OnNoop += (ReducerEventContext ctx) => + { + Log.Info("Got Noop callback"); + waiting--; + Debug.Assert( + testEventInsertCount == 1, + $"Expected exactly one TestEvent insert callback after noop, got {testEventInsertCount}" + ); + Debug.Assert( + ctx.Db.TestEvent.Count == 0, + $"Event table should still be empty after noop. Count was {ctx.Db.TestEvent.Count}" + ); + Debug.Assert( + !ctx.Db.TestEvent.Iter().Any(), + "Event table iterator should remain empty after noop" + ); + }; } const uint MAX_ID = 10; @@ -646,6 +702,14 @@ void OnSubscriptionApplied(SubscriptionEventContext context) waiting++; context.Reducers.InsertNullStringIntoNullable(); + Log.Debug("Calling EmitTestEvent"); + waiting++; + context.Reducers.EmitTestEvent(EXPECTED_TEST_EVENT_NAME, EXPECTED_TEST_EVENT_VALUE); + + Log.Debug("Calling Noop after EmitTestEvent"); + waiting++; + context.Reducers.Noop(); + // Procedures tests Log.Debug("Calling ReadMySchemaViaHttp"); waiting++; diff --git a/demo/Blackholio/client-unity/Assets/Scripts/autogen/Reducers/ConsumeEntity.g.cs b/sdks/csharp/examples~/regression-tests/client/module_bindings/Reducers/EmitTestEvent.g.cs similarity index 53% rename from demo/Blackholio/client-unity/Assets/Scripts/autogen/Reducers/ConsumeEntity.g.cs rename to sdks/csharp/examples~/regression-tests/client/module_bindings/Reducers/EmitTestEvent.g.cs index 8917fcf0ff6..ce0343964c0 100644 --- a/demo/Blackholio/client-unity/Assets/Scripts/autogen/Reducers/ConsumeEntity.g.cs +++ b/sdks/csharp/examples~/regression-tests/client/module_bindings/Reducers/EmitTestEvent.g.cs @@ -12,17 +12,17 @@ namespace SpacetimeDB.Types { public sealed partial class RemoteReducers : RemoteBase { - public delegate void ConsumeEntityHandler(ReducerEventContext ctx, SpacetimeDB.Types.ConsumeEntityTimer request); - public event ConsumeEntityHandler? OnConsumeEntity; + public delegate void EmitTestEventHandler(ReducerEventContext ctx, string name, ulong value); + public event EmitTestEventHandler? OnEmitTestEvent; - public void ConsumeEntity(SpacetimeDB.Types.ConsumeEntityTimer request) + public void EmitTestEvent(string name, ulong value) { - conn.InternalCallReducer(new Reducer.ConsumeEntity(request)); + conn.InternalCallReducer(new Reducer.EmitTestEvent(name, value)); } - public bool InvokeConsumeEntity(ReducerEventContext ctx, Reducer.ConsumeEntity args) + public bool InvokeEmitTestEvent(ReducerEventContext ctx, Reducer.EmitTestEvent args) { - if (OnConsumeEntity == null) + if (OnEmitTestEvent == null) { if (InternalOnUnhandledReducerError != null) { @@ -34,9 +34,10 @@ public bool InvokeConsumeEntity(ReducerEventContext ctx, Reducer.ConsumeEntity a } return false; } - OnConsumeEntity( + OnEmitTestEvent( ctx, - args.Request + args.Name, + args.Value ); return true; } @@ -46,22 +47,28 @@ public abstract partial class Reducer { [SpacetimeDB.Type] [DataContract] - public sealed partial class ConsumeEntity : Reducer, IReducerArgs + public sealed partial class EmitTestEvent : Reducer, IReducerArgs { - [DataMember(Name = "request")] - public ConsumeEntityTimer Request; + [DataMember(Name = "name")] + public string Name; + [DataMember(Name = "value")] + public ulong Value; - public ConsumeEntity(ConsumeEntityTimer Request) + public EmitTestEvent( + string Name, + ulong Value + ) { - this.Request = Request; + this.Name = Name; + this.Value = Value; } - public ConsumeEntity() + public EmitTestEvent() { - this.Request = new(); + this.Name = ""; } - string IReducerArgs.ReducerName => "consume_entity"; + string IReducerArgs.ReducerName => "EmitTestEvent"; } } } diff --git a/sdks/csharp/examples~/regression-tests/client/module_bindings/Reducers/ClientConnected.g.cs b/sdks/csharp/examples~/regression-tests/client/module_bindings/Reducers/Noop.g.cs similarity index 70% rename from sdks/csharp/examples~/regression-tests/client/module_bindings/Reducers/ClientConnected.g.cs rename to sdks/csharp/examples~/regression-tests/client/module_bindings/Reducers/Noop.g.cs index a23c796730b..6bd734fccf5 100644 --- a/sdks/csharp/examples~/regression-tests/client/module_bindings/Reducers/ClientConnected.g.cs +++ b/sdks/csharp/examples~/regression-tests/client/module_bindings/Reducers/Noop.g.cs @@ -12,12 +12,17 @@ namespace SpacetimeDB.Types { public sealed partial class RemoteReducers : RemoteBase { - public delegate void ClientConnectedHandler(ReducerEventContext ctx); - public event ClientConnectedHandler? OnClientConnected; + public delegate void NoopHandler(ReducerEventContext ctx); + public event NoopHandler? OnNoop; - public bool InvokeClientConnected(ReducerEventContext ctx, Reducer.ClientConnected args) + public void Noop() { - if (OnClientConnected == null) + conn.InternalCallReducer(new Reducer.Noop()); + } + + public bool InvokeNoop(ReducerEventContext ctx, Reducer.Noop args) + { + if (OnNoop == null) { if (InternalOnUnhandledReducerError != null) { @@ -29,7 +34,7 @@ public bool InvokeClientConnected(ReducerEventContext ctx, Reducer.ClientConnect } return false; } - OnClientConnected( + OnNoop( ctx ); return true; @@ -40,9 +45,9 @@ public abstract partial class Reducer { [SpacetimeDB.Type] [DataContract] - public sealed partial class ClientConnected : Reducer, IReducerArgs + public sealed partial class Noop : Reducer, IReducerArgs { - string IReducerArgs.ReducerName => "ClientConnected"; + string IReducerArgs.ReducerName => "Noop"; } } } diff --git a/sdks/csharp/examples~/regression-tests/client/module_bindings/SpacetimeDBClient.g.cs b/sdks/csharp/examples~/regression-tests/client/module_bindings/SpacetimeDBClient.g.cs index b725b37552d..69271cdcc18 100644 --- a/sdks/csharp/examples~/regression-tests/client/module_bindings/SpacetimeDBClient.g.cs +++ b/sdks/csharp/examples~/regression-tests/client/module_bindings/SpacetimeDBClient.g.cs @@ -1,7 +1,7 @@ // 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 cli version 2.0.0 (commit 18e8d1958a9e9cb62ca15cc849d35c1a17f9982c). +// This was generated using spacetimedb cli version 2.0.0 (commit 9e0e81a6aaec6bf3619cfb9f7916743d86ab7ffc). #nullable enable @@ -48,6 +48,7 @@ public RemoteTables(DbConnection conn) AddTable(ScoresPlayer123 = new(conn)); AddTable(ScoresPlayer123Level5 = new(conn)); AddTable(ScoresPlayer123Range = new(conn)); + AddTable(TestEvent = new(conn)); AddTable(User = new(conn)); AddTable(UsersAge1865 = new(conn)); AddTable(UsersAge18Plus = new(conn)); @@ -573,6 +574,7 @@ public sealed class QueryBuilder new QueryBuilder().From.ScoresPlayer123().ToSql(), new QueryBuilder().From.ScoresPlayer123Level5().ToSql(), new QueryBuilder().From.ScoresPlayer123Range().ToSql(), + new QueryBuilder().From.TestEvent().ToSql(), new QueryBuilder().From.User().ToSql(), new QueryBuilder().From.UsersAge1865().ToSql(), new QueryBuilder().From.UsersAge18Plus().ToSql(), @@ -608,6 +610,7 @@ public sealed class From public global::SpacetimeDB.Table ScoresPlayer123() => new("scores_player_123", new ScoresPlayer123Cols("scores_player_123"), new ScoresPlayer123IxCols("scores_player_123")); public global::SpacetimeDB.Table ScoresPlayer123Level5() => new("scores_player_123_level5", new ScoresPlayer123Level5Cols("scores_player_123_level5"), new ScoresPlayer123Level5IxCols("scores_player_123_level5")); public global::SpacetimeDB.Table ScoresPlayer123Range() => new("scores_player_123_range", new ScoresPlayer123RangeCols("scores_player_123_range"), new ScoresPlayer123RangeIxCols("scores_player_123_range")); + public global::SpacetimeDB.Table TestEvent() => new("test_event", new TestEventCols("test_event"), new TestEventIxCols("test_event")); public global::SpacetimeDB.Table User() => new("user", new UserCols("user"), new UserIxCols("user")); public global::SpacetimeDB.Table UsersAge1865() => new("users_age_18_65", new UsersAge1865Cols("users_age_18_65"), new UsersAge1865IxCols("users_age_18_65")); public global::SpacetimeDB.Table UsersAge18Plus() => new("users_age_18_plus", new UsersAge18PlusCols("users_age_18_plus"), new UsersAge18PlusIxCols("users_age_18_plus")); @@ -698,13 +701,14 @@ protected override bool Dispatch(IReducerEventContext context, Reducer reducer) return reducer switch { Reducer.Add args => Reducers.InvokeAdd(eventContext, args), - Reducer.ClientConnected args => Reducers.InvokeClientConnected(eventContext, args), Reducer.Delete args => Reducers.InvokeDelete(eventContext, args), + Reducer.EmitTestEvent args => Reducers.InvokeEmitTestEvent(eventContext, args), Reducer.InsertEmptyStringIntoNonNullable args => Reducers.InvokeInsertEmptyStringIntoNonNullable(eventContext, args), Reducer.InsertNullStringIntoNonNullable args => Reducers.InvokeInsertNullStringIntoNonNullable(eventContext, args), Reducer.InsertNullStringIntoNullable args => Reducers.InvokeInsertNullStringIntoNullable(eventContext, args), Reducer.InsertResult args => Reducers.InvokeInsertResult(eventContext, args), Reducer.InsertWhereTest args => Reducers.InvokeInsertWhereTest(eventContext, args), + Reducer.Noop args => Reducers.InvokeNoop(eventContext, args), Reducer.SetNullableVec args => Reducers.InvokeSetNullableVec(eventContext, args), Reducer.ThrowError args => Reducers.InvokeThrowError(eventContext, args), Reducer.UpdateWhereTest args => Reducers.InvokeUpdateWhereTest(eventContext, args), diff --git a/sdks/csharp/examples~/regression-tests/client/module_bindings/Tables/TestEvent.g.cs b/sdks/csharp/examples~/regression-tests/client/module_bindings/Tables/TestEvent.g.cs new file mode 100644 index 00000000000..9b8f8f3d489 --- /dev/null +++ b/sdks/csharp/examples~/regression-tests/client/module_bindings/Tables/TestEvent.g.cs @@ -0,0 +1,47 @@ +// 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 SpacetimeDB.BSATN; +using SpacetimeDB.ClientApi; +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace SpacetimeDB.Types +{ + public sealed partial class RemoteTables + { + public sealed class TestEventHandle : RemoteEventTableHandle + { + protected override string RemoteTableName => "test_event"; + + internal TestEventHandle(DbConnection conn) : base(conn) + { + } + } + + public readonly TestEventHandle TestEvent; + } + + public sealed class TestEventCols + { + public global::SpacetimeDB.Col Name { get; } + public global::SpacetimeDB.Col Value { get; } + + public TestEventCols(string tableName) + { + Name = new global::SpacetimeDB.Col(tableName, "Name"); + Value = new global::SpacetimeDB.Col(tableName, "Value"); + } + } + + public sealed class TestEventIxCols + { + + public TestEventIxCols(string tableName) + { + } + } +} diff --git a/sdks/csharp/examples~/regression-tests/client/module_bindings/Types/TestEvent.g.cs b/sdks/csharp/examples~/regression-tests/client/module_bindings/Types/TestEvent.g.cs new file mode 100644 index 00000000000..d9faeffc79d --- /dev/null +++ b/sdks/csharp/examples~/regression-tests/client/module_bindings/Types/TestEvent.g.cs @@ -0,0 +1,35 @@ +// 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.Types +{ + [SpacetimeDB.Type] + [DataContract] + public sealed partial class TestEvent + { + [DataMember(Name = "Name")] + public string Name; + [DataMember(Name = "Value")] + public ulong Value; + + public TestEvent( + string Name, + ulong Value + ) + { + this.Name = Name; + this.Value = Value; + } + + public TestEvent() + { + this.Name = ""; + } + } +} diff --git a/sdks/csharp/examples~/regression-tests/procedure-client/module_bindings/Procedures/ScheduledProc.g.cs b/sdks/csharp/examples~/regression-tests/procedure-client/module_bindings/Procedures/ScheduledProc.g.cs deleted file mode 100644 index 4d99bfe501c..00000000000 --- a/sdks/csharp/examples~/regression-tests/procedure-client/module_bindings/Procedures/ScheduledProc.g.cs +++ /dev/null @@ -1,77 +0,0 @@ -// 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 SpacetimeDB.ClientApi; -using System.Collections.Generic; -using System.Runtime.Serialization; - -namespace SpacetimeDB.Types -{ - public sealed partial class RemoteProcedures : RemoteBase - { - public void ScheduledProc(SpacetimeDB.Types.ScheduledProcTable data, ProcedureCallback callback) - { - // Convert the clean callback to the wrapper callback - InternalScheduledProc(data, (ctx, result) => - { - if (result.IsSuccess && result.Value != null) - { - callback(ctx, ProcedureCallbackResult.Success(result.Value.Value)); - } - else - { - callback(ctx, ProcedureCallbackResult.Failure(result.Error!)); - } - }); - } - - private void InternalScheduledProc(SpacetimeDB.Types.ScheduledProcTable data, ProcedureCallback callback) - { - conn.InternalCallProcedure(new Procedure.ScheduledProcArgs(data), callback); - } - - } - - public abstract partial class Procedure - { - [SpacetimeDB.Type] - [DataContract] - public sealed partial class ScheduledProc - { - [DataMember(Name = "Value")] - public SpacetimeDB.Unit Value; - - public ScheduledProc(SpacetimeDB.Unit Value) - { - this.Value = Value; - } - - public ScheduledProc() - { - } - } - [SpacetimeDB.Type] - [DataContract] - public sealed partial class ScheduledProcArgs : Procedure, IProcedureArgs - { - [DataMember(Name = "data")] - public ScheduledProcTable Data; - - public ScheduledProcArgs(ScheduledProcTable Data) - { - this.Data = Data; - } - - public ScheduledProcArgs() - { - this.Data = new(); - } - - string IProcedureArgs.ProcedureName => "scheduled_proc"; - } - - } -} diff --git a/sdks/csharp/examples~/regression-tests/procedure-client/module_bindings/SpacetimeDBClient.g.cs b/sdks/csharp/examples~/regression-tests/procedure-client/module_bindings/SpacetimeDBClient.g.cs index 188b733f976..59344f7ba69 100644 --- a/sdks/csharp/examples~/regression-tests/procedure-client/module_bindings/SpacetimeDBClient.g.cs +++ b/sdks/csharp/examples~/regression-tests/procedure-client/module_bindings/SpacetimeDBClient.g.cs @@ -1,7 +1,7 @@ // 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 cli version 2.0.0 (commit 18e8d1958a9e9cb62ca15cc849d35c1a17f9982c). +// This was generated using spacetimedb cli version 2.0.0 (commit 9e0e81a6aaec6bf3619cfb9f7916743d86ab7ffc). #nullable enable diff --git a/sdks/csharp/examples~/regression-tests/republishing/client/module_bindings/SpacetimeDBClient.g.cs b/sdks/csharp/examples~/regression-tests/republishing/client/module_bindings/SpacetimeDBClient.g.cs index 88cc4336398..2e01c9cce00 100644 --- a/sdks/csharp/examples~/regression-tests/republishing/client/module_bindings/SpacetimeDBClient.g.cs +++ b/sdks/csharp/examples~/regression-tests/republishing/client/module_bindings/SpacetimeDBClient.g.cs @@ -1,7 +1,7 @@ // 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 cli version 2.0.0 (commit 18e8d1958a9e9cb62ca15cc849d35c1a17f9982c). +// This was generated using spacetimedb cli version 2.0.0 (commit 9e0e81a6aaec6bf3619cfb9f7916743d86ab7ffc). #nullable enable diff --git a/sdks/csharp/examples~/regression-tests/server/Lib.cs b/sdks/csharp/examples~/regression-tests/server/Lib.cs index e4353f2cec5..47c62dc4b87 100644 --- a/sdks/csharp/examples~/regression-tests/server/Lib.cs +++ b/sdks/csharp/examples~/regression-tests/server/Lib.cs @@ -692,6 +692,13 @@ public partial class RetryLog public uint Attempts; } + [SpacetimeDB.Table(Name = "test_event", Public = true, Event = true)] + public partial struct TestEvent + { + public string Name; + public ulong Value; + } + [SpacetimeDB.Procedure] public static void InsertWithTxRetry(ProcedureContext ctx) { @@ -735,6 +742,15 @@ public static void InsertWithTxRetry(ProcedureContext ctx) Debug.Assert(outcome.IsSuccess, "Retry should have succeeded"); } + [SpacetimeDB.Reducer] + public static void EmitTestEvent(ReducerContext ctx, string name, ulong value) + { + ctx.Db.test_event.Insert(new TestEvent { Name = name, Value = value }); + } + + [SpacetimeDB.Reducer] + public static void Noop(ReducerContext ctx) { } + [SpacetimeDB.Procedure] public static void InsertWithTxPanic(ProcedureContext ctx) { diff --git a/sdks/csharp/src/Table.cs b/sdks/csharp/src/Table.cs index d23a917b8dd..620d99fffd1 100644 --- a/sdks/csharp/src/Table.cs +++ b/sdks/csharp/src/Table.cs @@ -90,7 +90,7 @@ interface IParsedTableUpdate /// /// /// - public abstract class RemoteTableHandle : RemoteBase, IRemoteTableHandle + public abstract class RemoteTableHandleBase : RemoteBase, IRemoteTableHandle where EventContext : class, IEventContext where Row : class, IStructuralReadWrite, new() { @@ -110,7 +110,7 @@ public abstract class UniqueIndexBase : IndexBase { private readonly Dictionary cache = new(); - public UniqueIndexBase(RemoteTableHandle table) + public UniqueIndexBase(RemoteTableHandleBase table) { table.OnInternalInsert += row => cache.Add(GetKey(row), row); table.OnInternalDelete += row => cache.Remove(GetKey(row)); @@ -125,7 +125,7 @@ public abstract class BTreeIndexBase : IndexBase // TODO: change to SortedDictionary when adding support for range queries. private readonly Dictionary> cache = new(); - public BTreeIndexBase(RemoteTableHandle table) + public BTreeIndexBase(RemoteTableHandleBase table) { table.OnInternalInsert += row => { @@ -164,12 +164,26 @@ internal class ParsedTableUpdate : IParsedTableUpdate /// Stores the set of changes for the table, mapping primary keys to updated rows. /// internal MultiDictionaryDelta Delta = new(EqualityComparer.Default, EqualityComparer.Default); + + /// + /// For event tables: stores the event rows directly (bypassing delta merge). + /// + internal List? EventRows; } protected abstract string RemoteTableName { get; } string IRemoteTableHandle.RemoteTableName => RemoteTableName; - public RemoteTableHandle(IDbConnection conn) : base(conn) { } + /// + /// Whether this table is an event table. + /// Event tables don't persist rows in the client cache — they only fire insert callbacks. + /// + internal bool IsEventTable { get; } + + public RemoteTableHandleBase(IDbConnection conn, bool isEventTable = false) : base(conn) + { + IsEventTable = isEventTable; + } // This method needs to be overridden by autogen. protected virtual object? GetPrimaryKey(Row row) => null; @@ -195,7 +209,7 @@ private event Action OnInternalDelete // These are implementations of the type-erased interface. object? IRemoteTableHandle.GetPrimaryKey(IStructuralReadWrite row) => GetPrimaryKey((Row)row); - // These are provided by RemoteTableHandle. + // These are provided by RemoteTableHandleBase. Type IRemoteTableHandle.ClientTableType => typeof(Row); // THE DATA IN THE TABLE. @@ -288,9 +302,12 @@ void IRemoteTableHandle.ParseInsertOnly(TableUpdate update, ParsedDatabaseUpdate } else if (rowSet is TableUpdateRows.EventTable(var events)) { - if (events.Events.RowsData.Count > 0) + var (eventReader, eventRowCount) = CompressionHelpers.ParseRowList(events.Events); + delta.EventRows ??= new(); + for (var i = 0; i < eventRowCount; i++) { - Log.Warn($"Event-table rows are not yet supported by the C# SDK; ignoring insert-only event rows for table `{RemoteTableName}`."); + var obj = DecodeValue(eventReader); + delta.EventRows.Add(obj); } } } @@ -321,10 +338,7 @@ void IRemoteTableHandle.ParseDeleteOnly(TableUpdate update, ParsedDatabaseUpdate } else if (rowSet is TableUpdateRows.EventTable(var events)) { - if (events.Events.RowsData.Count > 0) - { - Log.Warn($"Event-table rows are not yet supported by the C# SDK; ignoring delete-only event rows for table `{RemoteTableName}`."); - } + // Event tables never have deletes; ignore event rows in delete-only context. } } } @@ -359,9 +373,12 @@ void IRemoteTableHandle.Parse(TableUpdate update, ParsedDatabaseUpdate dbOps) } else if (rowSet is TableUpdateRows.EventTable(var events)) { - if (events.Events.RowsData.Count > 0) + var (eventReader, eventRowCount) = CompressionHelpers.ParseRowList(events.Events); + delta.EventRows ??= new(); + for (var i = 0; i < eventRowCount; i++) { - Log.Warn($"Event-table rows are not yet supported by the C# SDK; ignoring event rows for table `{RemoteTableName}`."); + var obj = DecodeValue(eventReader); + delta.EventRows.Add(obj); } } } @@ -375,26 +392,7 @@ public event RowEventHandler OnInsert add => OnInsertHandler.AddListener(value); remove => OnInsertHandler.RemoveListener(value); } - private CustomRowEventHandler OnDeleteHandler { get; } = new(); - public event RowEventHandler OnDelete - { - add => OnDeleteHandler.AddListener(value); - remove => OnDeleteHandler.RemoveListener(value); - } - private CustomRowEventHandler OnBeforeDeleteHandler { get; } = new(); - public event RowEventHandler OnBeforeDelete - { - add => OnBeforeDeleteHandler.AddListener(value); - remove => OnBeforeDeleteHandler.RemoveListener(value); - } - public delegate void UpdateEventHandler(EventContext context, Row oldRow, Row newRow); - private CustomUpdateEventHandler OnUpdateHandler { get; } = new(); - public event UpdateEventHandler OnUpdate - { - add => OnUpdateHandler.AddListener(value); - remove => OnUpdateHandler.RemoveListener(value); - } public int Count => (int)Entries.CountDistinct; @@ -415,41 +413,11 @@ void InvokeInsert(IEventContext context, IStructuralReadWrite row) } } - void InvokeDelete(IEventContext context, IStructuralReadWrite row) - { - try - { - OnDeleteHandler.Invoke((EventContext)context, (Row)row); - } - catch (Exception e) - { - Log.Exception(e); - } - } + protected virtual void InvokeDelete(IEventContext context, IStructuralReadWrite row) { } - void InvokeBeforeDelete(IEventContext context, IStructuralReadWrite row) - { - try - { - OnBeforeDeleteHandler.Invoke((EventContext)context, (Row)row); - } - catch (Exception e) - { - Log.Exception(e); - } - } + protected virtual void InvokeBeforeDelete(IEventContext context, IStructuralReadWrite row) { } - void InvokeUpdate(IEventContext context, IStructuralReadWrite oldRow, IStructuralReadWrite newRow) - { - try - { - OnUpdateHandler.Invoke((EventContext)context, (Row)oldRow, (Row)newRow); - } - catch (Exception e) - { - Log.Exception(e); - } - } + protected virtual void InvokeUpdate(IEventContext context, IStructuralReadWrite oldRow, IStructuralReadWrite newRow) { } List> wasInserted = new(); List<(object key, Row oldValue, Row newValue)> wasUpdated = new(); @@ -464,6 +432,7 @@ void InvokeUpdate(IEventContext context, IStructuralReadWrite oldRow, IStructura void IRemoteTableHandle.PreApply(IEventContext context, IParsedTableUpdate parsedTableUpdate) { Debug.Assert(wasInserted.Count == 0 && wasUpdated.Count == 0 && wasRemoved.Count == 0, "Call Apply and PostApply before calling PreApply again"); + if (IsEventTable) return; // Event tables have no deletes. var delta = (ParsedTableUpdate)parsedTableUpdate; foreach (var (_, value) in Entries.WillRemove(delta.Delta)) { @@ -478,9 +447,24 @@ void IRemoteTableHandle.PreApply(IEventContext context, IParsedTableUpdate parse /// void IRemoteTableHandle.Apply(IEventContext context, IParsedTableUpdate parsedTableUpdate) { + var delta = (ParsedTableUpdate)parsedTableUpdate; + + if (IsEventTable) + { + // Event tables: don't store rows in the cache or update indices. + // Just collect the event rows so PostApply can fire OnInsert callbacks. + if (delta.EventRows != null) + { + foreach (var row in delta.EventRows) + { + wasInserted.Add(new KeyValuePair(row, row)); + } + } + return; + } + try { - var delta = (ParsedTableUpdate)parsedTableUpdate; Entries.Apply(delta.Delta, wasInserted, wasUpdated, wasRemoved); } catch (Exception e) @@ -552,13 +536,16 @@ void IRemoteTableHandle.PostApply(IEventContext context) { InvokeInsert(context, value); } - foreach (var (_, oldValue, newValue) in wasUpdated) - { - InvokeUpdate(context, oldValue, newValue); - } - foreach (var (_, value) in wasRemoved) + if (!IsEventTable) { - InvokeDelete(context, value); + foreach (var (_, oldValue, newValue) in wasUpdated) + { + InvokeUpdate(context, oldValue, newValue); + } + foreach (var (_, value) in wasRemoved) + { + InvokeDelete(context, value); + } } wasInserted.Clear(); wasUpdated.Clear(); @@ -566,7 +553,7 @@ void IRemoteTableHandle.PostApply(IEventContext context) } - private class CustomRowEventHandler + protected class CustomRowEventHandler { private EventListeners Listeners { get; } = new(); @@ -581,7 +568,7 @@ public void Invoke(EventContext ctx, Row row) public void AddListener(RowEventHandler listener) => Listeners.Add(listener); public void RemoveListener(RowEventHandler listener) => Listeners.Remove(listener); } - private class CustomUpdateEventHandler + protected class CustomUpdateEventHandler { private EventListeners Listeners { get; } = new(); @@ -597,5 +584,82 @@ public void Invoke(EventContext ctx, Row oldRow, Row newRow) public void RemoveListener(UpdateEventHandler listener) => Listeners.Remove(listener); } } + + /// + /// A table handle for persistent tables, exposing insert/delete/update callbacks. + /// + public abstract class RemoteTableHandle : RemoteTableHandleBase + where EventContext : class, IEventContext + where Row : class, IStructuralReadWrite, new() + { + protected RemoteTableHandle(IDbConnection conn) : base(conn) { } + + private CustomRowEventHandler OnDeleteHandler { get; } = new(); + public event RowEventHandler OnDelete + { + add => OnDeleteHandler.AddListener(value); + remove => OnDeleteHandler.RemoveListener(value); + } + private CustomRowEventHandler OnBeforeDeleteHandler { get; } = new(); + public event RowEventHandler OnBeforeDelete + { + add => OnBeforeDeleteHandler.AddListener(value); + remove => OnBeforeDeleteHandler.RemoveListener(value); + } + private CustomUpdateEventHandler OnUpdateHandler { get; } = new(); + public event UpdateEventHandler OnUpdate + { + add => OnUpdateHandler.AddListener(value); + remove => OnUpdateHandler.RemoveListener(value); + } + + protected override void InvokeDelete(IEventContext context, IStructuralReadWrite row) + { + try + { + OnDeleteHandler.Invoke((EventContext)context, (Row)row); + } + catch (Exception e) + { + Log.Exception(e); + } + } + + protected override void InvokeBeforeDelete(IEventContext context, IStructuralReadWrite row) + { + try + { + OnBeforeDeleteHandler.Invoke((EventContext)context, (Row)row); + } + catch (Exception e) + { + Log.Exception(e); + } + } + + protected override void InvokeUpdate(IEventContext context, IStructuralReadWrite oldRow, IStructuralReadWrite newRow) + { + try + { + OnUpdateHandler.Invoke((EventContext)context, (Row)oldRow, (Row)newRow); + } + catch (Exception e) + { + Log.Exception(e); + } + } + } + + /// + /// A table handle for event tables, which only expose OnInsert callbacks. + /// Event tables do not persist rows in the client cache and do not support + /// OnDelete, OnBeforeDelete, or OnUpdate callbacks. + /// + public abstract class RemoteEventTableHandle : RemoteTableHandleBase + where EventContext : class, IEventContext + where Row : class, IStructuralReadWrite, new() + { + protected RemoteEventTableHandle(IDbConnection conn) : base(conn, isEventTable: true) { } + } } #nullable disable diff --git a/sdks/rust/src/client_cache.rs b/sdks/rust/src/client_cache.rs index 4a5bbfc312c..22d9b4b69b2 100644 --- a/sdks/rust/src/client_cache.rs +++ b/sdks/rust/src/client_cache.rs @@ -105,6 +105,19 @@ impl Default for TableAppliedDiff<'_, Row> { } impl<'r, Row> TableAppliedDiff<'r, Row> { + /// For event tables: construct a `TableAppliedDiff` with just inserts, + /// without touching the client cache. + /// Each insert will fire `on_insert` callbacks. + pub(crate) fn from_event_inserts(inserts: &'r [WithBsatn]) -> Self { + let insert_map = inserts.iter().map(|wb| (wb.bsatn.as_ref(), &wb.row)).collect(); + Self { + inserts: insert_map, + deletes: Default::default(), + update_deletes: Vec::new(), + update_inserts: Vec::new(), + } + } + /// Returns the applied diff restructured /// with row updates where deletes and inserts are found according to `derive_pk`. pub fn with_updates_by_pk(mut self, derive_pk: impl Fn(&Row) -> &Pk) -> Self { diff --git a/sdks/rust/src/lib.rs b/sdks/rust/src/lib.rs index fe9b2d1fc71..d99447d440f 100644 --- a/sdks/rust/src/lib.rs +++ b/sdks/rust/src/lib.rs @@ -29,7 +29,7 @@ pub use db_connection::DbConnectionBuilder; pub use db_context::DbContext; pub use error::{Error, Result}; pub use event::{Event, ReducerEvent, Status}; -pub use table::{Table, TableWithPrimaryKey}; +pub use table::{EventTable, Table, TableWithPrimaryKey}; pub use spacetime_module::SubscriptionHandle; pub use spacetimedb_client_api_messages::websocket::v1::Compression; @@ -61,7 +61,7 @@ pub mod __codegen { }; pub use crate::subscription::{OnEndedCallback, SubscriptionBuilder, SubscriptionHandleImpl}; pub use crate::{ - ConnectionId, DbConnectionBuilder, DbContext, Event, Identity, ReducerEvent, ScheduleAt, Table, + ConnectionId, DbConnectionBuilder, DbContext, Event, EventTable, Identity, ReducerEvent, ScheduleAt, Table, TableWithPrimaryKey, TimeDuration, Timestamp, Uuid, }; } diff --git a/sdks/rust/src/spacetime_module.rs b/sdks/rust/src/spacetime_module.rs index f4f69c7ffae..e50a3a90dfd 100644 --- a/sdks/rust/src/spacetime_module.rs +++ b/sdks/rust/src/spacetime_module.rs @@ -245,6 +245,12 @@ impl TableUpdate { pub(crate) fn is_empty(&self) -> bool { self.inserts.is_empty() && self.deletes.is_empty() } + + /// For event tables: convert inserts directly to a `TableAppliedDiff` + /// without touching the client cache. Each insert fires `on_insert` callbacks. + pub fn into_event_diff(&self) -> crate::client_cache::TableAppliedDiff<'_, Row> { + crate::client_cache::TableAppliedDiff::from_event_inserts(&self.inserts) + } } impl TableUpdate { @@ -254,7 +260,9 @@ impl TableUpdate { let mut deletes = Vec::new(); for update in raw_updates.rows { match update { - ws::v2::TableUpdateRows::EventTable(_) => todo!("Event tables"), + ws::v2::TableUpdateRows::EventTable(update) => { + Self::parse_from_row_list(&mut inserts, &update.events)?; + } ws::v2::TableUpdateRows::PersistentTable(update) => { Self::parse_from_row_list(&mut deletes, &update.deletes)?; Self::parse_from_row_list(&mut inserts, &update.inserts)?; diff --git a/sdks/rust/src/table.rs b/sdks/rust/src/table.rs index 2198201cb6e..fe42c613a4a 100644 --- a/sdks/rust/src/table.rs +++ b/sdks/rust/src/table.rs @@ -10,6 +10,8 @@ /// Trait implemented by table handles, which mediate access to tables in the client cache. /// /// Obtain a table handle by calling a method on `ctx.db`, where `ctx` is a `DbConnection` or `EventContext`. +/// +/// For persistent (non-event) tables only. See [`EventTable`] for transient event tables. pub trait Table { /// The type of rows stored in this table. type Row: 'static; @@ -75,3 +77,35 @@ pub trait TableWithPrimaryKey: Table { /// Cancel a callback previously registered by [`Self::on_update`], causing it not to run in the future. fn remove_on_update(&self, callback: Self::UpdateCallbackId); } + +/// Trait for event tables, whose rows are transient and never persisted in the client cache. +/// +/// Event table rows are delivered as inserts but are not stored; +/// only `on_insert` callbacks fire, and `count`/`iter` always reflect an empty table. +/// +/// Obtain a table handle by calling a method on `ctx.db`, where `ctx` is a `DbConnection` or `EventContext`. +pub trait EventTable { + /// The type of rows in this table. + type Row: 'static; + + /// The `EventContext` type generated for the module which defines this table. + type EventContext; + + /// The number of subscribed rows in the client cache (always 0 for event tables). + fn count(&self) -> u64; + + /// An iterator over all the subscribed rows in the client cache (always empty for event tables). + fn iter(&self) -> impl Iterator + '_; + + type InsertCallbackId; + /// Register a callback to run whenever a row is inserted. + /// + /// The returned [`Self::InsertCallbackId`] can be passed to [`Self::remove_on_insert`] + /// to cancel the callback. + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> Self::InsertCallbackId; + /// Cancel a callback previously registered by [`Self::on_insert`], causing it not to run in the future. + fn remove_on_insert(&self, callback: Self::InsertCallbackId); +} diff --git a/sdks/rust/tests/event-table-client/Cargo.toml b/sdks/rust/tests/event-table-client/Cargo.toml new file mode 100644 index 00000000000..f6502644119 --- /dev/null +++ b/sdks/rust/tests/event-table-client/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "event-table-client" +version.workspace = true +edition.workspace = true + +[dependencies] +spacetimedb-sdk = { path = "../.." } +test-counter = { path = "../test-counter" } +anyhow.workspace = true +env_logger.workspace = true + +[lints] +workspace = true diff --git a/sdks/rust/tests/event-table-client/src/main.rs b/sdks/rust/tests/event-table-client/src/main.rs new file mode 100644 index 00000000000..5edb1fdfe0a --- /dev/null +++ b/sdks/rust/tests/event-table-client/src/main.rs @@ -0,0 +1,248 @@ +#[allow(clippy::too_many_arguments)] +#[allow(clippy::large_enum_variant)] +mod module_bindings; + +use module_bindings::*; + +use spacetimedb_sdk::{DbContext, Event, EventTable}; +use std::sync::atomic::{AtomicU32, Ordering}; +use test_counter::TestCounter; + +const LOCALHOST: &str = "http://localhost:3000"; + +fn db_name_or_panic() -> String { + std::env::var("SPACETIME_SDK_TEST_DB_NAME").expect("Failed to read db name from env") +} + +/// Register a panic hook which will exit the process whenever any thread panics. +/// +/// This allows us to fail tests by panicking in callbacks. +fn exit_on_panic() { + let default_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |panic_info| { + default_hook(panic_info); + std::process::exit(1); + })); +} + +macro_rules! assert_eq_or_bail { + ($expected:expr, $found:expr) => {{ + let expected = &$expected; + let found = &$found; + if expected != found { + anyhow::bail!( + "Expected {} => {:?} but found {} => {:?}", + stringify!($expected), + expected, + stringify!($found), + found + ); + } + }}; +} + +fn main() { + env_logger::init(); + exit_on_panic(); + + let test = std::env::args() + .nth(1) + .expect("Pass a test name as a command-line argument to the test client"); + + match &*test { + "event-table" => exec_event_table(), + "multiple-events" => exec_multiple_events(), + "events-dont-persist" => exec_events_dont_persist(), + "v1-rejects-event-table" => exec_v1_rejects_event_table(), + _ => panic!("Unknown test: {test}"), + } +} + +fn connect_then( + test_counter: &std::sync::Arc, + callback: impl FnOnce(&DbConnection) + Send + 'static, +) -> DbConnection { + let connected_result = test_counter.add_test("on_connect"); + let name = db_name_or_panic(); + let conn = DbConnection::builder() + .with_database_name(name) + .with_uri(LOCALHOST) + .on_connect(|ctx, _, _| { + callback(ctx); + connected_result(Ok(())); + }) + .on_connect_error(|_ctx, error| panic!("Connect errored: {error:?}")) + .build() + .unwrap(); + conn.run_threaded(); + conn +} + +fn subscribe_these_then( + ctx: &impl RemoteDbContext, + queries: &[&str], + callback: impl FnOnce(&SubscriptionEventContext) + Send + 'static, +) { + ctx.subscription_builder() + .on_applied(callback) + .on_error(|_ctx, error| panic!("Subscription errored: {error:?}")) + .subscribe(queries); +} + +fn exec_event_table() { + let test_counter = TestCounter::new(); + let sub_applied_result = test_counter.add_test("subscription_applied"); + let on_insert_result = test_counter.add_test("event-table-on-insert"); + let on_insert_result = std::sync::Mutex::new(Some(on_insert_result)); + + connect_then(&test_counter, { + move |ctx| { + subscribe_these_then(ctx, &["SELECT * FROM test_event;"], move |ctx| { + // Event table should be empty on subscription applied + assert_eq!(0usize, ctx.db.test_event().iter().count()); + sub_applied_result(Ok(())); + + ctx.db.test_event().on_insert(move |ctx, row| { + if let Some(set_result) = on_insert_result.lock().unwrap().take() { + let run_checks = || { + assert_eq_or_bail!("hello", row.name); + assert_eq_or_bail!(42u64, row.value); + + let Event::Reducer(reducer_event) = &ctx.event else { + anyhow::bail!("Expected a reducer event"); + }; + anyhow::ensure!( + matches!(reducer_event.reducer, Reducer::EmitTestEvent { .. }), + "Unexpected Reducer variant {:?}", + reducer_event.reducer, + ); + + // Event table rows are not cached + assert_eq_or_bail!(0u64, ctx.db.test_event().count()); + assert_eq_or_bail!(0usize, ctx.db.test_event().iter().count()); + + Ok(()) + }; + set_result(run_checks()); + } + }); + + ctx.reducers.emit_test_event("hello".to_string(), 42).unwrap(); + }); + } + }); + + test_counter.wait_for_all(); +} + +/// Test that multiple events emitted in a single reducer call all arrive as inserts. +fn exec_multiple_events() { + let test_counter = TestCounter::new(); + let sub_applied_result = test_counter.add_test("subscription_applied"); + let result = test_counter.add_test("multiple-events"); + let result = std::sync::Mutex::new(Some(result)); + + connect_then(&test_counter, { + move |ctx| { + subscribe_these_then(ctx, &["SELECT * FROM test_event;"], move |ctx| { + assert_eq!(0usize, ctx.db.test_event().iter().count()); + sub_applied_result(Ok(())); + + let received = std::sync::Arc::new(AtomicU32::new(0)); + + ctx.db.test_event().on_insert({ + let received = received.clone(); + move |_ctx, _row| { + let count = received.fetch_add(1, Ordering::SeqCst) + 1; + if count == 3 { + let set_result = result.lock().unwrap().take().unwrap(); + set_result(Ok(())); + } + } + }); + + ctx.reducers.emit_multiple_test_events().unwrap(); + }); + } + }); + + test_counter.wait_for_all(); +} + +/// Test that event table rows don't persist across transactions. +/// Emit events, then call a no-op reducer. After the no-op completes, +/// verify we didn't receive any additional event inserts. +fn exec_events_dont_persist() { + let test_counter = TestCounter::new(); + let sub_applied_result = test_counter.add_test("subscription_applied"); + let noop_result = test_counter.add_test("events-dont-persist"); + let noop_result = std::sync::Mutex::new(Some(noop_result)); + + connect_then(&test_counter, { + move |ctx| { + subscribe_these_then(ctx, &["SELECT * FROM test_event;"], move |ctx| { + assert_eq!(0usize, ctx.db.test_event().iter().count()); + sub_applied_result(Ok(())); + + let insert_count = std::sync::Arc::new(AtomicU32::new(0)); + + ctx.db.test_event().on_insert({ + let insert_count = insert_count.clone(); + move |_ctx, _row| { + insert_count.fetch_add(1, Ordering::SeqCst); + } + }); + + ctx.reducers.emit_test_event("hello".to_string(), 42).unwrap(); + + // After the noop reducer completes, the insert count should + // still be 1 from the emit_test_event call — no stale events. + ctx.reducers + .noop_then({ + let insert_count = insert_count.clone(); + move |_ctx, _result| { + let set_result = noop_result.lock().unwrap().take().unwrap(); + let count = insert_count.load(Ordering::SeqCst); + if count == 1 { + set_result(Ok(())); + } else { + set_result(Err(anyhow::anyhow!("Expected 1 event insert, but got {count}"))); + } + } + }) + .unwrap(); + }); + } + }); + + test_counter.wait_for_all(); +} + +/// Test that v1 WebSocket clients are rejected when subscribing to event tables. +/// The server should return a subscription error directing the developer to upgrade. +fn exec_v1_rejects_event_table() { + let test_counter = TestCounter::new(); + + connect_then(&test_counter, { + let test_counter = test_counter.clone(); + move |ctx| { + let error_result = test_counter.add_test("v1-rejects-event-table"); + + ctx.subscription_builder() + .on_applied(move |_ctx: &SubscriptionEventContext| { + panic!("Subscription to event table should not succeed over v1"); + }) + .on_error(move |_ctx, error| { + let msg = format!("{error:?}"); + if msg.contains("v2") || msg.contains("upgrade") || msg.contains("Upgrade") { + error_result(Ok(())); + } else { + error_result(Err(anyhow::anyhow!("Expected error about v2/upgrade, got: {msg}"))); + } + }) + .subscribe(["SELECT * FROM test_event;"]); + } + }); + + test_counter.wait_for_all(); +} diff --git a/sdks/rust/tests/event-table-client/src/module_bindings/emit_multiple_test_events_reducer.rs b/sdks/rust/tests/event-table-client/src/module_bindings/emit_multiple_test_events_reducer.rs new file mode 100644 index 00000000000..2ac3bcf9e2d --- /dev/null +++ b/sdks/rust/tests/event-table-client/src/module_bindings/emit_multiple_test_events_reducer.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct EmitMultipleTestEventsArgs {} + +impl From for super::Reducer { + fn from(args: EmitMultipleTestEventsArgs) -> Self { + Self::EmitMultipleTestEvents + } +} + +impl __sdk::InModule for EmitMultipleTestEventsArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `emit_multiple_test_events`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait emit_multiple_test_events { + /// Request that the remote module invoke the reducer `emit_multiple_test_events` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`emit_multiple_test_events:emit_multiple_test_events_then`] to run a callback after the reducer completes. + fn emit_multiple_test_events(&self) -> __sdk::Result<()> { + self.emit_multiple_test_events_then(|_, _| {}) + } + + /// Request that the remote module invoke the reducer `emit_multiple_test_events` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn emit_multiple_test_events_then( + &self, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl emit_multiple_test_events for super::RemoteReducers { + fn emit_multiple_test_events_then( + &self, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp + .invoke_reducer_with_callback(EmitMultipleTestEventsArgs {}, callback) + } +} diff --git a/sdks/rust/tests/event-table-client/src/module_bindings/emit_test_event_reducer.rs b/sdks/rust/tests/event-table-client/src/module_bindings/emit_test_event_reducer.rs new file mode 100644 index 00000000000..d0e158e9e38 --- /dev/null +++ b/sdks/rust/tests/event-table-client/src/module_bindings/emit_test_event_reducer.rs @@ -0,0 +1,72 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct EmitTestEventArgs { + pub name: String, + pub value: u64, +} + +impl From for super::Reducer { + fn from(args: EmitTestEventArgs) -> Self { + Self::EmitTestEvent { + name: args.name, + value: args.value, + } + } +} + +impl __sdk::InModule for EmitTestEventArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `emit_test_event`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait emit_test_event { + /// Request that the remote module invoke the reducer `emit_test_event` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`emit_test_event:emit_test_event_then`] to run a callback after the reducer completes. + fn emit_test_event(&self, name: String, value: u64) -> __sdk::Result<()> { + self.emit_test_event_then(name, value, |_, _| {}) + } + + /// Request that the remote module invoke the reducer `emit_test_event` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn emit_test_event_then( + &self, + name: String, + value: u64, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl emit_test_event for super::RemoteReducers { + fn emit_test_event_then( + &self, + name: String, + value: u64, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp + .invoke_reducer_with_callback(EmitTestEventArgs { name, value }, callback) + } +} diff --git a/sdks/rust/tests/event-table-client/src/module_bindings/mod.rs b/sdks/rust/tests/event-table-client/src/module_bindings/mod.rs new file mode 100644 index 00000000000..270a63fc8c2 --- /dev/null +++ b/sdks/rust/tests/event-table-client/src/module_bindings/mod.rs @@ -0,0 +1,795 @@ +// 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 cli version 2.0.0 (commit f9ecae027971fa57c15a8a38f49d2df66ee48026). + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +pub mod emit_multiple_test_events_reducer; +pub mod emit_test_event_reducer; +pub mod noop_reducer; +pub mod test_event_table; +pub mod test_event_type; + +pub use emit_multiple_test_events_reducer::emit_multiple_test_events; +pub use emit_test_event_reducer::emit_test_event; +pub use noop_reducer::noop; +pub use test_event_table::*; +pub use test_event_type::TestEvent; + +#[derive(Clone, PartialEq, Debug)] + +/// One of the reducers defined by this module. +/// +/// Contained within a [`__sdk::ReducerEvent`] in [`EventContext`]s for reducer events +/// to indicate which reducer caused the event. + +pub enum Reducer { + EmitMultipleTestEvents, + EmitTestEvent { name: String, value: u64 }, + Noop, +} + +impl __sdk::InModule for Reducer { + type Module = RemoteModule; +} + +impl __sdk::Reducer for Reducer { + fn reducer_name(&self) -> &'static str { + match self { + Reducer::EmitMultipleTestEvents => "emit_multiple_test_events", + Reducer::EmitTestEvent { .. } => "emit_test_event", + Reducer::Noop => "noop", + _ => unreachable!(), + } + } + #[allow(clippy::clone_on_copy)] + fn args_bsatn(&self) -> Result, __sats::bsatn::EncodeError> { + match self { + Reducer::EmitMultipleTestEvents => { + __sats::bsatn::to_vec(&emit_multiple_test_events_reducer::EmitMultipleTestEventsArgs {}) + } + Reducer::EmitTestEvent { name, value } => { + __sats::bsatn::to_vec(&emit_test_event_reducer::EmitTestEventArgs { + name: name.clone(), + value: value.clone(), + }) + } + Reducer::Noop => __sats::bsatn::to_vec(&noop_reducer::NoopArgs {}), + _ => unreachable!(), + } + } +} + +#[derive(Default)] +#[allow(non_snake_case)] +#[doc(hidden)] +pub struct DbUpdate { + test_event: __sdk::TableUpdate, +} + +impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { + type Error = __sdk::Error; + fn try_from(raw: __ws::v2::TransactionUpdate) -> Result { + let mut db_update = DbUpdate::default(); + for table_update in __sdk::transaction_update_iter_table_updates(raw) { + match &table_update.table_name[..] { + "test_event" => db_update + .test_event + .append(test_event_table::parse_table_update(table_update)?), + + unknown => { + return Err(__sdk::InternalError::unknown_name("table", unknown, "DatabaseUpdate").into()); + } + } + } + Ok(db_update) + } +} + +impl __sdk::InModule for DbUpdate { + type Module = RemoteModule; +} + +impl __sdk::DbUpdate for DbUpdate { + fn apply_to_client_cache(&self, cache: &mut __sdk::ClientCache) -> AppliedDiff<'_> { + let mut diff = AppliedDiff::default(); + + diff.test_event = self.test_event.into_event_diff(); + + diff + } + fn parse_initial_rows(raw: __ws::v2::QueryRows) -> __sdk::Result { + let mut db_update = DbUpdate::default(); + for table_rows in raw.tables { + match &table_rows.table[..] { + "test_event" => db_update + .test_event + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + unknown => { + return Err(__sdk::InternalError::unknown_name("table", unknown, "QueryRows").into()); + } + } + } + Ok(db_update) + } + fn parse_unsubscribe_rows(raw: __ws::v2::QueryRows) -> __sdk::Result { + let mut db_update = DbUpdate::default(); + for table_rows in raw.tables { + match &table_rows.table[..] { + "test_event" => db_update + .test_event + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + unknown => { + return Err(__sdk::InternalError::unknown_name("table", unknown, "QueryRows").into()); + } + } + } + Ok(db_update) + } +} + +#[derive(Default)] +#[allow(non_snake_case)] +#[doc(hidden)] +pub struct AppliedDiff<'r> { + test_event: __sdk::TableAppliedDiff<'r, TestEvent>, + __unused: std::marker::PhantomData<&'r ()>, +} + +impl __sdk::InModule for AppliedDiff<'_> { + type Module = RemoteModule; +} + +impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { + fn invoke_row_callbacks(&self, event: &EventContext, callbacks: &mut __sdk::DbCallbacks) { + callbacks.invoke_table_row_callbacks::("test_event", &self.test_event, event); + } +} + +#[doc(hidden)] +pub struct RemoteModule; + +impl __sdk::InModule for RemoteModule { + type Module = Self; +} + +/// The `reducers` field of [`EventContext`] and [`DbConnection`], +/// with methods provided by extension traits for each reducer defined by the module. +pub struct RemoteReducers { + imp: __sdk::DbContextImpl, +} + +impl __sdk::InModule for RemoteReducers { + type Module = RemoteModule; +} + +/// The `procedures` field of [`DbConnection`] and other [`DbContext`] types, +/// with methods provided by extension traits for each procedure defined by the module. +pub struct RemoteProcedures { + imp: __sdk::DbContextImpl, +} + +impl __sdk::InModule for RemoteProcedures { + type Module = RemoteModule; +} + +/// The `db` field of [`EventContext`] and [`DbConnection`], +/// with methods provided by extension traits for each table defined by the module. +pub struct RemoteTables { + imp: __sdk::DbContextImpl, +} + +impl __sdk::InModule for RemoteTables { + type Module = RemoteModule; +} + +/// A connection to a remote module, including a materialized view of a subset of the database. +/// +/// Connect to a remote module by calling [`DbConnection::builder`] +/// and using the [`__sdk::DbConnectionBuilder`] builder-pattern constructor. +/// +/// You must explicitly advance the connection by calling any one of: +/// +/// - [`DbConnection::frame_tick`]. +/// - [`DbConnection::run_threaded`]. +/// - [`DbConnection::run_async`]. +/// - [`DbConnection::advance_one_message`]. +/// - [`DbConnection::advance_one_message_blocking`]. +/// - [`DbConnection::advance_one_message_async`]. +/// +/// Which of these methods you should call depends on the specific needs of your application, +/// but you must call one of them, or else the connection will never progress. +pub struct DbConnection { + /// Access to tables defined by the module via extension traits implemented for [`RemoteTables`]. + pub db: RemoteTables, + /// Access to reducers defined by the module via extension traits implemented for [`RemoteReducers`]. + pub reducers: RemoteReducers, + #[doc(hidden)] + + /// Access to procedures defined by the module via extension traits implemented for [`RemoteProcedures`]. + pub procedures: RemoteProcedures, + + imp: __sdk::DbContextImpl, +} + +impl __sdk::InModule for DbConnection { + type Module = RemoteModule; +} + +impl __sdk::DbContext for DbConnection { + type DbView = RemoteTables; + type Reducers = RemoteReducers; + type Procedures = RemoteProcedures; + + fn db(&self) -> &Self::DbView { + &self.db + } + fn reducers(&self) -> &Self::Reducers { + &self.reducers + } + fn procedures(&self) -> &Self::Procedures { + &self.procedures + } + + fn is_active(&self) -> bool { + self.imp.is_active() + } + + fn disconnect(&self) -> __sdk::Result<()> { + self.imp.disconnect() + } + + type SubscriptionBuilder = __sdk::SubscriptionBuilder; + + fn subscription_builder(&self) -> Self::SubscriptionBuilder { + __sdk::SubscriptionBuilder::new(&self.imp) + } + + fn try_identity(&self) -> Option<__sdk::Identity> { + self.imp.try_identity() + } + fn connection_id(&self) -> __sdk::ConnectionId { + self.imp.connection_id() + } + fn try_connection_id(&self) -> Option<__sdk::ConnectionId> { + self.imp.try_connection_id() + } +} + +impl DbConnection { + /// Builder-pattern constructor for a connection to a remote module. + /// + /// See [`__sdk::DbConnectionBuilder`] for required and optional configuration for the new connection. + pub fn builder() -> __sdk::DbConnectionBuilder { + __sdk::DbConnectionBuilder::new() + } + + /// If any WebSocket messages are waiting, process one of them. + /// + /// Returns `true` if a message was processed, or `false` if the queue is empty. + /// Callers should invoke this message in a loop until it returns `false` + /// or for as much time is available to process messages. + /// + /// Returns an error if the connection is disconnected. + /// If the disconnection in question was normal, + /// i.e. the result of a call to [`__sdk::DbContext::disconnect`], + /// the returned error will be downcastable to [`__sdk::DisconnectedError`]. + /// + /// This is a low-level primitive exposed for power users who need significant control over scheduling. + /// Most applications should call [`Self::frame_tick`] each frame + /// to fully exhaust the queue whenever time is available. + pub fn advance_one_message(&self) -> __sdk::Result { + self.imp.advance_one_message() + } + + /// Process one WebSocket message, potentially blocking the current thread until one is received. + /// + /// Returns an error if the connection is disconnected. + /// If the disconnection in question was normal, + /// i.e. the result of a call to [`__sdk::DbContext::disconnect`], + /// the returned error will be downcastable to [`__sdk::DisconnectedError`]. + /// + /// This is a low-level primitive exposed for power users who need significant control over scheduling. + /// Most applications should call [`Self::run_threaded`] to spawn a thread + /// which advances the connection automatically. + pub fn advance_one_message_blocking(&self) -> __sdk::Result<()> { + self.imp.advance_one_message_blocking() + } + + /// Process one WebSocket message, `await`ing until one is received. + /// + /// Returns an error if the connection is disconnected. + /// If the disconnection in question was normal, + /// i.e. the result of a call to [`__sdk::DbContext::disconnect`], + /// the returned error will be downcastable to [`__sdk::DisconnectedError`]. + /// + /// This is a low-level primitive exposed for power users who need significant control over scheduling. + /// Most applications should call [`Self::run_async`] to run an `async` loop + /// which advances the connection when polled. + pub async fn advance_one_message_async(&self) -> __sdk::Result<()> { + self.imp.advance_one_message_async().await + } + + /// Process all WebSocket messages waiting in the queue, + /// then return without `await`ing or blocking the current thread. + pub fn frame_tick(&self) -> __sdk::Result<()> { + self.imp.frame_tick() + } + + /// Spawn a thread which processes WebSocket messages as they are received. + pub fn run_threaded(&self) -> std::thread::JoinHandle<()> { + self.imp.run_threaded() + } + + /// Run an `async` loop which processes WebSocket messages when polled. + pub async fn run_async(&self) -> __sdk::Result<()> { + self.imp.run_async().await + } +} + +impl __sdk::DbConnection for DbConnection { + fn new(imp: __sdk::DbContextImpl) -> Self { + Self { + db: RemoteTables { imp: imp.clone() }, + reducers: RemoteReducers { imp: imp.clone() }, + procedures: RemoteProcedures { imp: imp.clone() }, + imp, + } + } +} + +/// A handle on a subscribed query. +// TODO: Document this better after implementing the new subscription API. +#[derive(Clone)] +pub struct SubscriptionHandle { + imp: __sdk::SubscriptionHandleImpl, +} + +impl __sdk::InModule for SubscriptionHandle { + type Module = RemoteModule; +} + +impl __sdk::SubscriptionHandle for SubscriptionHandle { + fn new(imp: __sdk::SubscriptionHandleImpl) -> Self { + Self { imp } + } + + /// Returns true if this subscription has been terminated due to an unsubscribe call or an error. + fn is_ended(&self) -> bool { + self.imp.is_ended() + } + + /// Returns true if this subscription has been applied and has not yet been unsubscribed. + fn is_active(&self) -> bool { + self.imp.is_active() + } + + /// Unsubscribe from the query controlled by this `SubscriptionHandle`, + /// then run `on_end` when its rows are removed from the client cache. + fn unsubscribe_then(self, on_end: __sdk::OnEndedCallback) -> __sdk::Result<()> { + self.imp.unsubscribe_then(Some(on_end)) + } + + fn unsubscribe(self) -> __sdk::Result<()> { + self.imp.unsubscribe_then(None) + } +} + +/// Alias trait for a [`__sdk::DbContext`] connected to this module, +/// with that trait's associated types bounded to this module's concrete types. +/// +/// Users can use this trait as a boundary on definitions which should accept +/// either a [`DbConnection`] or an [`EventContext`] and operate on either. +pub trait RemoteDbContext: + __sdk::DbContext< + DbView = RemoteTables, + Reducers = RemoteReducers, + SubscriptionBuilder = __sdk::SubscriptionBuilder, +> +{ +} +impl< + Ctx: __sdk::DbContext< + DbView = RemoteTables, + Reducers = RemoteReducers, + SubscriptionBuilder = __sdk::SubscriptionBuilder, + >, + > RemoteDbContext for Ctx +{ +} + +/// An [`__sdk::DbContext`] augmented with a [`__sdk::Event`], +/// passed to [`__sdk::Table::on_insert`], [`__sdk::Table::on_delete`] and [`__sdk::TableWithPrimaryKey::on_update`] callbacks. +pub struct EventContext { + /// Access to tables defined by the module via extension traits implemented for [`RemoteTables`]. + pub db: RemoteTables, + /// Access to reducers defined by the module via extension traits implemented for [`RemoteReducers`]. + pub reducers: RemoteReducers, + /// Access to procedures defined by the module via extension traits implemented for [`RemoteProcedures`]. + pub procedures: RemoteProcedures, + /// The event which caused these callbacks to run. + pub event: __sdk::Event, + imp: __sdk::DbContextImpl, +} + +impl __sdk::AbstractEventContext for EventContext { + type Event = __sdk::Event; + fn event(&self) -> &Self::Event { + &self.event + } + fn new(imp: __sdk::DbContextImpl, event: Self::Event) -> Self { + Self { + db: RemoteTables { imp: imp.clone() }, + reducers: RemoteReducers { imp: imp.clone() }, + procedures: RemoteProcedures { imp: imp.clone() }, + event, + imp, + } + } +} + +impl __sdk::InModule for EventContext { + type Module = RemoteModule; +} + +impl __sdk::DbContext for EventContext { + type DbView = RemoteTables; + type Reducers = RemoteReducers; + type Procedures = RemoteProcedures; + + fn db(&self) -> &Self::DbView { + &self.db + } + fn reducers(&self) -> &Self::Reducers { + &self.reducers + } + fn procedures(&self) -> &Self::Procedures { + &self.procedures + } + + fn is_active(&self) -> bool { + self.imp.is_active() + } + + fn disconnect(&self) -> __sdk::Result<()> { + self.imp.disconnect() + } + + type SubscriptionBuilder = __sdk::SubscriptionBuilder; + + fn subscription_builder(&self) -> Self::SubscriptionBuilder { + __sdk::SubscriptionBuilder::new(&self.imp) + } + + fn try_identity(&self) -> Option<__sdk::Identity> { + self.imp.try_identity() + } + fn connection_id(&self) -> __sdk::ConnectionId { + self.imp.connection_id() + } + fn try_connection_id(&self) -> Option<__sdk::ConnectionId> { + self.imp.try_connection_id() + } +} + +impl __sdk::EventContext for EventContext {} + +/// An [`__sdk::DbContext`] augmented with a [`__sdk::ReducerEvent`], +/// passed to on-reducer callbacks. +pub struct ReducerEventContext { + /// Access to tables defined by the module via extension traits implemented for [`RemoteTables`]. + pub db: RemoteTables, + /// Access to reducers defined by the module via extension traits implemented for [`RemoteReducers`]. + pub reducers: RemoteReducers, + /// Access to procedures defined by the module via extension traits implemented for [`RemoteProcedures`]. + pub procedures: RemoteProcedures, + /// The event which caused these callbacks to run. + pub event: __sdk::ReducerEvent, + imp: __sdk::DbContextImpl, +} + +impl __sdk::AbstractEventContext for ReducerEventContext { + type Event = __sdk::ReducerEvent; + fn event(&self) -> &Self::Event { + &self.event + } + fn new(imp: __sdk::DbContextImpl, event: Self::Event) -> Self { + Self { + db: RemoteTables { imp: imp.clone() }, + reducers: RemoteReducers { imp: imp.clone() }, + procedures: RemoteProcedures { imp: imp.clone() }, + event, + imp, + } + } +} + +impl __sdk::InModule for ReducerEventContext { + type Module = RemoteModule; +} + +impl __sdk::DbContext for ReducerEventContext { + type DbView = RemoteTables; + type Reducers = RemoteReducers; + type Procedures = RemoteProcedures; + + fn db(&self) -> &Self::DbView { + &self.db + } + fn reducers(&self) -> &Self::Reducers { + &self.reducers + } + fn procedures(&self) -> &Self::Procedures { + &self.procedures + } + + fn is_active(&self) -> bool { + self.imp.is_active() + } + + fn disconnect(&self) -> __sdk::Result<()> { + self.imp.disconnect() + } + + type SubscriptionBuilder = __sdk::SubscriptionBuilder; + + fn subscription_builder(&self) -> Self::SubscriptionBuilder { + __sdk::SubscriptionBuilder::new(&self.imp) + } + + fn try_identity(&self) -> Option<__sdk::Identity> { + self.imp.try_identity() + } + fn connection_id(&self) -> __sdk::ConnectionId { + self.imp.connection_id() + } + fn try_connection_id(&self) -> Option<__sdk::ConnectionId> { + self.imp.try_connection_id() + } +} + +impl __sdk::ReducerEventContext for ReducerEventContext {} + +/// An [`__sdk::DbContext`] passed to procedure callbacks. +pub struct ProcedureEventContext { + /// Access to tables defined by the module via extension traits implemented for [`RemoteTables`]. + pub db: RemoteTables, + /// Access to reducers defined by the module via extension traits implemented for [`RemoteReducers`]. + pub reducers: RemoteReducers, + /// Access to procedures defined by the module via extension traits implemented for [`RemoteProcedures`]. + pub procedures: RemoteProcedures, + imp: __sdk::DbContextImpl, +} + +impl __sdk::AbstractEventContext for ProcedureEventContext { + type Event = (); + fn event(&self) -> &Self::Event { + &() + } + fn new(imp: __sdk::DbContextImpl, _event: Self::Event) -> Self { + Self { + db: RemoteTables { imp: imp.clone() }, + reducers: RemoteReducers { imp: imp.clone() }, + procedures: RemoteProcedures { imp: imp.clone() }, + imp, + } + } +} + +impl __sdk::InModule for ProcedureEventContext { + type Module = RemoteModule; +} + +impl __sdk::DbContext for ProcedureEventContext { + type DbView = RemoteTables; + type Reducers = RemoteReducers; + type Procedures = RemoteProcedures; + + fn db(&self) -> &Self::DbView { + &self.db + } + fn reducers(&self) -> &Self::Reducers { + &self.reducers + } + fn procedures(&self) -> &Self::Procedures { + &self.procedures + } + + fn is_active(&self) -> bool { + self.imp.is_active() + } + + fn disconnect(&self) -> __sdk::Result<()> { + self.imp.disconnect() + } + + type SubscriptionBuilder = __sdk::SubscriptionBuilder; + + fn subscription_builder(&self) -> Self::SubscriptionBuilder { + __sdk::SubscriptionBuilder::new(&self.imp) + } + + fn try_identity(&self) -> Option<__sdk::Identity> { + self.imp.try_identity() + } + fn connection_id(&self) -> __sdk::ConnectionId { + self.imp.connection_id() + } + fn try_connection_id(&self) -> Option<__sdk::ConnectionId> { + self.imp.try_connection_id() + } +} + +impl __sdk::ProcedureEventContext for ProcedureEventContext {} + +/// An [`__sdk::DbContext`] passed to [`__sdk::SubscriptionBuilder::on_applied`] and [`SubscriptionHandle::unsubscribe_then`] callbacks. +pub struct SubscriptionEventContext { + /// Access to tables defined by the module via extension traits implemented for [`RemoteTables`]. + pub db: RemoteTables, + /// Access to reducers defined by the module via extension traits implemented for [`RemoteReducers`]. + pub reducers: RemoteReducers, + /// Access to procedures defined by the module via extension traits implemented for [`RemoteProcedures`]. + pub procedures: RemoteProcedures, + imp: __sdk::DbContextImpl, +} + +impl __sdk::AbstractEventContext for SubscriptionEventContext { + type Event = (); + fn event(&self) -> &Self::Event { + &() + } + fn new(imp: __sdk::DbContextImpl, _event: Self::Event) -> Self { + Self { + db: RemoteTables { imp: imp.clone() }, + reducers: RemoteReducers { imp: imp.clone() }, + procedures: RemoteProcedures { imp: imp.clone() }, + imp, + } + } +} + +impl __sdk::InModule for SubscriptionEventContext { + type Module = RemoteModule; +} + +impl __sdk::DbContext for SubscriptionEventContext { + type DbView = RemoteTables; + type Reducers = RemoteReducers; + type Procedures = RemoteProcedures; + + fn db(&self) -> &Self::DbView { + &self.db + } + fn reducers(&self) -> &Self::Reducers { + &self.reducers + } + fn procedures(&self) -> &Self::Procedures { + &self.procedures + } + + fn is_active(&self) -> bool { + self.imp.is_active() + } + + fn disconnect(&self) -> __sdk::Result<()> { + self.imp.disconnect() + } + + type SubscriptionBuilder = __sdk::SubscriptionBuilder; + + fn subscription_builder(&self) -> Self::SubscriptionBuilder { + __sdk::SubscriptionBuilder::new(&self.imp) + } + + fn try_identity(&self) -> Option<__sdk::Identity> { + self.imp.try_identity() + } + fn connection_id(&self) -> __sdk::ConnectionId { + self.imp.connection_id() + } + fn try_connection_id(&self) -> Option<__sdk::ConnectionId> { + self.imp.try_connection_id() + } +} + +impl __sdk::SubscriptionEventContext for SubscriptionEventContext {} + +/// An [`__sdk::DbContext`] augmented with a [`__sdk::Error`], +/// passed to [`__sdk::DbConnectionBuilder::on_disconnect`], [`__sdk::DbConnectionBuilder::on_connect_error`] and [`__sdk::SubscriptionBuilder::on_error`] callbacks. +pub struct ErrorContext { + /// Access to tables defined by the module via extension traits implemented for [`RemoteTables`]. + pub db: RemoteTables, + /// Access to reducers defined by the module via extension traits implemented for [`RemoteReducers`]. + pub reducers: RemoteReducers, + /// Access to procedures defined by the module via extension traits implemented for [`RemoteProcedures`]. + pub procedures: RemoteProcedures, + /// The event which caused these callbacks to run. + pub event: Option<__sdk::Error>, + imp: __sdk::DbContextImpl, +} + +impl __sdk::AbstractEventContext for ErrorContext { + type Event = Option<__sdk::Error>; + fn event(&self) -> &Self::Event { + &self.event + } + fn new(imp: __sdk::DbContextImpl, event: Self::Event) -> Self { + Self { + db: RemoteTables { imp: imp.clone() }, + reducers: RemoteReducers { imp: imp.clone() }, + procedures: RemoteProcedures { imp: imp.clone() }, + event, + imp, + } + } +} + +impl __sdk::InModule for ErrorContext { + type Module = RemoteModule; +} + +impl __sdk::DbContext for ErrorContext { + type DbView = RemoteTables; + type Reducers = RemoteReducers; + type Procedures = RemoteProcedures; + + fn db(&self) -> &Self::DbView { + &self.db + } + fn reducers(&self) -> &Self::Reducers { + &self.reducers + } + fn procedures(&self) -> &Self::Procedures { + &self.procedures + } + + fn is_active(&self) -> bool { + self.imp.is_active() + } + + fn disconnect(&self) -> __sdk::Result<()> { + self.imp.disconnect() + } + + type SubscriptionBuilder = __sdk::SubscriptionBuilder; + + fn subscription_builder(&self) -> Self::SubscriptionBuilder { + __sdk::SubscriptionBuilder::new(&self.imp) + } + + fn try_identity(&self) -> Option<__sdk::Identity> { + self.imp.try_identity() + } + fn connection_id(&self) -> __sdk::ConnectionId { + self.imp.connection_id() + } + fn try_connection_id(&self) -> Option<__sdk::ConnectionId> { + self.imp.try_connection_id() + } +} + +impl __sdk::ErrorContext for ErrorContext {} + +impl __sdk::SpacetimeModule for RemoteModule { + type DbConnection = DbConnection; + type EventContext = EventContext; + type ReducerEventContext = ReducerEventContext; + type ProcedureEventContext = ProcedureEventContext; + type SubscriptionEventContext = SubscriptionEventContext; + type ErrorContext = ErrorContext; + type Reducer = Reducer; + type DbView = RemoteTables; + type Reducers = RemoteReducers; + type Procedures = RemoteProcedures; + type DbUpdate = DbUpdate; + type AppliedDiff<'r> = AppliedDiff<'r>; + type SubscriptionHandle = SubscriptionHandle; + type QueryBuilder = __sdk::QueryBuilder; + + fn register_tables(client_cache: &mut __sdk::ClientCache) { + test_event_table::register_table(client_cache); + } + const ALL_TABLE_NAMES: &'static [&'static str] = &["test_event"]; +} diff --git a/sdks/rust/tests/event-table-client/src/module_bindings/noop_reducer.rs b/sdks/rust/tests/event-table-client/src/module_bindings/noop_reducer.rs new file mode 100644 index 00000000000..f999e055148 --- /dev/null +++ b/sdks/rust/tests/event-table-client/src/module_bindings/noop_reducer.rs @@ -0,0 +1,61 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct NoopArgs {} + +impl From for super::Reducer { + fn from(args: NoopArgs) -> Self { + Self::Noop + } +} + +impl __sdk::InModule for NoopArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `noop`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait noop { + /// Request that the remote module invoke the reducer `noop` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`noop:noop_then`] to run a callback after the reducer completes. + fn noop(&self) -> __sdk::Result<()> { + self.noop_then(|_, _| {}) + } + + /// Request that the remote module invoke the reducer `noop` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn noop_then( + &self, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl noop for super::RemoteReducers { + fn noop_then( + &self, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp.invoke_reducer_with_callback(NoopArgs {}, callback) + } +} diff --git a/sdks/rust/tests/event-table-client/src/module_bindings/test_event_table.rs b/sdks/rust/tests/event-table-client/src/module_bindings/test_event_table.rs new file mode 100644 index 00000000000..b6d14f66674 --- /dev/null +++ b/sdks/rust/tests/event-table-client/src/module_bindings/test_event_table.rs @@ -0,0 +1,95 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::test_event_type::TestEvent; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `test_event`. +/// +/// Obtain a handle from the [`TestEventTableAccess::test_event`] method on [`super::RemoteTables`], +/// like `ctx.db.test_event()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.test_event().on_insert(...)`. +pub struct TestEventTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `test_event`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait TestEventTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`TestEventTableHandle`], which mediates access to the table `test_event`. + fn test_event(&self) -> TestEventTableHandle<'_>; +} + +impl TestEventTableAccess for super::RemoteTables { + fn test_event(&self) -> TestEventTableHandle<'_> { + TestEventTableHandle { + imp: self.imp.get_table::("test_event"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct TestEventInsertCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::EventTable for TestEventTableHandle<'ctx> { + type Row = TestEvent; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = TestEventInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> TestEventInsertCallbackId { + TestEventInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: TestEventInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("test_event"); +} + +#[doc(hidden)] +pub(super) fn parse_table_update(raw_updates: __ws::v2::TableUpdate) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `TestEvent`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait test_eventQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `TestEvent`. + fn test_event(&self) -> __sdk::__query_builder::Table; +} + +impl test_eventQueryTableAccess for __sdk::QueryTableAccessor { + fn test_event(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("test_event") + } +} diff --git a/sdks/rust/tests/event-table-client/src/module_bindings/test_event_type.rs b/sdks/rust/tests/event-table-client/src/module_bindings/test_event_type.rs new file mode 100644 index 00000000000..03025351b0a --- /dev/null +++ b/sdks/rust/tests/event-table-client/src/module_bindings/test_event_type.rs @@ -0,0 +1,46 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct TestEvent { + pub name: String, + pub value: u64, +} + +impl __sdk::InModule for TestEvent { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `TestEvent`. +/// +/// Provides typed access to columns for query building. +pub struct TestEventCols { + pub name: __sdk::__query_builder::Col, + pub value: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for TestEvent { + type Cols = TestEventCols; + fn cols(table_name: &'static str) -> Self::Cols { + TestEventCols { + name: __sdk::__query_builder::Col::new(table_name, "name"), + value: __sdk::__query_builder::Col::new(table_name, "value"), + } + } +} + +/// Indexed column accessor struct for the table `TestEvent`. +/// +/// Provides typed access to indexed columns for query building. +pub struct TestEventIxCols {} + +impl __sdk::__query_builder::HasIxCols for TestEvent { + type IxCols = TestEventIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + TestEventIxCols {} + } +} diff --git a/sdks/rust/tests/test-client/src/main.rs b/sdks/rust/tests/test-client/src/main.rs index 53b9c5e5606..8429d1fa9a5 100644 --- a/sdks/rust/tests/test-client/src/main.rs +++ b/sdks/rust/tests/test-client/src/main.rs @@ -1010,7 +1010,7 @@ fn exec_fail_reducer() { let test_counter = TestCounter::new(); let sub_applied_nothing_result = test_counter.add_test("on_subscription_applied_nothing"); let reducer_success_result = test_counter.add_test("reducer-callback-success"); - let reducer_fail_result = test_counter.add_test("reducer-callback-failure"); + let reducer_fail_result = test_counter.add_test("reducer-callback-fail"); let connection = connect(&test_counter); @@ -1061,16 +1061,12 @@ fn exec_fail_reducer() { ctx.reducers .insert_pk_u_8_then(key, fail_data, move |ctx, status| { let run_checks = || { - if let Ok(Ok(())) = &status { - anyhow::bail!( - "Expected reducer `insert_pk_u_8` to error or panic, but got a successful return" - ) + match &status { + Ok(Err(_err_msg)) => {} + other => anyhow::bail!("Expected reducer error but got {other:?}"), } - - if matches!(ctx.event.status, Status::Committed) { - anyhow::bail!( - "Expected reducer `insert_pk_u_8` to error or panic, but got a `Status::Committed`" - ); + if !matches!(ctx.event.status, Status::Err(_)) { + anyhow::bail!("Unexpected status. Expected Err but found {:?}", ctx.event.status); } let expected_reducer = Reducer::InsertPkU8 { n: key, @@ -1869,7 +1865,7 @@ fn exec_subscribe_all_select_star() { sub_applied_nothing_result(assert_all_tables_empty(ctx)); } }) - .on_error(|_, e| panic!("Subscription error: {e:?}")) + .on_error(|_, _| panic!("Subscription error")) .subscribe_to_all_tables(); test_counter.wait_for_all(); diff --git a/sdks/rust/tests/test.rs b/sdks/rust/tests/test.rs index afc1e83487b..d6c5cac38ae 100644 --- a/sdks/rust/tests/test.rs +++ b/sdks/rust/tests/test.rs @@ -316,6 +316,44 @@ declare_tests_with_suffix!(typescript, "-ts"); declare_tests_with_suffix!(csharp, "-cs"); declare_tests_with_suffix!(cpp, "-cpp"); +/// Tests of event table functionality, using <./event-table-client> and <../../../modules/sdk-test>. +/// +/// These are separate from the existing client because as of writing (2026-02-07), +/// we do not have event table support in all of the module languages we have tested. +mod event_table_tests { + use spacetimedb_testing::sdk::Test; + + const MODULE: &str = "sdk-test-event-table"; + const CLIENT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/event-table-client"); + + fn make_test(subcommand: &str) -> Test { + Test::builder() + .with_name(subcommand) + .with_module(MODULE) + .with_client(CLIENT) + .with_language("rust") + .with_bindings_dir("src/module_bindings") + .with_compile_command("cargo build") + .with_run_command(format!("cargo run -- {}", subcommand)) + .build() + } + + #[test] + fn event_table() { + make_test("event-table").run(); + } + + #[test] + fn multiple_events() { + make_test("multiple-events").run(); + } + + #[test] + fn events_dont_persist() { + make_test("events-dont-persist").run(); + } +} + macro_rules! procedure_tests { ($mod_name:ident, $suffix:literal) => { mod $mod_name { diff --git a/sdks/rust/tests/view-client/src/module_bindings/mod.rs b/sdks/rust/tests/view-client/src/module_bindings/mod.rs index c5a296bc9b3..b2c244afef7 100644 --- a/sdks/rust/tests/view-client/src/module_bindings/mod.rs +++ b/sdks/rust/tests/view-client/src/module_bindings/mod.rs @@ -1,7 +1,7 @@ // 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 cli version 1.12.0 (commit 729b96fecd3ee7ab4f69443b80cafa6d39c2782e). +// This was generated using spacetimedb cli version 2.0.0 (commit 9e0e81a6aaec6bf3619cfb9f7916743d86ab7ffc). #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; @@ -15,7 +15,6 @@ pub mod nearby_players_table; pub mod player_and_level_type; pub mod player_level_table; pub mod player_level_type; -pub mod player_location_table; pub mod player_location_type; pub mod player_table; pub mod player_type; @@ -30,7 +29,6 @@ pub use nearby_players_table::*; pub use player_and_level_type::PlayerAndLevel; pub use player_level_table::*; pub use player_level_type::PlayerLevel; -pub use player_location_table::*; pub use player_location_type::PlayerLocation; pub use player_table::*; pub use player_type::Player; @@ -92,7 +90,6 @@ pub struct DbUpdate { nearby_players: __sdk::TableUpdate, player: __sdk::TableUpdate, player_level: __sdk::TableUpdate, - player_location: __sdk::TableUpdate, players_at_level_0: __sdk::TableUpdate, } @@ -115,9 +112,6 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "player_level" => db_update .player_level .append(player_level_table::parse_table_update(table_update)?), - "player_location" => db_update - .player_location - .append(player_location_table::parse_table_update(table_update)?), "players_at_level_0" => db_update .players_at_level_0 .append(players_at_level_0_table::parse_table_update(table_update)?), @@ -143,7 +137,6 @@ impl __sdk::DbUpdate for DbUpdate { .apply_diff_to_table::("player", &self.player) .with_updates_by_pk(|row| &row.entity_id); diff.player_level = cache.apply_diff_to_table::("player_level", &self.player_level); - diff.player_location = cache.apply_diff_to_table::("player_location", &self.player_location); diff.my_player = cache.apply_diff_to_table::("my_player", &self.my_player); diff.my_player_and_level = cache.apply_diff_to_table::("my_player_and_level", &self.my_player_and_level); @@ -171,9 +164,6 @@ impl __sdk::DbUpdate for DbUpdate { "player_level" => db_update .player_level .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), - "player_location" => db_update - .player_location - .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "players_at_level_0" => db_update .players_at_level_0 .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -203,9 +193,6 @@ impl __sdk::DbUpdate for DbUpdate { "player_level" => db_update .player_level .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), - "player_location" => db_update - .player_location - .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "players_at_level_0" => db_update .players_at_level_0 .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -227,7 +214,6 @@ pub struct AppliedDiff<'r> { nearby_players: __sdk::TableAppliedDiff<'r, PlayerLocation>, player: __sdk::TableAppliedDiff<'r, Player>, player_level: __sdk::TableAppliedDiff<'r, PlayerLevel>, - player_location: __sdk::TableAppliedDiff<'r, PlayerLocation>, players_at_level_0: __sdk::TableAppliedDiff<'r, Player>, __unused: std::marker::PhantomData<&'r ()>, } @@ -243,7 +229,6 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { callbacks.invoke_table_row_callbacks::("nearby_players", &self.nearby_players, event); callbacks.invoke_table_row_callbacks::("player", &self.player, event); callbacks.invoke_table_row_callbacks::("player_level", &self.player_level, event); - callbacks.invoke_table_row_callbacks::("player_location", &self.player_location, event); callbacks.invoke_table_row_callbacks::("players_at_level_0", &self.players_at_level_0, event); } } @@ -894,7 +879,6 @@ impl __sdk::SpacetimeModule for RemoteModule { nearby_players_table::register_table(client_cache); player_table::register_table(client_cache); player_level_table::register_table(client_cache); - player_location_table::register_table(client_cache); players_at_level_0_table::register_table(client_cache); } const ALL_TABLE_NAMES: &'static [&'static str] = &[ @@ -903,7 +887,6 @@ impl __sdk::SpacetimeModule for RemoteModule { "nearby_players", "player", "player_level", - "player_location", "players_at_level_0", ]; } diff --git a/sdks/rust/tests/view-client/src/module_bindings/my_player_and_level_table.rs b/sdks/rust/tests/view-client/src/module_bindings/my_player_and_level_table.rs index ac0ac773bdc..d6455ec7763 100644 --- a/sdks/rust/tests/view-client/src/module_bindings/my_player_and_level_table.rs +++ b/sdks/rust/tests/view-client/src/module_bindings/my_player_and_level_table.rs @@ -38,6 +38,7 @@ impl MyPlayerAndLevelTableAccess for super::RemoteTables { } pub struct MyPlayerAndLevelInsertCallbackId(__sdk::CallbackId); + pub struct MyPlayerAndLevelDeleteCallbackId(__sdk::CallbackId); impl<'ctx> __sdk::Table for MyPlayerAndLevelTableHandle<'ctx> { diff --git a/sdks/rust/tests/view-client/src/module_bindings/my_player_table.rs b/sdks/rust/tests/view-client/src/module_bindings/my_player_table.rs index ddb0d624e15..5b1cf86fa7e 100644 --- a/sdks/rust/tests/view-client/src/module_bindings/my_player_table.rs +++ b/sdks/rust/tests/view-client/src/module_bindings/my_player_table.rs @@ -38,6 +38,7 @@ impl MyPlayerTableAccess for super::RemoteTables { } pub struct MyPlayerInsertCallbackId(__sdk::CallbackId); + pub struct MyPlayerDeleteCallbackId(__sdk::CallbackId); impl<'ctx> __sdk::Table for MyPlayerTableHandle<'ctx> { diff --git a/sdks/rust/tests/view-client/src/module_bindings/nearby_players_table.rs b/sdks/rust/tests/view-client/src/module_bindings/nearby_players_table.rs index 9ecc2f275fe..cf35955108c 100644 --- a/sdks/rust/tests/view-client/src/module_bindings/nearby_players_table.rs +++ b/sdks/rust/tests/view-client/src/module_bindings/nearby_players_table.rs @@ -38,6 +38,7 @@ impl NearbyPlayersTableAccess for super::RemoteTables { } pub struct NearbyPlayersInsertCallbackId(__sdk::CallbackId); + pub struct NearbyPlayersDeleteCallbackId(__sdk::CallbackId); impl<'ctx> __sdk::Table for NearbyPlayersTableHandle<'ctx> { diff --git a/sdks/rust/tests/view-client/src/module_bindings/player_level_table.rs b/sdks/rust/tests/view-client/src/module_bindings/player_level_table.rs index 7cd3eec5a85..e61a83840f5 100644 --- a/sdks/rust/tests/view-client/src/module_bindings/player_level_table.rs +++ b/sdks/rust/tests/view-client/src/module_bindings/player_level_table.rs @@ -38,6 +38,7 @@ impl PlayerLevelTableAccess for super::RemoteTables { } pub struct PlayerLevelInsertCallbackId(__sdk::CallbackId); + pub struct PlayerLevelDeleteCallbackId(__sdk::CallbackId); impl<'ctx> __sdk::Table for PlayerLevelTableHandle<'ctx> { @@ -78,21 +79,6 @@ impl<'ctx> __sdk::Table for PlayerLevelTableHandle<'ctx> { } } -#[doc(hidden)] -pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { - let _table = client_cache.get_or_make_table::("player_level"); - _table.add_unique_constraint::("entity_id", |row| &row.entity_id); -} - -#[doc(hidden)] -pub(super) fn parse_table_update(raw_updates: __ws::v2::TableUpdate) -> __sdk::Result<__sdk::TableUpdate> { - __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { - __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") - .with_cause(e) - .into() - }) -} - /// Access to the `entity_id` unique index on the table `player_level`, /// which allows point queries on the field of the same name /// via the [`PlayerLevelEntityIdUnique::find`] method. @@ -123,6 +109,21 @@ impl<'ctx> PlayerLevelEntityIdUnique<'ctx> { } } +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("player_level"); + _table.add_unique_constraint::("entity_id", |row| &row.entity_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update(raw_updates: __ws::v2::TableUpdate) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + #[allow(non_camel_case_types)] /// Extension trait for query builder access to the table `PlayerLevel`. /// diff --git a/sdks/rust/tests/view-client/src/module_bindings/player_level_type.rs b/sdks/rust/tests/view-client/src/module_bindings/player_level_type.rs index 98b25e6a137..8a5ba6071e4 100644 --- a/sdks/rust/tests/view-client/src/module_bindings/player_level_type.rs +++ b/sdks/rust/tests/view-client/src/module_bindings/player_level_type.rs @@ -50,3 +50,5 @@ impl __sdk::__query_builder::HasIxCols for PlayerLevel { } } } + +impl __sdk::__query_builder::CanBeLookupTable for PlayerLevel {} diff --git a/sdks/rust/tests/view-client/src/module_bindings/player_location_table.rs b/sdks/rust/tests/view-client/src/module_bindings/player_location_table.rs deleted file mode 100644 index efe15384b4d..00000000000 --- a/sdks/rust/tests/view-client/src/module_bindings/player_location_table.rs +++ /dev/null @@ -1,142 +0,0 @@ -// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE -// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. - -#![allow(unused, clippy::all)] -use super::player_location_type::PlayerLocation; -use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; - -/// Table handle for the table `player_location`. -/// -/// Obtain a handle from the [`PlayerLocationTableAccess::player_location`] method on [`super::RemoteTables`], -/// like `ctx.db.player_location()`. -/// -/// Users are encouraged not to explicitly reference this type, -/// but to directly chain method calls, -/// like `ctx.db.player_location().on_insert(...)`. -pub struct PlayerLocationTableHandle<'ctx> { - imp: __sdk::TableHandle, - ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, -} - -#[allow(non_camel_case_types)] -/// Extension trait for access to the table `player_location`. -/// -/// Implemented for [`super::RemoteTables`]. -pub trait PlayerLocationTableAccess { - #[allow(non_snake_case)] - /// Obtain a [`PlayerLocationTableHandle`], which mediates access to the table `player_location`. - fn player_location(&self) -> PlayerLocationTableHandle<'_>; -} - -impl PlayerLocationTableAccess for super::RemoteTables { - fn player_location(&self) -> PlayerLocationTableHandle<'_> { - PlayerLocationTableHandle { - imp: self.imp.get_table::("player_location"), - ctx: std::marker::PhantomData, - } - } -} - -pub struct PlayerLocationInsertCallbackId(__sdk::CallbackId); -pub struct PlayerLocationDeleteCallbackId(__sdk::CallbackId); - -impl<'ctx> __sdk::Table for PlayerLocationTableHandle<'ctx> { - type Row = PlayerLocation; - type EventContext = super::EventContext; - - fn count(&self) -> u64 { - self.imp.count() - } - fn iter(&self) -> impl Iterator + '_ { - self.imp.iter() - } - - type InsertCallbackId = PlayerLocationInsertCallbackId; - - fn on_insert( - &self, - callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, - ) -> PlayerLocationInsertCallbackId { - PlayerLocationInsertCallbackId(self.imp.on_insert(Box::new(callback))) - } - - fn remove_on_insert(&self, callback: PlayerLocationInsertCallbackId) { - self.imp.remove_on_insert(callback.0) - } - - type DeleteCallbackId = PlayerLocationDeleteCallbackId; - - fn on_delete( - &self, - callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, - ) -> PlayerLocationDeleteCallbackId { - PlayerLocationDeleteCallbackId(self.imp.on_delete(Box::new(callback))) - } - - fn remove_on_delete(&self, callback: PlayerLocationDeleteCallbackId) { - self.imp.remove_on_delete(callback.0) - } -} - -#[doc(hidden)] -pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { - let _table = client_cache.get_or_make_table::("player_location"); - _table.add_unique_constraint::("entity_id", |row| &row.entity_id); -} - -#[doc(hidden)] -pub(super) fn parse_table_update( - raw_updates: __ws::v2::TableUpdate, -) -> __sdk::Result<__sdk::TableUpdate> { - __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { - __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") - .with_cause(e) - .into() - }) -} - -/// Access to the `entity_id` unique index on the table `player_location`, -/// which allows point queries on the field of the same name -/// via the [`PlayerLocationEntityIdUnique::find`] method. -/// -/// Users are encouraged not to explicitly reference this type, -/// but to directly chain method calls, -/// like `ctx.db.player_location().entity_id().find(...)`. -pub struct PlayerLocationEntityIdUnique<'ctx> { - imp: __sdk::UniqueConstraintHandle, - phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, -} - -impl<'ctx> PlayerLocationTableHandle<'ctx> { - /// Get a handle on the `entity_id` unique index on the table `player_location`. - pub fn entity_id(&self) -> PlayerLocationEntityIdUnique<'ctx> { - PlayerLocationEntityIdUnique { - imp: self.imp.get_unique_constraint::("entity_id"), - phantom: std::marker::PhantomData, - } - } -} - -impl<'ctx> PlayerLocationEntityIdUnique<'ctx> { - /// Find the subscribed row whose `entity_id` column value is equal to `col_val`, - /// if such a row is present in the client cache. - pub fn find(&self, col_val: &u64) -> Option { - self.imp.find(col_val) - } -} - -#[allow(non_camel_case_types)] -/// Extension trait for query builder access to the table `PlayerLocation`. -/// -/// Implemented for [`__sdk::QueryTableAccessor`]. -pub trait player_locationQueryTableAccess { - #[allow(non_snake_case)] - /// Get a query builder for the table `PlayerLocation`. - fn player_location(&self) -> __sdk::__query_builder::Table; -} - -impl player_locationQueryTableAccess for __sdk::QueryTableAccessor { - fn player_location(&self) -> __sdk::__query_builder::Table { - __sdk::__query_builder::Table::new("player_location") - } -} diff --git a/sdks/rust/tests/view-client/src/module_bindings/player_location_type.rs b/sdks/rust/tests/view-client/src/module_bindings/player_location_type.rs index a2d29d18188..51e3d45390f 100644 --- a/sdks/rust/tests/view-client/src/module_bindings/player_location_type.rs +++ b/sdks/rust/tests/view-client/src/module_bindings/player_location_type.rs @@ -56,3 +56,5 @@ impl __sdk::__query_builder::HasIxCols for PlayerLocation { } } } + +impl __sdk::__query_builder::CanBeLookupTable for PlayerLocation {} diff --git a/sdks/rust/tests/view-client/src/module_bindings/player_table.rs b/sdks/rust/tests/view-client/src/module_bindings/player_table.rs index 916d5b5db4d..a18246e13dc 100644 --- a/sdks/rust/tests/view-client/src/module_bindings/player_table.rs +++ b/sdks/rust/tests/view-client/src/module_bindings/player_table.rs @@ -38,6 +38,7 @@ impl PlayerTableAccess for super::RemoteTables { } pub struct PlayerInsertCallbackId(__sdk::CallbackId); + pub struct PlayerDeleteCallbackId(__sdk::CallbackId); impl<'ctx> __sdk::Table for PlayerTableHandle<'ctx> { @@ -78,12 +79,6 @@ impl<'ctx> __sdk::Table for PlayerTableHandle<'ctx> { } } -#[doc(hidden)] -pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { - let _table = client_cache.get_or_make_table::("player"); - _table.add_unique_constraint::("entity_id", |row| &row.entity_id); - _table.add_unique_constraint::<__sdk::Identity>("identity", |row| &row.identity); -} pub struct PlayerUpdateCallbackId(__sdk::CallbackId); impl<'ctx> __sdk::TableWithPrimaryKey for PlayerTableHandle<'ctx> { @@ -101,15 +96,6 @@ impl<'ctx> __sdk::TableWithPrimaryKey for PlayerTableHandle<'ctx> { } } -#[doc(hidden)] -pub(super) fn parse_table_update(raw_updates: __ws::v2::TableUpdate) -> __sdk::Result<__sdk::TableUpdate> { - __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { - __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") - .with_cause(e) - .into() - }) -} - /// Access to the `entity_id` unique index on the table `player`, /// which allows point queries on the field of the same name /// via the [`PlayerEntityIdUnique::find`] method. @@ -170,6 +156,22 @@ impl<'ctx> PlayerIdentityUnique<'ctx> { } } +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("player"); + _table.add_unique_constraint::("entity_id", |row| &row.entity_id); + _table.add_unique_constraint::<__sdk::Identity>("identity", |row| &row.identity); +} + +#[doc(hidden)] +pub(super) fn parse_table_update(raw_updates: __ws::v2::TableUpdate) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + #[allow(non_camel_case_types)] /// Extension trait for query builder access to the table `Player`. /// diff --git a/sdks/rust/tests/view-client/src/module_bindings/player_type.rs b/sdks/rust/tests/view-client/src/module_bindings/player_type.rs index b99ac46acf3..57b07de9a0b 100644 --- a/sdks/rust/tests/view-client/src/module_bindings/player_type.rs +++ b/sdks/rust/tests/view-client/src/module_bindings/player_type.rs @@ -50,3 +50,5 @@ impl __sdk::__query_builder::HasIxCols for Player { } } } + +impl __sdk::__query_builder::CanBeLookupTable for Player {} diff --git a/sdks/rust/tests/view-client/src/module_bindings/players_at_level_0_table.rs b/sdks/rust/tests/view-client/src/module_bindings/players_at_level_0_table.rs index 57c3ae723eb..12b43390843 100644 --- a/sdks/rust/tests/view-client/src/module_bindings/players_at_level_0_table.rs +++ b/sdks/rust/tests/view-client/src/module_bindings/players_at_level_0_table.rs @@ -38,6 +38,7 @@ impl PlayersAtLevel0TableAccess for super::RemoteTables { } pub struct PlayersAtLevel0InsertCallbackId(__sdk::CallbackId); + pub struct PlayersAtLevel0DeleteCallbackId(__sdk::CallbackId); impl<'ctx> __sdk::Table for PlayersAtLevel0TableHandle<'ctx> {