diff --git a/docs/docs/00100-intro/00300-tutorials/00100-chat-app.md b/docs/docs/00100-intro/00300-tutorials/00100-chat-app.md index d1e6b2c3aae..8a3c0c4197c 100644 --- a/docs/docs/00100-intro/00300-tutorials/00100-chat-app.md +++ b/docs/docs/00100-intro/00300-tutorials/00100-chat-app.md @@ -41,6 +41,13 @@ SpacetimeDB runs your module inside the database host (not Node.js). There's no - By default, tables are **private**. The `#[table(name = table_name, public)]` macro makes a table public. **Public** tables are readable by all users but can still only be modified by your server module code. - A reducer is a function that traverses and updates the database. Each reducer call runs in its own transaction, and its updates to the database are only committed if the reducer returns successfully. Reducers may return a `Result<()>`, with an `Err` return aborting the transaction. + + + +- Each table is defined as a C++ struct with the `SPACETIMEDB_STRUCT` macro to register its fields, and the `SPACETIMEDB_TABLE` macro to create the table. An instance of the struct represents a row, and each field represents a column. +- By default, tables are **private**. Use `SPACETIMEDB_TABLE(StructName, table_name, Public)` to make a table public. **Public** tables are readable by all users but can still only be modified by your server module code. +- A reducer is a function defined with the `SPACETIMEDB_REDUCER` macro that traverses and updates the database. Each reducer call runs in its own transaction, and its updates to the database are only committed if the reducer returns successfully. Reducers may return `Err("message")` to abort the transaction. + @@ -85,6 +92,25 @@ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh If you're on Windows, go [here](https://learn.microsoft.com/en-us/windows/dev-environment/rust/setup). + + + +Next we need to [install Emscripten](https://emscripten.org/docs/getting_started/downloads.html) so that we can compile our C++ module to WebAssembly. + +Install the Emscripten SDK: + +```bash +git clone https://github.com/emscripten-core/emsdk.git +cd emsdk +./emsdk install latest +./emsdk activate latest +source ./emsdk_env.sh +``` + +On Windows, use `emsdk_env.bat` instead of `source ./emsdk_env.sh`. + +You'll also need CMake installed on your system. On macOS: `brew install cmake`. On Ubuntu/Debian: `sudo apt install cmake`. On Windows, download from [cmake.org](https://cmake.org/download/). + @@ -113,6 +139,13 @@ spacetime init --lang csharp quickstart-chat spacetime init --lang rust quickstart-chat ``` + + + +```bash +spacetime init --lang cpp quickstart-chat +``` + @@ -143,6 +176,16 @@ cd spacetimedb spacetime build ``` + + + +`spacetime init` generates a few files: + +1. `spacetimedb/src/lib.cpp` - your module code +2. `spacetimedb/CMakeLists.txt` - build configuration for Emscripten + +Clear out the example code in `src/lib.cpp` so we can write our chat module. + @@ -199,6 +242,27 @@ From `spacetimedb`, we import: - `Identity`, a unique identifier for each user. - `Timestamp`, a point in time. + + + +Open `spacetimedb/src/lib.cpp` and add the SpacetimeDB header: + +```cpp server +#include + +using namespace SpacetimeDb; +``` + +This gives us access to: + +- `SPACETIMEDB_STRUCT` macro to register struct fields. +- `SPACETIMEDB_TABLE` macro to define SpacetimeDB tables. +- `SPACETIMEDB_REDUCER` macro to define SpacetimeDB reducers. +- `FIELD_*` macros to define primary keys, indexes, and constraints. +- `ReducerContext` passed to each reducer. +- `Identity` for unique user identifiers. +- `Timestamp` for points in time. + @@ -282,6 +346,30 @@ pub struct Message { } ``` + + + +In `spacetimedb/src/lib.cpp`, define the `User` and `Message` structs with the `SPACETIMEDB_STRUCT` macro, then create tables with the `SPACETIMEDB_TABLE` macro: + +```cpp server +struct User { + Identity identity; + std::optional name; + bool online; +}; +SPACETIMEDB_STRUCT(User, identity, name, online); +SPACETIMEDB_TABLE(User, user, Public); +FIELD_PrimaryKey(user, identity); + +struct Message { + Identity sender; + Timestamp sent; + std::string text; +}; +SPACETIMEDB_STRUCT(Message, sender, sent, text); +SPACETIMEDB_TABLE(Message, message, Public); +``` + @@ -365,6 +453,38 @@ fn validate_name(name: String) -> Result { } ``` + + + +Add to `spacetimedb/src/lib.cpp`: + +```cpp server +Outcome validate_name(const std::string& name) { + if (name.empty()) { + return Err("Names must not be empty"); + } + return Ok(name); +} + +SPACETIMEDB_REDUCER(set_name, ReducerContext ctx, std::string name) { + auto validated = validate_name(name); + if (validated.is_err()) { + return Err(validated.error()); + } + + // Find and update the user by identity (primary key) + auto user_row = ctx.db[user_identity].find(ctx.sender); + if (user_row.has_value()) { + auto user = user_row.value(); + user.name = validated.value(); + ctx.db[user_identity].update(user); + return Ok(); + } + + return Err("Cannot set name for unknown user"); +} +``` + @@ -455,6 +575,31 @@ fn validate_message(text: String) -> Result { } ``` + + + +Add to `spacetimedb/src/lib.cpp`: + +```cpp server +Outcome validate_message(const std::string& text) { + if (text.empty()) { + return Err("Messages must not be empty"); + } + return Ok(text); +} + +SPACETIMEDB_REDUCER(send_message, ReducerContext ctx, std::string text) { + auto validated = validate_message(text); + if (validated.is_err()) { + return Err(validated.error()); + } + + Message msg{ctx.sender, ctx.timestamp, validated.value()}; + ctx.db[message].insert(msg); + return Ok(); +} +``` + @@ -568,6 +713,38 @@ pub fn identity_disconnected(ctx: &ReducerContext) { } ``` + + + +Add to `spacetimedb/src/lib.cpp`: + +```cpp server +SPACETIMEDB_CLIENT_CONNECTED(client_connected, ReducerContext ctx) { + auto user_row = ctx.db[user_identity].find(ctx.sender); + if (user_row.has_value()) { + auto user = user_row.value(); + user.online = true; + ctx.db[user_identity].update(user); + } else { + User new_user{ctx.sender, std::nullopt, true}; + ctx.db[user].insert(new_user); + } + return Ok(); +} + +SPACETIMEDB_CLIENT_DISCONNECTED(client_disconnected, ReducerContext ctx) { + auto user_row = ctx.db[user_identity].find(ctx.sender); + if (user_row.has_value()) { + auto user = user_row.value(); + user.online = false; + ctx.db[user_identity].update(user); + } else { + LOG_WARN("Disconnect event for unknown user"); + } + return Ok(); +} +``` + @@ -604,6 +781,13 @@ spacetime publish --server local --project-path spacetimedb quickstart-chat spacetime publish --server local --project-path spacetimedb quickstart-chat ``` + + + +```bash +spacetime publish --server local --project-path spacetimedb quickstart-chat +``` + @@ -635,6 +819,13 @@ spacetime call --server local quickstart-chat SendMessage 'Hello, World!' spacetime call --server local quickstart-chat send_message 'Hello, World!' ``` + + + +```bash +spacetime call --server local quickstart-chat send_message 'Hello, World!' +``` + @@ -665,6 +856,12 @@ You've just set up your first SpacetimeDB module! You can find the full code for - [C# server module](https://github.com/clockworklabs/SpacetimeDB/tree/master/templates/chat-console-cs/spacetimedb) - [Rust server module](https://github.com/clockworklabs/SpacetimeDB/tree/master/templates/chat-console-rs/spacetimedb) +:::note + +For C++ modules, there is not yet a dedicated C++ client SDK. To test your C++ module with a client, use one of the available client libraries: [TypeScript (React)](#creating-the-client), [C# (Console)](#creating-the-client), or [Rust (Console)](#creating-the-client). We recommend starting with the [Rust client](#creating-the-client) for testing C++ modules. + +::: + --- ## Creating the Client diff --git a/smoketests/__init__.py b/smoketests/__init__.py index effb7781065..3ec2c6cc9df 100644 --- a/smoketests/__init__.py +++ b/smoketests/__init__.py @@ -38,6 +38,8 @@ # this is set to true when the --skip-dotnet flag is not passed to the cli, # and a dotnet installation is detected HAVE_DOTNET = False +# this is set to true when emscripten is detected +HAVE_EMSCRIPTEN = False # When we pass --spacetime-login, we are running against a server that requires "real" spacetime logins (rather than `--server-issued-login`). # This is used to skip tests that don't work with that. @@ -69,11 +71,21 @@ def stream(self, value): logging.getLogger().addHandler(handler) logging.getLogger().setLevel(logging.DEBUG) +def check_emscripten(): + """Check if emscripten is available.""" + global HAVE_EMSCRIPTEN + HAVE_EMSCRIPTEN = shutil.which("emcc") is not None + def requires_dotnet(item): if HAVE_DOTNET: return item return unittest.skip("dotnet 8.0 not available")(item) +def requires_emscripten(item): + if HAVE_EMSCRIPTEN: + return item + return unittest.skip("emscripten not available")(item) + def requires_anonymous_login(item): if USE_SPACETIME_LOGIN: return unittest.skip("using `spacetime login`")(item) diff --git a/smoketests/__main__.py b/smoketests/__main__.py index cc3b0d004b6..b292b97b0aa 100644 --- a/smoketests/__main__.py +++ b/smoketests/__main__.py @@ -107,6 +107,10 @@ def main(): print("no suitable dotnet installation found") exit(1) + smoketests.check_emscripten() + if not smoketests.HAVE_EMSCRIPTEN: + logging.info("emscripten not available, skipping C++ smoketests") + add_prefix = lambda testlist: [TESTPREFIX + test for test in testlist] import fnmatch excludelist = add_prefix(args.exclude) diff --git a/smoketests/tests/quickstart.py b/smoketests/tests/quickstart.py index 254db8bed04..540f03455fe 100644 --- a/smoketests/tests/quickstart.py +++ b/smoketests/tests/quickstart.py @@ -376,3 +376,48 @@ def server_postprocess(self, server_path: Path): def test_quickstart(self): """Run the TypeScript quickstart guides for server.""" self._test_quickstart() + + +class Cpp(Rust): + """C++ server with Rust client quickstart test.""" + lang = "cpp" + client_lang = "rust" + server_doc = STDB_DIR / "docs/docs/00100-intro/00300-tutorials/00100-chat-app.md" + server_file = "src/lib.cpp" + # Inherit client_file, module_bindings, run_cmd, build_cmd from Rust + + # Inherit Rust client behavior + replacements = Rust.replacements + extra_code = Rust.extra_code + connected_str = Rust.connected_str + + def generate_server(self, server_path: Path): + """Generate the C++ server code from documentation. + Override to use correct project path and skip Rust-specific setup.""" + logging.info(f"Generating server code {self.lang}: {server_path}...") + self.spacetime( + "init", + "--non-interactive", + "--lang", + self.lang, + "--project-path", + server_path, + "spacetimedb-project", + capture_stderr=True, + ) + # C++ init creates server_path/spacetimedb structure + self.project_path = server_path / "spacetimedb" + # Don't copy rust-toolchain.toml for C++ + _write_file(self.project_path / self.server_file, _parse_quickstart(self.server_doc, self.lang, self._module_name, server=True)) + self.server_postprocess(self.project_path) + self.spacetime("build", "-d", "-p", self.project_path, capture_stderr=True) + + def server_postprocess(self, server_path: Path): + """C++ doesn't need Cargo.toml - override parent's Rust implementation.""" + pass + + def test_quickstart(self): + """Run the C++ server + Rust client quickstart.""" + if not smoketests.HAVE_EMSCRIPTEN: + self.skipTest("C++ SDK requires Emscripten to be installed.") + self._test_quickstart()