diff --git a/Cargo.lock b/Cargo.lock index 2920966183..2904e09927 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3035,6 +3035,14 @@ dependencies = [ "memchr", ] +[[package]] +name = "quickstart-chat-module" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + [[package]] name = "quote" version = "1.0.29" @@ -4125,6 +4133,7 @@ dependencies = [ "base64 0.21.2", "futures", "futures-channel", + "hex", "home", "http", "im", diff --git a/Cargo.toml b/Cargo.toml index fdc80c4cff..df77524c71 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ members = [ "modules/rust-wasm-test", "modules/benchmarks", "modules/spacetimedb-quickstart", + "modules/quickstart-chat", ] default-members = ["crates/cli"] diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml index 3e96746dde..132d2ee148 100644 --- a/crates/sdk/Cargo.toml +++ b/crates/sdk/Cargo.toml @@ -24,3 +24,7 @@ im = "15.1" base64 = "0.21" home = "0.5" lazy_static = "1.4" + +[dev-dependencies] +# for quickstart-chat example +hex = "0.4" diff --git a/crates/sdk/examples/quickstart-chat/README.md b/crates/sdk/examples/quickstart-chat/README.md new file mode 100644 index 0000000000..5e5eb0953f --- /dev/null +++ b/crates/sdk/examples/quickstart-chat/README.md @@ -0,0 +1,5 @@ +# `quickstart-chat` + +A simple command-line client for [the `quickstart-chat` module](/modules/quickstart-chat). + +This client is described in-depth by [the SpacetimeDB Rust client quickstart](https://spacetimedb.com/docs/client-languages/rust/rust-sdk-quickstart-guide). diff --git a/crates/sdk/examples/quickstart-chat/main.rs b/crates/sdk/examples/quickstart-chat/main.rs new file mode 100644 index 0000000000..0dda10a04b --- /dev/null +++ b/crates/sdk/examples/quickstart-chat/main.rs @@ -0,0 +1,182 @@ +mod module_bindings; +use module_bindings::*; + +use spacetimedb_sdk::{ + identity::{load_credentials, once_on_connect, save_credentials, Credentials, Identity}, + on_subscription_applied, + reducer::Status, + subscribe, + table::{TableType, TableWithPrimaryKey}, +}; + +// # Our main function + +fn main() { + register_callbacks(); + connect_to_db(); + subscribe_to_tables(); + user_input_loop(); +} + +// # Register callbacks + +/// Register all the callbacks our app will use to respond to database events. +fn register_callbacks() { + // When we receive our `Credentials`, save them to a file. + once_on_connect(on_connected); + + // When a new user joins, print a notification. + User::on_insert(on_user_inserted); + + // When a user's status changes, print a notification. + User::on_update(on_user_updated); + + // When a new message is received, print it. + Message::on_insert(on_message_inserted); + + // When we receive the message backlog, print it in timestamp order. + on_subscription_applied(on_sub_applied); + + // When we fail to set our name, print a warning. + on_set_name(on_name_set); + + // When we fail to send a message, print a warning. + on_send_message(on_message_sent); +} + +// ## Save credentials to a file + +/// Our `on_connect` callback: save our credentials to a file. +fn on_connected(creds: &Credentials) { + if let Err(e) = save_credentials(CREDS_DIR, creds) { + eprintln!("Failed to save credentials: {:?}", e); + } +} + +const CREDS_DIR: &str = ".spacetime_chat"; + +// ## Notify about new users + +/// Our `User::on_insert` callback: if the user is online, print a notification. +fn on_user_inserted(user: &User, _: Option<&ReducerEvent>) { + if user.online { + println!("User {} connected.", user_name_or_identity(user)); + } +} + +fn user_name_or_identity(user: &User) -> String { + user.name + .clone() + .unwrap_or_else(|| identity_leading_hex(&user.identity)) +} + +fn identity_leading_hex(id: &Identity) -> String { + hex::encode(&id.bytes()[0..8]) +} + +// ## Notify about updated users + +/// Our `User::on_update` callback: +/// print a notification about name and status changes. +fn on_user_updated(old: &User, new: &User, _: Option<&ReducerEvent>) { + if old.name != new.name { + println!( + "User {} renamed to {}.", + user_name_or_identity(old), + user_name_or_identity(new) + ); + } + if old.online && !new.online { + println!("User {} disconnected.", user_name_or_identity(new)); + } + if !old.online && new.online { + println!("User {} connected.", user_name_or_identity(new)); + } +} + +// ## Display incoming messages + +/// Our `Message::on_insert` callback: print new messages. +fn on_message_inserted(message: &Message, reducer_event: Option<&ReducerEvent>) { + if reducer_event.is_some() { + print_message(message); + } +} + +fn print_message(message: &Message) { + let sender = User::filter_by_identity(message.sender.clone()) + .map(|u| user_name_or_identity(&u)) + .unwrap_or_else(|| "unknown".to_string()); + println!("{}: {}", sender, message.text); +} + +// ## Print message backlog + +/// Our `on_subscription_applied` callback: +/// sort all past messages and print them in timestamp order. +fn on_sub_applied() { + let mut messages = Message::iter().collect::>(); + messages.sort_by_key(|m| m.sent); + for message in messages { + print_message(&message); + } +} + +// ## Warn if set_name failed + +/// Our `on_set_name` callback: print a warning if the reducer failed. +fn on_name_set(_sender: &Identity, status: &Status, name: &String) { + if let Status::Failed(err) = status { + eprintln!("Failed to change name to {:?}: {}", name, err); + } +} + +// ## Warn if a message was rejected + +/// Our `on_send_message` callback: print a warning if the reducer failed. +fn on_message_sent(_sender: &Identity, status: &Status, text: &String) { + if let Status::Failed(err) = status { + eprintln!("Failed to send message {:?}: {}", text, err); + } +} + +// # Connect to the database + +/// The URL of the SpacetimeDB instance hosting our chat module. +const HOST: &str = "http://localhost:3000"; + +/// The module name we chose when we published our module. +const DB_NAME: &str = "chat"; + +/// Load credentials from a file and connect to the database. +fn connect_to_db() { + connect( + HOST, + DB_NAME, + load_credentials(CREDS_DIR).expect("Error reading stored credentials"), + ) + .expect("Failed to connect"); +} + +// # Subscribe to queries + +/// Register subscriptions for all rows of both tables. +fn subscribe_to_tables() { + subscribe(&["SELECT * FROM User;", "SELECT * FROM Message;"]).unwrap(); +} + +// # Handle user input + +/// Read each line of standard input, and either set our name or send a message as appropriate. +fn user_input_loop() { + for line in std::io::stdin().lines() { + let Ok(line) = line else { + panic!("Failed to read from stdin."); + }; + if let Some(name) = line.strip_prefix("/name ") { + set_name(name.to_string()); + } else { + send_message(line); + } + } +} diff --git a/crates/sdk/examples/quickstart-chat/module_bindings/message.rs b/crates/sdk/examples/quickstart-chat/module_bindings/message.rs new file mode 100644 index 0000000000..e44493675b --- /dev/null +++ b/crates/sdk/examples/quickstart-chat/module_bindings/message.rs @@ -0,0 +1,39 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN RUST INSTEAD. + +#[allow(unused)] +use spacetimedb_sdk::{ + anyhow::{anyhow, Result}, + identity::Identity, + reducer::{Reducer, ReducerCallbackId, Status}, + sats::{de::Deserialize, ser::Serialize}, + spacetimedb_lib, + table::{TableIter, TableType, TableWithPrimaryKey}, +}; + +#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)] +pub struct Message { + pub sender: Identity, + pub sent: u64, + pub text: String, +} + +impl TableType for Message { + const TABLE_NAME: &'static str = "Message"; + type ReducerEvent = super::ReducerEvent; +} + +impl Message { + #[allow(unused)] + pub fn filter_by_sender(sender: Identity) -> TableIter { + Self::filter(|row| row.sender == sender) + } + #[allow(unused)] + pub fn filter_by_sent(sent: u64) -> TableIter { + Self::filter(|row| row.sent == sent) + } + #[allow(unused)] + pub fn filter_by_text(text: String) -> TableIter { + Self::filter(|row| row.text == text) + } +} diff --git a/crates/sdk/examples/quickstart-chat/module_bindings/mod.rs b/crates/sdk/examples/quickstart-chat/module_bindings/mod.rs new file mode 100644 index 0000000000..02b769a1df --- /dev/null +++ b/crates/sdk/examples/quickstart-chat/module_bindings/mod.rs @@ -0,0 +1,123 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN RUST INSTEAD. + +use spacetimedb_sdk::callbacks::{DbCallbacks, ReducerCallbacks}; +use spacetimedb_sdk::client_api_messages::{Event, TableUpdate}; +use spacetimedb_sdk::client_cache::{ClientCache, RowCallbackReminders}; +use spacetimedb_sdk::global_connection::with_connection_mut; +use spacetimedb_sdk::identity::Credentials; +use spacetimedb_sdk::reducer::AnyReducerEvent; +#[allow(unused)] +use spacetimedb_sdk::{ + anyhow::{anyhow, Result}, + identity::Identity, + reducer::{Reducer, ReducerCallbackId, Status}, + sats::{de::Deserialize, ser::Serialize}, + spacetimedb_lib, + table::{TableIter, TableType, TableWithPrimaryKey}, +}; +use std::sync::Arc; + +pub mod message; +pub mod send_message_reducer; +pub mod set_name_reducer; +pub mod user; + +pub use message::*; +pub use send_message_reducer::*; +pub use set_name_reducer::*; +pub use user::*; + +#[allow(unused)] +#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)] +pub enum ReducerEvent { + SendMessage(send_message_reducer::SendMessageArgs), + SetName(set_name_reducer::SetNameArgs), +} + +#[allow(unused)] +fn handle_table_update( + table_update: TableUpdate, + client_cache: &mut ClientCache, + callbacks: &mut RowCallbackReminders, +) { + let table_name = &table_update.table_name[..]; + match table_name { + "Message" => client_cache.handle_table_update_no_primary_key::(callbacks, table_update), + "User" => client_cache.handle_table_update_with_primary_key::(callbacks, table_update), + _ => spacetimedb_sdk::log::error!("TableRowOperation on unknown table {:?}", table_name), + } +} + +#[allow(unused)] +fn invoke_row_callbacks( + reminders: &mut RowCallbackReminders, + worker: &mut DbCallbacks, + reducer_event: Option>, + state: &Arc, +) { + reminders.invoke_callbacks::(worker, &reducer_event, state); + reminders.invoke_callbacks::(worker, &reducer_event, state); +} + +#[allow(unused)] +fn handle_resubscribe(new_subs: TableUpdate, client_cache: &mut ClientCache, callbacks: &mut RowCallbackReminders) { + let table_name = &new_subs.table_name[..]; + match table_name { + "Message" => client_cache.handle_resubscribe_for_type::(callbacks, new_subs), + "User" => client_cache.handle_resubscribe_for_type::(callbacks, new_subs), + _ => spacetimedb_sdk::log::error!("TableRowOperation on unknown table {:?}", table_name), + } +} + +#[allow(unused)] +fn handle_event( + event: Event, + reducer_callbacks: &mut ReducerCallbacks, + state: Arc, +) -> Option> { + let Some(function_call) = &event.function_call else { + spacetimedb_sdk::log::warn!("Received Event with None function_call"); return None; +}; + match &function_call.reducer[..] { + "send_message" => reducer_callbacks + .handle_event_of_type::( + event, + state, + ReducerEvent::SendMessage, + ), + "set_name" => reducer_callbacks.handle_event_of_type::( + event, + state, + ReducerEvent::SetName, + ), + unknown => { + spacetimedb_sdk::log::error!("Event on an unknown reducer: {:?}", unknown); + None + } + } +} + +/// Connect to a database named `db_name` accessible over the internet at the URI `spacetimedb_uri`. +/// +/// If `credentials` are supplied, they will be passed to the new connection to +/// identify and authenticate the user. Otherwise, a set of `Credentials` will be +/// generated by the server. +pub fn connect(spacetimedb_uri: IntoUri, db_name: &str, credentials: Option) -> Result<()> +where + IntoUri: TryInto, + >::Error: std::error::Error + Send + Sync + 'static, +{ + with_connection_mut(|connection| { + connection.connect( + spacetimedb_uri, + db_name, + credentials, + handle_table_update, + handle_resubscribe, + invoke_row_callbacks, + handle_event, + )?; + Ok(()) + }) +} diff --git a/crates/sdk/examples/quickstart-chat/module_bindings/send_message_reducer.rs b/crates/sdk/examples/quickstart-chat/module_bindings/send_message_reducer.rs new file mode 100644 index 0000000000..5cef95354c --- /dev/null +++ b/crates/sdk/examples/quickstart-chat/module_bindings/send_message_reducer.rs @@ -0,0 +1,51 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN RUST INSTEAD. + +#[allow(unused)] +use spacetimedb_sdk::{ + anyhow::{anyhow, Result}, + identity::Identity, + reducer::{Reducer, ReducerCallbackId, Status}, + sats::{de::Deserialize, ser::Serialize}, + spacetimedb_lib, + table::{TableIter, TableType, TableWithPrimaryKey}, +}; + +#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)] +pub struct SendMessageArgs { + pub text: String, +} + +impl Reducer for SendMessageArgs { + const REDUCER_NAME: &'static str = "send_message"; +} + +#[allow(unused)] +pub fn send_message(text: String) { + SendMessageArgs { text }.invoke(); +} + +#[allow(unused)] +pub fn on_send_message( + mut __callback: impl FnMut(&Identity, &Status, &String) + Send + 'static, +) -> ReducerCallbackId { + SendMessageArgs::on_reducer(move |__identity, __status, __args| { + let SendMessageArgs { text } = __args; + __callback(__identity, __status, text); + }) +} + +#[allow(unused)] +pub fn once_on_send_message( + __callback: impl FnOnce(&Identity, &Status, &String) + Send + 'static, +) -> ReducerCallbackId { + SendMessageArgs::once_on_reducer(move |__identity, __status, __args| { + let SendMessageArgs { text } = __args; + __callback(__identity, __status, text); + }) +} + +#[allow(unused)] +pub fn remove_on_send_message(id: ReducerCallbackId) { + SendMessageArgs::remove_on_reducer(id); +} diff --git a/crates/sdk/examples/quickstart-chat/module_bindings/set_name_reducer.rs b/crates/sdk/examples/quickstart-chat/module_bindings/set_name_reducer.rs new file mode 100644 index 0000000000..785defcbec --- /dev/null +++ b/crates/sdk/examples/quickstart-chat/module_bindings/set_name_reducer.rs @@ -0,0 +1,51 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN RUST INSTEAD. + +#[allow(unused)] +use spacetimedb_sdk::{ + anyhow::{anyhow, Result}, + identity::Identity, + reducer::{Reducer, ReducerCallbackId, Status}, + sats::{de::Deserialize, ser::Serialize}, + spacetimedb_lib, + table::{TableIter, TableType, TableWithPrimaryKey}, +}; + +#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)] +pub struct SetNameArgs { + pub name: String, +} + +impl Reducer for SetNameArgs { + const REDUCER_NAME: &'static str = "set_name"; +} + +#[allow(unused)] +pub fn set_name(name: String) { + SetNameArgs { name }.invoke(); +} + +#[allow(unused)] +pub fn on_set_name( + mut __callback: impl FnMut(&Identity, &Status, &String) + Send + 'static, +) -> ReducerCallbackId { + SetNameArgs::on_reducer(move |__identity, __status, __args| { + let SetNameArgs { name } = __args; + __callback(__identity, __status, name); + }) +} + +#[allow(unused)] +pub fn once_on_set_name( + __callback: impl FnOnce(&Identity, &Status, &String) + Send + 'static, +) -> ReducerCallbackId { + SetNameArgs::once_on_reducer(move |__identity, __status, __args| { + let SetNameArgs { name } = __args; + __callback(__identity, __status, name); + }) +} + +#[allow(unused)] +pub fn remove_on_set_name(id: ReducerCallbackId) { + SetNameArgs::remove_on_reducer(id); +} diff --git a/crates/sdk/examples/quickstart-chat/module_bindings/user.rs b/crates/sdk/examples/quickstart-chat/module_bindings/user.rs new file mode 100644 index 0000000000..7d472ca97e --- /dev/null +++ b/crates/sdk/examples/quickstart-chat/module_bindings/user.rs @@ -0,0 +1,46 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN RUST INSTEAD. + +#[allow(unused)] +use spacetimedb_sdk::{ + anyhow::{anyhow, Result}, + identity::Identity, + reducer::{Reducer, ReducerCallbackId, Status}, + sats::{de::Deserialize, ser::Serialize}, + spacetimedb_lib, + table::{TableIter, TableType, TableWithPrimaryKey}, +}; + +#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)] +pub struct User { + pub identity: Identity, + pub name: Option, + pub online: bool, +} + +impl TableType for User { + const TABLE_NAME: &'static str = "User"; + type ReducerEvent = super::ReducerEvent; +} + +impl TableWithPrimaryKey for User { + type PrimaryKey = Identity; + fn primary_key(&self) -> &Self::PrimaryKey { + &self.identity + } +} + +impl User { + #[allow(unused)] + pub fn filter_by_identity(identity: Identity) -> Option { + Self::find(|row| row.identity == identity) + } + #[allow(unused)] + pub fn filter_by_name(name: Option) -> TableIter { + Self::filter(|row| row.name == name) + } + #[allow(unused)] + pub fn filter_by_online(online: bool) -> TableIter { + Self::filter(|row| row.online == online) + } +} diff --git a/modules/quickstart-chat/.gitignore b/modules/quickstart-chat/.gitignore new file mode 100644 index 0000000000..31b13f058a --- /dev/null +++ b/modules/quickstart-chat/.gitignore @@ -0,0 +1,17 @@ +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +# Spacetime ignore +/.spacetime \ No newline at end of file diff --git a/modules/quickstart-chat/Cargo.toml b/modules/quickstart-chat/Cargo.toml new file mode 100644 index 0000000000..805f79c3f7 --- /dev/null +++ b/modules/quickstart-chat/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "quickstart-chat-module" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb = { path = "../../crates/bindings" } +log = "0.4" diff --git a/modules/quickstart-chat/README.md b/modules/quickstart-chat/README.md new file mode 100644 index 0000000000..2982d9fd20 --- /dev/null +++ b/modules/quickstart-chat/README.md @@ -0,0 +1,22 @@ +# `quickstart-chat` + +A SpacetimeDB module which defines a simple chat server. This module is explained in-depth by [the SpacetimeDB Rust module quickstart](https://spacetimedb.com/docs/server-languages/rust/rust-module-quickstart-guide). + +## Clients + +### Rust + +A Rust command-line client for this module is defined in [the Rust SDK's examples](/crates/sdk/examples/quickstart-chat), and described by [the SpacetimeDB Rust client quickstart](https://spacetimedb.com/docs/client-languages/rust/rust-sdk-quickstart-guide). + +### C# + +A C# command-line client for this module is defined in [the C# SDK's examples](https://github.com/clockworklabs/spacetimedb-csharp-sdk/tree/master/examples/quickstart/client), and described by [the SpacetimeDB C# client quickstart](https://spacetimedb.com/docs/client-languages/csharp-sdk/csharp-sdk-quickstart-guide). + +### Python + +A Python command-line client for this module is defined in [the Python SDK's examples](https://github.com/clockworklabs/spacetimedb-python-sdk/tree/master/examples/quickstart/client), and described by [the SpacetimeDB Python client quickstart](https://spacetimedb.com/docs/client-languages/python/python-sdk-quickstart-guide). + +### TypeScript + + +A web client for this module, built with TypeScript and React, is defined in [the TypeScript SDK's examples](https://github.com/spacetimedb-typescript-sdk/tree/master/examples/quickstart/client), and described by [the SpacetimeDB TypeScript client quickstart](https://spacetimedb.com/docs/client-languages/typescript/typescript-sdk-quickstart-guide). diff --git a/modules/quickstart-chat/src/lib.rs b/modules/quickstart-chat/src/lib.rs new file mode 100644 index 0000000000..564f8c64de --- /dev/null +++ b/modules/quickstart-chat/src/lib.rs @@ -0,0 +1,96 @@ +use spacetimedb::{spacetimedb, Identity, ReducerContext, Timestamp}; + +#[spacetimedb(table)] +pub struct User { + #[primarykey] + identity: Identity, + name: Option, + online: bool, +} + +#[spacetimedb(table)] +pub struct Message { + sender: Identity, + sent: Timestamp, + text: String, +} + +fn validate_name(name: String) -> Result { + if name.is_empty() { + Err("Names must not be empty".to_string()) + } else { + Ok(name) + } +} + +#[spacetimedb(reducer)] +pub fn set_name(ctx: ReducerContext, name: String) -> Result<(), String> { + let name = validate_name(name)?; + if let Some(user) = User::filter_by_identity(&ctx.sender) { + User::update_by_identity( + &ctx.sender, + User { + name: Some(name), + ..user + }, + ); + Ok(()) + } else { + Err("Cannot set name for unknown user".to_string()) + } +} + +fn validate_message(text: String) -> Result { + if text.is_empty() { + Err("Messages must not be empty".to_string()) + } else { + Ok(text) + } +} + +#[spacetimedb(reducer)] +pub fn send_message(ctx: ReducerContext, text: String) -> Result<(), String> { + // Things to consider: + // - Rate-limit messages per-user. + // - Reject messages from unnamed users. + let text = validate_message(text)?; + Message::insert(Message { + sender: ctx.sender, + text, + sent: ctx.timestamp, + }); + Ok(()) +} + +#[spacetimedb(init)] +// Called when the module is initially published +pub fn init() {} + +#[spacetimedb(connect)] +pub fn identity_connected(ctx: ReducerContext) { + if let Some(user) = User::filter_by_identity(&ctx.sender) { + // If this is a returning user, i.e. we already have a `User` with this `Identity`, + // set `online: true`, but leave `name` and `identity` unchanged. + User::update_by_identity(&ctx.sender, User { online: true, ..user }); + } else { + // If this is a new user, create a `User` row for the `Identity`, + // which is online, but hasn't set a name. + User::insert(User { + name: None, + identity: ctx.sender, + online: true, + }) + .unwrap(); + } +} + +#[spacetimedb(disconnect)] +pub fn identity_disconnected(ctx: ReducerContext) { + if let Some(user) = User::filter_by_identity(&ctx.sender) { + User::update_by_identity(&ctx.sender, User { online: false, ..user }); + } else { + // This branch should be unreachable, + // as it doesn't make sense for a client to disconnect without connecting first. + log::warn!("Disconnect event for unknown user with identity {:?}", ctx.sender); + } +}