From 40beb1a6e1542fb7bf4c5247f7f59c3d15317fe1 Mon Sep 17 00:00:00 2001 From: Egor Vorontsov Date: Mon, 12 Jan 2026 02:43:00 +0300 Subject: [PATCH 1/3] Implemented `UniqueColumn::try_update()`. Resolves #2128 --- crates/bindings/README.md | 4 +-- crates/bindings/src/table.rs | 67 +++++++++++++++++++++++++++++++++--- 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/crates/bindings/README.md b/crates/bindings/README.md index ea0046b287b..e7972d67340 100644 --- a/crates/bindings/README.md +++ b/crates/bindings/README.md @@ -378,9 +378,9 @@ ctx.db.person().ssn() - [`UniqueColumn::find`] - [`UniqueColumn::delete`] - [`UniqueColumn::update`] - +- [`UniqueColumn::try_update`] -Notice that updating a row is only possible if a row has a unique column -- there is no `update` method in the base [`Table`] trait. SpacetimeDB has no notion of rows having an "identity" aside from their unique / primary keys. +Notice that updating a row is only possible if a row has a unique column -- there is no `update` or `try_update` method in the base [`Table`] trait. SpacetimeDB has no notion of rows having an "identity" aside from their unique / primary keys. The `#[primary_key]` annotation implies `#[unique]` annotation, but avails additional methods in the [client]-side SDKs. diff --git a/crates/bindings/src/table.rs b/crates/bindings/src/table.rs index 6865aa48e98..b2441df6522 100644 --- a/crates/bindings/src/table.rs +++ b/crates/bindings/src/table.rs @@ -259,6 +259,48 @@ impl From> for String { } } +/// The error type returned from [`UniqueColumn::try_update()`], signalling a constraint violation. +pub enum TryUpdateError { + /// A [`UniqueConstraintViolation`]. + /// + /// Returned from [`Table::try_update`] if an attempted update + /// has the same value in a unique column as an already-present row + /// (excluding the update key itself). + UniqueConstraintViolation(Tbl::UniqueConstraintViolation), +} + +impl fmt::Debug for TryUpdateError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "TryUpdateError::<{}>::", Tbl::TABLE_NAME)?; + match self { + Self::UniqueConstraintViolation(e) => fmt::Debug::fmt(e, f), + } + } +} + +impl fmt::Display for TryUpdateError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "update error on table `{}`:", Tbl::TABLE_NAME)?; + match self { + Self::UniqueConstraintViolation(e) => fmt::Display::fmt(e, f), + } + } +} + +impl std::error::Error for TryUpdateError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(match self { + Self::UniqueConstraintViolation(e) => e, + }) + } +} + +impl From> for String { + fn from(err: TryUpdateError) -> Self { + err.to_string() + } +} + #[doc(hidden)] pub trait MaybeError: std::error::Error + Send + Sync + Sized + 'static { fn get() -> Option; @@ -388,8 +430,17 @@ impl> UniqueColumn Tbl::Row + pub fn update(&self, new_row: Tbl::Row) -> Tbl::Row { + self.try_update(new_row).unwrap_or_else(|e| panic!("{e}")) + } + + /// Counterpart to [`Self::update`] which allows handling failed updates. + /// + /// This method returns an `Err` when the update fails rather than panicking. + #[track_caller] + pub fn try_update(&self, new_row: Tbl::Row) -> Result> where Col: PrimaryKey, { @@ -1374,7 +1425,7 @@ fn insert(mut row: T::Row, mut buf: IterBuf) -> Result(index_id: IndexId, mut row: T::Row, mut buf: IterBuf) -> T::Row { +fn update(index_id: IndexId, mut row: T::Row, mut buf: IterBuf) -> Result> { let table_id = T::table_id(); // Encode the row as bsatn into the buffer `buf`. buf.clear(); @@ -1387,9 +1438,15 @@ fn update(index_id: IndexId, mut row: T::Row, mut buf: IterBuf) -> T:: T::integrate_generated_columns(&mut row, gen_cols); row }); - - // TODO(centril): introduce a `TryUpdateError`. - res.unwrap_or_else(|e| panic!("unexpected update error: {e}")) + res.map_err(|e| { + let err = match e { + sys::Errno::UNIQUE_ALREADY_EXISTS => { + T::UniqueConstraintViolation::get().map(TryUpdateError::UniqueConstraintViolation) + } + _ => None, + }; + err.unwrap_or_else(|| panic!("unexpected update error: {e}")) + }) } /// A table iterator which yields values of the `TableType` corresponding to the table. From 056d147622b1577dbeb2366f6a693ba312c39c44 Mon Sep 17 00:00:00 2001 From: clockwork-labs-bot Date: Tue, 28 Apr 2026 11:39:38 -0400 Subject: [PATCH 2/3] Add regression test for try_update unique conflicts --- crates/bindings/src/table.rs | 5 +- modules/sdk-test/src/lib.rs | 31 +++ ...t_pk_try_update_unique_conflict_reducer.rs | 76 +++++++ .../test-client/src/module_bindings/mod.rs | 60 +++++- .../pk_try_update_unique_conflict_table.rs | 192 ++++++++++++++++++ .../pk_try_update_unique_conflict_type.rs | 57 ++++++ ...e_pk_try_update_unique_conflict_reducer.rs | 78 +++++++ .../tests/test-client/src/test_handlers.rs | 170 ++++++++++++++++ sdks/rust/tests/test.rs | 5 + 9 files changed, 672 insertions(+), 2 deletions(-) create mode 100644 sdks/rust/tests/test-client/src/module_bindings/insert_pk_try_update_unique_conflict_reducer.rs create mode 100644 sdks/rust/tests/test-client/src/module_bindings/pk_try_update_unique_conflict_table.rs create mode 100644 sdks/rust/tests/test-client/src/module_bindings/pk_try_update_unique_conflict_type.rs create mode 100644 sdks/rust/tests/test-client/src/module_bindings/try_update_pk_try_update_unique_conflict_reducer.rs diff --git a/crates/bindings/src/table.rs b/crates/bindings/src/table.rs index b2441df6522..00eed6ceccf 100644 --- a/crates/bindings/src/table.rs +++ b/crates/bindings/src/table.rs @@ -432,7 +432,10 @@ impl> UniqueColumn Tbl::Row { + pub fn update(&self, new_row: Tbl::Row) -> Tbl::Row + where + Col: PrimaryKey, + { self.try_update(new_row).unwrap_or_else(|e| panic!("{e}")) } diff --git a/modules/sdk-test/src/lib.rs b/modules/sdk-test/src/lib.rs index 2b4a78f9afa..3deca0004f5 100644 --- a/modules/sdk-test/src/lib.rs +++ b/modules/sdk-test/src/lib.rs @@ -480,6 +480,37 @@ define_tables! { } #[unique] u Uuid, data i32; } +#[spacetimedb::table(accessor = pk_try_update_unique_conflict, public)] +pub struct PkTryUpdateUniqueConflict { + #[primary_key] + id: u32, + #[unique] + unique_value: u32, + data: i32, +} + +#[spacetimedb::reducer] +fn insert_pk_try_update_unique_conflict(ctx: &ReducerContext, id: u32, unique_value: u32, data: i32) { + ctx.db + .pk_try_update_unique_conflict() + .insert(PkTryUpdateUniqueConflict { id, unique_value, data }); +} + +#[spacetimedb::reducer] +fn try_update_pk_try_update_unique_conflict( + ctx: &ReducerContext, + id: u32, + unique_value: u32, + data: i32, +) -> anyhow::Result<()> { + ctx.db + .pk_try_update_unique_conflict() + .id() + .try_update(PkTryUpdateUniqueConflict { id, unique_value, data }) + .map_err(|err| anyhow::anyhow!(err.to_string()))?; + Ok(()) +} + // Tables mapping a primary key to a boring i32 payload. // This allows us to test update and delete events. define_tables! { diff --git a/sdks/rust/tests/test-client/src/module_bindings/insert_pk_try_update_unique_conflict_reducer.rs b/sdks/rust/tests/test-client/src/module_bindings/insert_pk_try_update_unique_conflict_reducer.rs new file mode 100644 index 00000000000..2a4af85c6d0 --- /dev/null +++ b/sdks/rust/tests/test-client/src/module_bindings/insert_pk_try_update_unique_conflict_reducer.rs @@ -0,0 +1,76 @@ +// 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 InsertPkTryUpdateUniqueConflictArgs { + pub id: u32, + pub unique_value: u32, + pub data: i32, +} + +impl From for super::Reducer { + fn from(args: InsertPkTryUpdateUniqueConflictArgs) -> Self { + Self::InsertPkTryUpdateUniqueConflict { + id: args.id, + unique_value: args.unique_value, + data: args.data, + } + } +} + +impl __sdk::InModule for InsertPkTryUpdateUniqueConflictArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `insert_pk_try_update_unique_conflict`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait insert_pk_try_update_unique_conflict { + /// Request that the remote module invoke the reducer `insert_pk_try_update_unique_conflict` 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 [`insert_pk_try_update_unique_conflict:insert_pk_try_update_unique_conflict_then`] to run a callback after the reducer completes. + fn insert_pk_try_update_unique_conflict(&self, id: u32, unique_value: u32, data: i32) -> __sdk::Result<()> { + self.insert_pk_try_update_unique_conflict_then(id, unique_value, data, |_, _| {}) + } + + /// Request that the remote module invoke the reducer `insert_pk_try_update_unique_conflict` 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 insert_pk_try_update_unique_conflict_then( + &self, + id: u32, + unique_value: u32, + data: i32, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl insert_pk_try_update_unique_conflict for super::RemoteReducers { + fn insert_pk_try_update_unique_conflict_then( + &self, + id: u32, + unique_value: u32, + data: i32, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp + .invoke_reducer_with_callback(InsertPkTryUpdateUniqueConflictArgs { id, unique_value, data }, callback) + } +} diff --git a/sdks/rust/tests/test-client/src/module_bindings/mod.rs b/sdks/rust/tests/test-client/src/module_bindings/mod.rs index 50dc1e0cb5a..9cb3a1d3b95 100644 --- a/sdks/rust/tests/test-client/src/module_bindings/mod.rs +++ b/sdks/rust/tests/test-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 2.1.0 (commit 77575596072d271b763513ec1833d4a6e0627aef). +// This was generated using spacetimedb cli version 2.1.0 (commit 40beb1a6e1542fb7bf4c5247f7f59c3d15317fe1). #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; @@ -115,6 +115,7 @@ pub mod insert_pk_i_8_reducer; pub mod insert_pk_identity_reducer; pub mod insert_pk_simple_enum_reducer; pub mod insert_pk_string_reducer; +pub mod insert_pk_try_update_unique_conflict_reducer; pub mod insert_pk_u_128_reducer; pub mod insert_pk_u_16_reducer; pub mod insert_pk_u_256_reducer; @@ -267,6 +268,8 @@ pub mod pk_simple_enum_table; pub mod pk_simple_enum_type; pub mod pk_string_table; pub mod pk_string_type; +pub mod pk_try_update_unique_conflict_table; +pub mod pk_try_update_unique_conflict_type; pub mod pk_u_128_table; pub mod pk_u_128_type; pub mod pk_u_16_table; @@ -302,6 +305,7 @@ pub mod simple_enum_type; pub mod sorted_uuids_insert_reducer; pub mod table_holds_table_table; pub mod table_holds_table_type; +pub mod try_update_pk_try_update_unique_conflict_reducer; pub mod unique_bool_table; pub mod unique_bool_type; pub mod unique_connection_id_table; @@ -538,6 +542,7 @@ pub use insert_pk_i_8_reducer::insert_pk_i_8; pub use insert_pk_identity_reducer::insert_pk_identity; pub use insert_pk_simple_enum_reducer::insert_pk_simple_enum; pub use insert_pk_string_reducer::insert_pk_string; +pub use insert_pk_try_update_unique_conflict_reducer::insert_pk_try_update_unique_conflict; pub use insert_pk_u_128_reducer::insert_pk_u_128; pub use insert_pk_u_16_reducer::insert_pk_u_16; pub use insert_pk_u_256_reducer::insert_pk_u_256; @@ -690,6 +695,8 @@ pub use pk_simple_enum_table::*; pub use pk_simple_enum_type::PkSimpleEnum; pub use pk_string_table::*; pub use pk_string_type::PkString; +pub use pk_try_update_unique_conflict_table::*; +pub use pk_try_update_unique_conflict_type::PkTryUpdateUniqueConflict; pub use pk_u_128_table::*; pub use pk_u_128_type::PkU128; pub use pk_u_16_table::*; @@ -725,6 +732,7 @@ pub use simple_enum_type::SimpleEnum; pub use sorted_uuids_insert_reducer::sorted_uuids_insert; pub use table_holds_table_table::*; pub use table_holds_table_type::TableHoldsTable; +pub use try_update_pk_try_update_unique_conflict_reducer::try_update_pk_try_update_unique_conflict; pub use unique_bool_table::*; pub use unique_bool_type::UniqueBool; pub use unique_connection_id_table::*; @@ -1192,6 +1200,11 @@ pub enum Reducer { s: String, data: i32, }, + InsertPkTryUpdateUniqueConflict { + id: u32, + unique_value: u32, + data: i32, + }, InsertPkU128 { n: u128, data: i32, @@ -1409,6 +1422,11 @@ pub enum Reducer { arg: ScheduledTable, }, SortedUuidsInsert, + TryUpdatePkTryUpdateUniqueConflict { + id: u32, + unique_value: u32, + data: i32, + }, UpdateIndexedSimpleEnum { a: SimpleEnum, b: SimpleEnum, @@ -1663,6 +1681,7 @@ impl __sdk::Reducer for Reducer { Reducer::InsertPkIdentity { .. } => "insert_pk_identity", Reducer::InsertPkSimpleEnum { .. } => "insert_pk_simple_enum", Reducer::InsertPkString { .. } => "insert_pk_string", + Reducer::InsertPkTryUpdateUniqueConflict { .. } => "insert_pk_try_update_unique_conflict", Reducer::InsertPkU128 { .. } => "insert_pk_u_128", Reducer::InsertPkU16 { .. } => "insert_pk_u_16", Reducer::InsertPkU256 { .. } => "insert_pk_u_256", @@ -1727,6 +1746,7 @@ impl __sdk::Reducer for Reducer { Reducer::NoOpSucceeds => "no_op_succeeds", Reducer::SendScheduledMessage { .. } => "send_scheduled_message", Reducer::SortedUuidsInsert => "sorted_uuids_insert", + Reducer::TryUpdatePkTryUpdateUniqueConflict { .. } => "try_update_pk_try_update_unique_conflict", Reducer::UpdateIndexedSimpleEnum { .. } => "update_indexed_simple_enum", Reducer::UpdatePkBool { .. } => "update_pk_bool", Reducer::UpdatePkConnectionId { .. } => "update_pk_connection_id", @@ -2178,6 +2198,13 @@ impl __sdk::Reducer for Reducer { data: data.clone(), }) } + Reducer::InsertPkTryUpdateUniqueConflict { id, unique_value, data } => __sats::bsatn::to_vec( + &insert_pk_try_update_unique_conflict_reducer::InsertPkTryUpdateUniqueConflictArgs { + id: id.clone(), + unique_value: unique_value.clone(), + data: data.clone(), + }, + ), Reducer::InsertPkU128 { n, data } => __sats::bsatn::to_vec(&insert_pk_u_128_reducer::InsertPkU128Args { n: n.clone(), data: data.clone(), @@ -2449,6 +2476,13 @@ impl __sdk::Reducer for Reducer { __sats::bsatn::to_vec(&send_scheduled_message_reducer::SendScheduledMessageArgs { arg: arg.clone() }) } Reducer::SortedUuidsInsert => __sats::bsatn::to_vec(&sorted_uuids_insert_reducer::SortedUuidsInsertArgs {}), + Reducer::TryUpdatePkTryUpdateUniqueConflict { id, unique_value, data } => __sats::bsatn::to_vec( + &try_update_pk_try_update_unique_conflict_reducer::TryUpdatePkTryUpdateUniqueConflictArgs { + id: id.clone(), + unique_value: unique_value.clone(), + data: data.clone(), + }, + ), Reducer::UpdateIndexedSimpleEnum { a, b } => { __sats::bsatn::to_vec(&update_indexed_simple_enum_reducer::UpdateIndexedSimpleEnumArgs { a: a.clone(), @@ -2701,6 +2735,7 @@ pub struct DbUpdate { pk_identity: __sdk::TableUpdate, pk_simple_enum: __sdk::TableUpdate, pk_string: __sdk::TableUpdate, + pk_try_update_unique_conflict: __sdk::TableUpdate, pk_u_128: __sdk::TableUpdate, pk_u_16: __sdk::TableUpdate, pk_u_256: __sdk::TableUpdate, @@ -2914,6 +2949,9 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "pk_string" => db_update .pk_string .append(pk_string_table::parse_table_update(table_update)?), + "pk_try_update_unique_conflict" => db_update + .pk_try_update_unique_conflict + .append(pk_try_update_unique_conflict_table::parse_table_update(table_update)?), "pk_u_128" => db_update .pk_u_128 .append(pk_u_128_table::parse_table_update(table_update)?), @@ -3191,6 +3229,12 @@ impl __sdk::DbUpdate for DbUpdate { diff.pk_string = cache .apply_diff_to_table::("pk_string", &self.pk_string) .with_updates_by_pk(|row| &row.s); + diff.pk_try_update_unique_conflict = cache + .apply_diff_to_table::( + "pk_try_update_unique_conflict", + &self.pk_try_update_unique_conflict, + ) + .with_updates_by_pk(|row| &row.id); diff.pk_u_128 = cache .apply_diff_to_table::("pk_u_128", &self.pk_u_128) .with_updates_by_pk(|row| &row.n); @@ -3441,6 +3485,9 @@ impl __sdk::DbUpdate for DbUpdate { "pk_string" => db_update .pk_string .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "pk_try_update_unique_conflict" => db_update + .pk_try_update_unique_conflict + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "pk_u_128" => db_update .pk_u_128 .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -3779,6 +3826,9 @@ impl __sdk::DbUpdate for DbUpdate { "pk_string" => db_update .pk_string .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "pk_try_update_unique_conflict" => db_update + .pk_try_update_unique_conflict + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "pk_u_128" => db_update .pk_u_128 .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -4021,6 +4071,7 @@ pub struct AppliedDiff<'r> { pk_identity: __sdk::TableAppliedDiff<'r, PkIdentity>, pk_simple_enum: __sdk::TableAppliedDiff<'r, PkSimpleEnum>, pk_string: __sdk::TableAppliedDiff<'r, PkString>, + pk_try_update_unique_conflict: __sdk::TableAppliedDiff<'r, PkTryUpdateUniqueConflict>, pk_u_128: __sdk::TableAppliedDiff<'r, PkU128>, pk_u_16: __sdk::TableAppliedDiff<'r, PkU16>, pk_u_256: __sdk::TableAppliedDiff<'r, PkU256>, @@ -4163,6 +4214,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { callbacks.invoke_table_row_callbacks::("pk_identity", &self.pk_identity, event); callbacks.invoke_table_row_callbacks::("pk_simple_enum", &self.pk_simple_enum, event); callbacks.invoke_table_row_callbacks::("pk_string", &self.pk_string, event); + callbacks.invoke_table_row_callbacks::( + "pk_try_update_unique_conflict", + &self.pk_try_update_unique_conflict, + event, + ); callbacks.invoke_table_row_callbacks::("pk_u_128", &self.pk_u_128, event); callbacks.invoke_table_row_callbacks::("pk_u_16", &self.pk_u_16, event); callbacks.invoke_table_row_callbacks::("pk_u_256", &self.pk_u_256, event); @@ -4961,6 +5017,7 @@ impl __sdk::SpacetimeModule for RemoteModule { pk_identity_table::register_table(client_cache); pk_simple_enum_table::register_table(client_cache); pk_string_table::register_table(client_cache); + pk_try_update_unique_conflict_table::register_table(client_cache); pk_u_128_table::register_table(client_cache); pk_u_16_table::register_table(client_cache); pk_u_256_table::register_table(client_cache); @@ -5072,6 +5129,7 @@ impl __sdk::SpacetimeModule for RemoteModule { "pk_identity", "pk_simple_enum", "pk_string", + "pk_try_update_unique_conflict", "pk_u_128", "pk_u_16", "pk_u_256", diff --git a/sdks/rust/tests/test-client/src/module_bindings/pk_try_update_unique_conflict_table.rs b/sdks/rust/tests/test-client/src/module_bindings/pk_try_update_unique_conflict_table.rs new file mode 100644 index 00000000000..39c04391d33 --- /dev/null +++ b/sdks/rust/tests/test-client/src/module_bindings/pk_try_update_unique_conflict_table.rs @@ -0,0 +1,192 @@ +// 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::pk_try_update_unique_conflict_type::PkTryUpdateUniqueConflict; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `pk_try_update_unique_conflict`. +/// +/// Obtain a handle from the [`PkTryUpdateUniqueConflictTableAccess::pk_try_update_unique_conflict`] method on [`super::RemoteTables`], +/// like `ctx.db.pk_try_update_unique_conflict()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.pk_try_update_unique_conflict().on_insert(...)`. +pub struct PkTryUpdateUniqueConflictTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `pk_try_update_unique_conflict`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait PkTryUpdateUniqueConflictTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`PkTryUpdateUniqueConflictTableHandle`], which mediates access to the table `pk_try_update_unique_conflict`. + fn pk_try_update_unique_conflict(&self) -> PkTryUpdateUniqueConflictTableHandle<'_>; +} + +impl PkTryUpdateUniqueConflictTableAccess for super::RemoteTables { + fn pk_try_update_unique_conflict(&self) -> PkTryUpdateUniqueConflictTableHandle<'_> { + PkTryUpdateUniqueConflictTableHandle { + imp: self + .imp + .get_table::("pk_try_update_unique_conflict"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct PkTryUpdateUniqueConflictInsertCallbackId(__sdk::CallbackId); +pub struct PkTryUpdateUniqueConflictDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for PkTryUpdateUniqueConflictTableHandle<'ctx> { + type Row = PkTryUpdateUniqueConflict; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = PkTryUpdateUniqueConflictInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PkTryUpdateUniqueConflictInsertCallbackId { + PkTryUpdateUniqueConflictInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: PkTryUpdateUniqueConflictInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = PkTryUpdateUniqueConflictDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PkTryUpdateUniqueConflictDeleteCallbackId { + PkTryUpdateUniqueConflictDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: PkTryUpdateUniqueConflictDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct PkTryUpdateUniqueConflictUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for PkTryUpdateUniqueConflictTableHandle<'ctx> { + type UpdateCallbackId = PkTryUpdateUniqueConflictUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> PkTryUpdateUniqueConflictUpdateCallbackId { + PkTryUpdateUniqueConflictUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: PkTryUpdateUniqueConflictUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `id` unique index on the table `pk_try_update_unique_conflict`, +/// which allows point queries on the field of the same name +/// via the [`PkTryUpdateUniqueConflictIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.pk_try_update_unique_conflict().id().find(...)`. +pub struct PkTryUpdateUniqueConflictIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> PkTryUpdateUniqueConflictTableHandle<'ctx> { + /// Get a handle on the `id` unique index on the table `pk_try_update_unique_conflict`. + pub fn id(&self) -> PkTryUpdateUniqueConflictIdUnique<'ctx> { + PkTryUpdateUniqueConflictIdUnique { + imp: self.imp.get_unique_constraint::("id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> PkTryUpdateUniqueConflictIdUnique<'ctx> { + /// Find the subscribed row whose `id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &u32) -> Option { + self.imp.find(col_val) + } +} + +/// Access to the `unique_value` unique index on the table `pk_try_update_unique_conflict`, +/// which allows point queries on the field of the same name +/// via the [`PkTryUpdateUniqueConflictUniqueValueUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.pk_try_update_unique_conflict().unique_value().find(...)`. +pub struct PkTryUpdateUniqueConflictUniqueValueUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> PkTryUpdateUniqueConflictTableHandle<'ctx> { + /// Get a handle on the `unique_value` unique index on the table `pk_try_update_unique_conflict`. + pub fn unique_value(&self) -> PkTryUpdateUniqueConflictUniqueValueUnique<'ctx> { + PkTryUpdateUniqueConflictUniqueValueUnique { + imp: self.imp.get_unique_constraint::("unique_value"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> PkTryUpdateUniqueConflictUniqueValueUnique<'ctx> { + /// Find the subscribed row whose `unique_value` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &u32) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("pk_try_update_unique_conflict"); + _table.add_unique_constraint::("id", |row| &row.id); + _table.add_unique_constraint::("unique_value", |row| &row.unique_value); +} + +#[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 `PkTryUpdateUniqueConflict`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait pk_try_update_unique_conflictQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `PkTryUpdateUniqueConflict`. + fn pk_try_update_unique_conflict(&self) -> __sdk::__query_builder::Table; +} + +impl pk_try_update_unique_conflictQueryTableAccess for __sdk::QueryTableAccessor { + fn pk_try_update_unique_conflict(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("pk_try_update_unique_conflict") + } +} diff --git a/sdks/rust/tests/test-client/src/module_bindings/pk_try_update_unique_conflict_type.rs b/sdks/rust/tests/test-client/src/module_bindings/pk_try_update_unique_conflict_type.rs new file mode 100644 index 00000000000..420139ee744 --- /dev/null +++ b/sdks/rust/tests/test-client/src/module_bindings/pk_try_update_unique_conflict_type.rs @@ -0,0 +1,57 @@ +// 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 PkTryUpdateUniqueConflict { + pub id: u32, + pub unique_value: u32, + pub data: i32, +} + +impl __sdk::InModule for PkTryUpdateUniqueConflict { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `PkTryUpdateUniqueConflict`. +/// +/// Provides typed access to columns for query building. +pub struct PkTryUpdateUniqueConflictCols { + pub id: __sdk::__query_builder::Col, + pub unique_value: __sdk::__query_builder::Col, + pub data: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for PkTryUpdateUniqueConflict { + type Cols = PkTryUpdateUniqueConflictCols; + fn cols(table_name: &'static str) -> Self::Cols { + PkTryUpdateUniqueConflictCols { + id: __sdk::__query_builder::Col::new(table_name, "id"), + unique_value: __sdk::__query_builder::Col::new(table_name, "unique_value"), + data: __sdk::__query_builder::Col::new(table_name, "data"), + } + } +} + +/// Indexed column accessor struct for the table `PkTryUpdateUniqueConflict`. +/// +/// Provides typed access to indexed columns for query building. +pub struct PkTryUpdateUniqueConflictIxCols { + pub id: __sdk::__query_builder::IxCol, + pub unique_value: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for PkTryUpdateUniqueConflict { + type IxCols = PkTryUpdateUniqueConflictIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + PkTryUpdateUniqueConflictIxCols { + id: __sdk::__query_builder::IxCol::new(table_name, "id"), + unique_value: __sdk::__query_builder::IxCol::new(table_name, "unique_value"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for PkTryUpdateUniqueConflict {} diff --git a/sdks/rust/tests/test-client/src/module_bindings/try_update_pk_try_update_unique_conflict_reducer.rs b/sdks/rust/tests/test-client/src/module_bindings/try_update_pk_try_update_unique_conflict_reducer.rs new file mode 100644 index 00000000000..0eb5ae1544a --- /dev/null +++ b/sdks/rust/tests/test-client/src/module_bindings/try_update_pk_try_update_unique_conflict_reducer.rs @@ -0,0 +1,78 @@ +// 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 TryUpdatePkTryUpdateUniqueConflictArgs { + pub id: u32, + pub unique_value: u32, + pub data: i32, +} + +impl From for super::Reducer { + fn from(args: TryUpdatePkTryUpdateUniqueConflictArgs) -> Self { + Self::TryUpdatePkTryUpdateUniqueConflict { + id: args.id, + unique_value: args.unique_value, + data: args.data, + } + } +} + +impl __sdk::InModule for TryUpdatePkTryUpdateUniqueConflictArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `try_update_pk_try_update_unique_conflict`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait try_update_pk_try_update_unique_conflict { + /// Request that the remote module invoke the reducer `try_update_pk_try_update_unique_conflict` 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 [`try_update_pk_try_update_unique_conflict:try_update_pk_try_update_unique_conflict_then`] to run a callback after the reducer completes. + fn try_update_pk_try_update_unique_conflict(&self, id: u32, unique_value: u32, data: i32) -> __sdk::Result<()> { + self.try_update_pk_try_update_unique_conflict_then(id, unique_value, data, |_, _| {}) + } + + /// Request that the remote module invoke the reducer `try_update_pk_try_update_unique_conflict` 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 try_update_pk_try_update_unique_conflict_then( + &self, + id: u32, + unique_value: u32, + data: i32, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl try_update_pk_try_update_unique_conflict for super::RemoteReducers { + fn try_update_pk_try_update_unique_conflict_then( + &self, + id: u32, + unique_value: u32, + data: i32, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp.invoke_reducer_with_callback( + TryUpdatePkTryUpdateUniqueConflictArgs { id, unique_value, data }, + callback, + ) + } +} diff --git a/sdks/rust/tests/test-client/src/test_handlers.rs b/sdks/rust/tests/test-client/src/test_handlers.rs index d83ee46cf4d..4e8dca8178e 100644 --- a/sdks/rust/tests/test-client/src/test_handlers.rs +++ b/sdks/rust/tests/test-client/src/test_handlers.rs @@ -75,6 +75,7 @@ pub async fn dispatch(test: &str, db_name: &str) { "on-reducer" => exec_on_reducer(db_name).await, "fail-reducer" => exec_fail_reducer(db_name).await, + "try-update-unique-conflict" => exec_try_update_unique_conflict(db_name).await, "insert-vec" => exec_insert_vec(db_name).await, "insert-option-some" => exec_insert_option_some(db_name).await, @@ -340,6 +341,7 @@ const SUBSCRIBE_ALL: &[&str] = &[ "SELECT * FROM unique_identity;", "SELECT * FROM unique_connection_id;", "SELECT * FROM unique_uuid;", + "SELECT * FROM pk_try_update_unique_conflict;", "SELECT * FROM pk_u_8;", "SELECT * FROM pk_u_16;", "SELECT * FROM pk_u_32;", @@ -1142,6 +1144,174 @@ async fn exec_fail_reducer(db_name: &str) { test_counter.wait_for_all().await; } +/// This tests that `UniqueColumn::try_update()` lets reducers handle a unique +/// constraint failure on a non-primary-key unique column without panicking. +async fn exec_try_update_unique_conflict(db_name: &str) { + let test_counter = TestCounter::new(); + let sub_applied_nothing_result = test_counter.add_test("on_subscription_applied_nothing"); + let insert_first_result = test_counter.add_test("insert-first"); + let insert_second_result = test_counter.add_test("insert-second"); + let try_update_conflict_result = test_counter.add_test("try-update-conflict"); + + let connection = connect(db_name, &test_counter).await; + + let first_id = 1; + let first_unique = 10; + let first_data = 100; + let second_id = 2; + let second_unique = 20; + let second_data = 200; + let failed_update_data = 999; + + subscribe_all_then(&connection, move |ctx| { + sub_applied_nothing_result(assert_all_tables_empty(ctx)); + + ctx.reducers + .insert_pk_try_update_unique_conflict_then(first_id, first_unique, first_data, move |ctx, status| { + let run_checks = || { + match &status { + Ok(Ok(())) => {} + other => anyhow::bail!("Expected success but got {other:?}"), + } + if !matches!(ctx.event.status, Status::Committed) { + anyhow::bail!("Unexpected status. Expected Committed but found {:?}", ctx.event.status); + } + assert_eq_or_bail!( + Reducer::InsertPkTryUpdateUniqueConflict { + id: first_id, + unique_value: first_unique, + data: first_data, + }, + ctx.event.reducer + ); + + let row = ctx + .db + .pk_try_update_unique_conflict() + .id() + .find(&first_id) + .ok_or_else(|| anyhow::anyhow!("expected first row to exist"))?; + assert_eq_or_bail!(first_unique, row.unique_value); + assert_eq_or_bail!(first_data, row.data); + Ok(()) + }; + + insert_first_result(run_checks()); + + ctx.reducers + .insert_pk_try_update_unique_conflict_then( + second_id, + second_unique, + second_data, + move |ctx, status| { + let run_checks = || { + match &status { + Ok(Ok(())) => {} + other => anyhow::bail!("Expected success but got {other:?}"), + } + if !matches!(ctx.event.status, Status::Committed) { + anyhow::bail!("Unexpected status. Expected Committed but found {:?}", ctx.event.status); + } + assert_eq_or_bail!( + Reducer::InsertPkTryUpdateUniqueConflict { + id: second_id, + unique_value: second_unique, + data: second_data, + }, + ctx.event.reducer + ); + + let first_row = ctx + .db + .pk_try_update_unique_conflict() + .id() + .find(&first_id) + .ok_or_else(|| anyhow::anyhow!("expected first row to exist"))?; + let second_row = ctx + .db + .pk_try_update_unique_conflict() + .id() + .find(&second_id) + .ok_or_else(|| anyhow::anyhow!("expected second row to exist"))?; + assert_eq_or_bail!(first_unique, first_row.unique_value); + assert_eq_or_bail!(first_data, first_row.data); + assert_eq_or_bail!(second_unique, second_row.unique_value); + assert_eq_or_bail!(second_data, second_row.data); + Ok(()) + }; + + insert_second_result(run_checks()); + + ctx.reducers + .try_update_pk_try_update_unique_conflict_then( + first_id, + second_unique, + failed_update_data, + move |ctx, status| { + let run_checks = || { + let err = match status { + Ok(Err(msg)) => msg, + Ok(Ok(())) => { + anyhow::bail!( + "expected unique conflict from try_update reducer, but it succeeded" + ) + } + Err(internal_error) => { + anyhow::bail!( + "expected handled reducer error, but reducer panicked: {internal_error:?}" + ) + } + }; + + anyhow::ensure!( + !err.is_empty(), + "expected a surfaced reducer error message for the unique conflict" + ); + anyhow::ensure!( + !matches!(ctx.event.status, Status::Committed), + "expected failed try_update reducer not to commit" + ); + assert_eq_or_bail!( + Reducer::TryUpdatePkTryUpdateUniqueConflict { + id: first_id, + unique_value: second_unique, + data: failed_update_data, + }, + ctx.event.reducer + ); + + let table = ctx.db.pk_try_update_unique_conflict(); + assert_eq_or_bail!(2, table.count()); + + let first_row = table + .id() + .find(&first_id) + .ok_or_else(|| anyhow::anyhow!("expected first row to still exist"))?; + let second_row = table + .id() + .find(&second_id) + .ok_or_else(|| anyhow::anyhow!("expected second row to still exist"))?; + assert_eq_or_bail!(first_unique, first_row.unique_value); + assert_eq_or_bail!(first_data, first_row.data); + assert_eq_or_bail!(second_unique, second_row.unique_value); + assert_eq_or_bail!(second_data, second_row.data); + Ok(()) + }; + + try_update_conflict_result(run_checks()); + }, + ) + .unwrap(); + }, + ) + .unwrap(); + }) + .unwrap(); + }); + + test_counter.wait_for_all().await; +} + /// This tests that we can serialize and deserialize `Vec` in various contexts. async fn exec_insert_vec(db_name: &str) { let test_counter = TestCounter::new(); diff --git a/sdks/rust/tests/test.rs b/sdks/rust/tests/test.rs index 1008d2c54f4..2f71b7b9faf 100644 --- a/sdks/rust/tests/test.rs +++ b/sdks/rust/tests/test.rs @@ -222,6 +222,11 @@ macro_rules! declare_tests_with_suffix { make_test("fail-reducer").run(); } + #[test] + fn try_update_unique_conflict() { + make_test("try-update-unique-conflict").run(); + } + #[test] fn insert_vec() { make_test("insert-vec").run(); From 6c418a6321be1b392e6f88e29a0fe7e8713af0d9 Mon Sep 17 00:00:00 2001 From: clockwork-labs-bot Date: Tue, 28 Apr 2026 12:28:48 -0400 Subject: [PATCH 3/3] Add module-test try_update example --- modules/module-test/src/lib.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/modules/module-test/src/lib.rs b/modules/module-test/src/lib.rs index e8a7d35ee2c..34bba0b6f8d 100644 --- a/modules/module-test/src/lib.rs +++ b/modules/module-test/src/lib.rs @@ -355,6 +355,26 @@ pub fn add_player(ctx: &ReducerContext, name: String) -> Result<(), String> { Ok(()) } +#[spacetimedb::reducer] +pub fn try_update_player_name(ctx: &ReducerContext, old_name: String, new_name: String) -> Result<(), String> { + let player = ctx + .db + .player() + .name() + .find(&old_name) + .ok_or_else(|| format!("No Player row with name {old_name:?}"))?; + + ctx.db + .player() + .identity() + .try_update(Player { + name: new_name, + ..player + }) + .map(|_| ()) + .map_err(|err| err.to_string()) +} + #[spacetimedb::reducer] pub fn delete_player(ctx: &ReducerContext, id: u64) -> Result<(), String> { if ctx.db.test_e().id().delete(id) {