From e24bf42f1862ad2beaa71576e6c545d4adc73c31 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Wed, 11 Oct 2023 22:22:57 -0700 Subject: [PATCH 001/164] Update index.md --- docs/Overview/index.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/Overview/index.md b/docs/Overview/index.md index 2464e6e3..264b5861 100644 --- a/docs/Overview/index.md +++ b/docs/Overview/index.md @@ -1,5 +1,3 @@ -# SpacetimeDB Documentation - ## Installation You can run SpacetimeDB as a standalone database server via the `spacetime` CLI tool. From 176c19d7983bfbb6563f4cdf03dff08134b00a98 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Wed, 11 Oct 2023 23:07:24 -0700 Subject: [PATCH 002/164] Update index.md --- docs/Overview/index.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/Overview/index.md b/docs/Overview/index.md index 264b5861..2464e6e3 100644 --- a/docs/Overview/index.md +++ b/docs/Overview/index.md @@ -1,3 +1,5 @@ +# SpacetimeDB Documentation + ## Installation You can run SpacetimeDB as a standalone database server via the `spacetime` CLI tool. From 9d7d5c71441640bca2a7860cff2a2711c676d472 Mon Sep 17 00:00:00 2001 From: John Detter Date: Thu, 12 Oct 2023 08:22:23 -0500 Subject: [PATCH 003/164] Including this line for completeness --- LICENSE.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/LICENSE.txt b/LICENSE.txt index dd5b3a58..d9a10c0d 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -172,3 +172,5 @@ defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS From ec418f1d54e5d6426a2cd464119362dcec77e3fd Mon Sep 17 00:00:00 2001 From: John Detter Date: Thu, 12 Oct 2023 09:39:03 -0500 Subject: [PATCH 004/164] C# Quickstart is working --- docs/Client SDK Languages/C#/index.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/docs/Client SDK Languages/C#/index.md b/docs/Client SDK Languages/C#/index.md index b64ca13d..eb7829a6 100644 --- a/docs/Client SDK Languages/C#/index.md +++ b/docs/Client SDK Languages/C#/index.md @@ -150,7 +150,7 @@ This second case means that, even though the module only ever inserts online use Whenever we want to print a user, if they have set a name, we'll use that. If they haven't set a name, we'll instead print the first 8 bytes of their identity, encoded as hexadecimal. We'll define a function `UserNameOrIdentity` to handle this. ```csharp -string UserNameOrIdentity(User user) => user.Name ?? Identity.From(user.Identity).ToString()!.Substring(0, 8); +string UserNameOrIdentity(User user) => user.Name ?? user.Identity.ToString()!.Substring(0, 8); void User_OnInsert(User insertedValue, ReducerEvent? dbEvent) { @@ -291,7 +291,7 @@ void OnConnect() This callback is executed when we receive our credentials from the SpacetimeDB module. We'll use the `AuthToken` module to save our token to local storage, so that we can re-authenticate as the same user the next time we connect. We'll also store the identity in a global variable `local_identity` so that we can use it to check if we are the sender of a message or name change. ```csharp -void OnIdentityReceived(string authToken, Identity identity) +void OnIdentityReceived(string authToken, Identity identity, Address _address) { local_identity = identity; AuthToken.SaveToken(authToken); @@ -333,13 +333,12 @@ Since the input loop will be blocking, we'll run our processing code in a separa 3. Finally, Close the connection to the module. ```csharp -const string HOST = "localhost:3000"; -const string DBNAME = "chat"; -const bool SSL_ENABLED = false; - +const string HOST = "http://localhost:3000"; +const string DBNAME = "module"; + void ProcessThread() { - SpacetimeDBClient.instance.Connect(AuthToken.Token, HOST, DBNAME, SSL_ENABLED); + SpacetimeDBClient.instance.Connect(AuthToken.Token, HOST, DBNAME); // loop until cancellation token while (!cancel_token.IsCancellationRequested) From e40ce9282f13c4cbb77d16bde0c582f3b023da4b Mon Sep 17 00:00:00 2001 From: John Detter Date: Thu, 12 Oct 2023 11:16:06 -0500 Subject: [PATCH 005/164] Python quickstart updated --- docs/Client SDK Languages/Python/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/Client SDK Languages/Python/index.md b/docs/Client SDK Languages/Python/index.md index 52630452..6cdac567 100644 --- a/docs/Client SDK Languages/Python/index.md +++ b/docs/Client SDK Languages/Python/index.md @@ -53,7 +53,7 @@ In your `client` directory, run: ```bash mkdir -p module_bindings -spacetime generate --lang python --out-dir src/module_bindings --project_path ../server +spacetime generate --lang python --out-dir module_bindings --project-path ../server ``` Take a look inside `client/module_bindings`. The CLI should have generated five files: From d6de290fcb86e5677a1c181388f4b956dd02e178 Mon Sep 17 00:00:00 2001 From: John Detter Date: Thu, 12 Oct 2023 11:48:08 -0500 Subject: [PATCH 006/164] Another small python quickstart fix --- docs/Client SDK Languages/Python/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/Client SDK Languages/Python/index.md b/docs/Client SDK Languages/Python/index.md index 6cdac567..29552c32 100644 --- a/docs/Client SDK Languages/Python/index.md +++ b/docs/Client SDK Languages/Python/index.md @@ -250,7 +250,7 @@ We handle warnings on rejected messages the same way as rejected names, though t Add this function before the `register_callbacks` function: ```python -def on_send_message_reducer(sender, status, message, msg): +def on_send_message_reducer(sender, _addr, status, message, msg): if sender == local_identity: if status == "failed": print(f"Failed to send message: {message}") From 60fee240bdb90e9251c581aca447304f4b336379 Mon Sep 17 00:00:00 2001 From: John Detter Date: Thu, 12 Oct 2023 14:10:57 -0500 Subject: [PATCH 007/164] Fix Nuget command --- docs/Client SDK Languages/C#/index.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/Client SDK Languages/C#/index.md b/docs/Client SDK Languages/C#/index.md index eb7829a6..66342bce 100644 --- a/docs/Client SDK Languages/C#/index.md +++ b/docs/Client SDK Languages/C#/index.md @@ -22,10 +22,10 @@ Open the project in your IDE of choice. ## Add the NuGet package for the C# SpacetimeDB SDK -Add the `spacetimedbsdk` [NuGet package](https://www.nuget.org/packages/spacetimedbsdk) using Visual Studio NuGet package manager or via the .NET CLI +Add the `SpacetimeDB.ClientSDK` [NuGet package](https://www.nuget.org/packages/spacetimedbsdk) using Visual Studio NuGet package manager or via the .NET CLI ```bash -dotnet add package spacetimedbsdk +dotnet add package SpacetimeDB.ClientSDK ``` ## Generate your module types From 54935143294935729283cc64c77d69a49ed27de5 Mon Sep 17 00:00:00 2001 From: John Detter Date: Thu, 12 Oct 2023 15:16:44 -0500 Subject: [PATCH 008/164] Applied Phoebe's patch --- docs/Client SDK Languages/C#/SDK Reference.md | 49 ++++--- docs/Client SDK Languages/C#/index.md | 2 +- .../Python/SDK Reference.md | 73 ++++++---- docs/Client SDK Languages/Python/index.md | 22 +-- .../Rust/SDK Reference.md | 72 +++++++--- docs/Client SDK Languages/Rust/index.md | 18 ++- .../Typescript/SDK Reference.md | 127 +++++++++++++++--- docs/Client SDK Languages/Typescript/index.md | 8 +- docs/Overview/index.md | 12 +- .../C#/ModuleReference.md | 9 +- docs/Server Module Languages/C#/index.md | 5 +- docs/Server Module Languages/Rust/index.md | 6 +- .../Part 1 - Basic Multiplayer.md | 6 +- 13 files changed, 295 insertions(+), 114 deletions(-) diff --git a/docs/Client SDK Languages/C#/SDK Reference.md b/docs/Client SDK Languages/C#/SDK Reference.md index 3284e6fe..ad4c8c48 100644 --- a/docs/Client SDK Languages/C#/SDK Reference.md +++ b/docs/Client SDK Languages/C#/SDK Reference.md @@ -44,6 +44,7 @@ The SpacetimeDB client C# for Rust contains all the tools you need to build nati - [Static Property `AuthToken.Token`](#static-property-authtokentoken) - [Static Method `AuthToken.SaveToken`](#static-method-authtokensavetoken) - [Class `Identity`](#class-identity) + - [Class `Address`](#class-address) - [Customizing logging](#customizing-logging) - [Interface `ISpacetimeDBLogger`](#interface-ispacetimedblogger) - [Class `ConsoleLogger`](#class-consolelogger) @@ -178,7 +179,7 @@ SpacetimeDBClient.instance.Connect(null, "dev.spacetimedb.net", DBNAME, true); AuthToken.Init(); Identity localIdentity; SpacetimeDBClient.instance.Connect(AuthToken.Token, "dev.spacetimedb.net", DBNAME, true); -SpacetimeDBClient.instance.onIdentityReceived += (string authToken, Identity identity) { +SpacetimeDBClient.instance.onIdentityReceived += (string authToken, Identity identity, Address address) { AuthToken.SaveToken(authToken); localIdentity = identity; } @@ -192,13 +193,13 @@ SpacetimeDBClient.instance.onIdentityReceived += (string authToken, Identity ide namespace SpacetimeDB { class SpacetimeDBClient { - public event Action onIdentityReceived; + public event Action onIdentityReceived; } } ``` -Called when we receive an auth token and [`Identity`](#class-identity) from the server. The [`Identity`](#class-identity) serves as a unique public identifier for a client connected to the database. It can be for several purposes, such as filtering rows in a database for the rows created by a particular user. The auth token is a private access token that allows us to assume an identity. ++Called when we receive an auth token, [`Identity`](#class-identity) and [`Address`](#class-address) from the server. The [`Identity`](#class-identity) serves as a unique public identifier for a user of the database. It can be for several purposes, such as filtering rows in a database for the rows created by a particular user. The auth token is a private access token that allows us to assume an identity. The [`Address`](#class-address) is opaque identifier for a client connection to a database, intended to differentiate between connections from the same [`Identity`](#class-identity). To store the auth token to the filesystem, use the static method [`AuthToken.SaveToken`](#static-method-authtokensavetoken). You may also want to store the returned [`Identity`](#class-identity) in a local variable. @@ -209,7 +210,7 @@ If an existing auth token is used to connect to the database, the same auth toke AuthToken.Init(); Identity localIdentity; SpacetimeDBClient.instance.Connect(AuthToken.Token, "dev.spacetimedb.net", DBNAME, true); -SpacetimeDBClient.instance.onIdentityReceived += (string authToken, Identity identity) { +SpacetimeDBClient.instance.onIdentityReceived += (string authToken, Identity identity, Address address) { AuthToken.SaveToken(authToken); localIdentity = identity; } @@ -856,24 +857,42 @@ Save a token to the filesystem. ### Class `Identity` ```cs -namespace SpacetimeDB { - -public struct Identity : IEquatable +namespace SpacetimeDB { - public byte[] Bytes { get; } - public static Identity From(byte[] bytes); - public bool Equals(Identity other); - public static bool operator ==(Identity a, Identity b); - public static bool operator !=(Identity a, Identity b); -} - + public struct Identity : IEquatable + { + public byte[] Bytes { get; } + public static Identity From(byte[] bytes); + public bool Equals(Identity other); + public static bool operator ==(Identity a, Identity b); + public static bool operator !=(Identity a, Identity b); + } } ``` -A unique public identifier for a client connected to a database. +A unique public identifier for a user of a database. + + Columns of type `Identity` inside a module will be represented in the C# SDK as properties of type `byte[]`. `Identity` is essentially just a wrapper around `byte[]`, and you can use the `Bytes` property to get a `byte[]` that can be used to filter tables and so on. +### Class `Identity` +```cs +namespace SpacetimeDB +{ + public struct Address : IEquatable
+ { + public byte[] Bytes { get; } + public static Address? From(byte[] bytes); + public bool Equals(Address other); + public static bool operator ==(Address a, Address b); + public static bool operator !=(Address a, Address b); + } +} +``` + +An opaque identifier for a client connection to a database, intended to differentiate between connections from the same [`Identity`](#class-identity). + ## Customizing logging The SpacetimeDB C# SDK performs internal logging. Instances of [`ISpacetimeDBLogger`](#interface-ispacetimedblogger) can be passed to [`SpacetimeDBClient.CreateInstance`](#static-method-spacetimedbclientcreateinstance) to customize how SDK logs are delivered to your application. diff --git a/docs/Client SDK Languages/C#/index.md b/docs/Client SDK Languages/C#/index.md index 66342bce..f4d8b7ee 100644 --- a/docs/Client SDK Languages/C#/index.md +++ b/docs/Client SDK Languages/C#/index.md @@ -288,7 +288,7 @@ void OnConnect() ## OnIdentityReceived callback -This callback is executed when we receive our credentials from the SpacetimeDB module. We'll use the `AuthToken` module to save our token to local storage, so that we can re-authenticate as the same user the next time we connect. We'll also store the identity in a global variable `local_identity` so that we can use it to check if we are the sender of a message or name change. +This callback is executed when we receive our credentials from the SpacetimeDB module. We'll use the `AuthToken` module to save our token to local storage, so that we can re-authenticate as the same user the next time we connect. We'll also store the identity in a global variable `local_identity` so that we can use it to check if we are the sender of a message or name change. This callback also notifies us of our client's `Address`, an opaque identifier SpacetimeDB modules can use to distinguish connections by the same `Identity`, but we won't use it in our app. ```csharp void OnIdentityReceived(string authToken, Identity identity, Address _address) diff --git a/docs/Client SDK Languages/Python/SDK Reference.md b/docs/Client SDK Languages/Python/SDK Reference.md index 8cd4b4ca..276d59df 100644 --- a/docs/Client SDK Languages/Python/SDK Reference.md +++ b/docs/Client SDK Languages/Python/SDK Reference.md @@ -44,8 +44,9 @@ The following functions and types are used in both the Basic and Async clients. ### API at a glance | Definition | Description | -| ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +|---------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------| | Type [`Identity`](#type-identity) | A unique public identifier for a client. | +| Type [`Address`](#type-address) | An opaque identifier for differentiating connections by the same `Identity`. | | Type [`ReducerEvent`](#type-reducerevent) | `class` containing information about the reducer that triggered a row update event. | | Type [`module_bindings::{TABLE}`](#type-table) | Autogenerated `class` type for a table, holding one row. | | Method [`module_bindings::{TABLE}::filter_by_{COLUMN}`](#method-filter_by_column) | Autogenerated method to iterate over or seek subscribed rows where a column matches a value. | @@ -76,7 +77,31 @@ class Identity: | `__str__` | `None` | Convert the Identity to a hex string | | `__eq__` | `Identity` | Compare two Identities for equality | -A unique public identifier for a client connected to a database. +A unique public identifier for a user of a database. + +### Type `Address` + +```python +class Address: + @staticmethod + def from_string(string) + + @staticmethod + def from_bytes(data) + + def __str__(self) + + def __eq__(self, other) +``` + +| Member | Type | Meaning | +|---------------|-----------|-------------------------------------| +| `from_string` | `str` | Create an Address from a hex string | +| `from_bytes` | `bytes` | Create an Address from raw bytes | +| `__str__` | `None` | Convert the Address to a hex string | +| `__eq__` | `Address` | Compare two Identities for equality | + +An opaque identifier for a client connection to a database, intended to differentiate between connections from the same [`Identity`](#type-identity). ### Type `ReducerEvent` @@ -90,13 +115,14 @@ class ReducerEvent: self.args = args ``` -| Member | Args | Meaning | -| ----------------- | ----------- | --------------------------------------------------------------------------- | -| `caller_identity` | `Identity` | The identity of the user who invoked the reducer | -| `reducer_name` | `str` | The name of the reducer that was invoked | -| `status` | `str` | The status of the reducer invocation ("committed", "failed", "outofenergy") | -| `message` | `str` | The message returned by the reducer if it fails | -| `args` | `List[str]` | The arguments passed to the reducer | +| Member | Type | Meaning | +|-------------------|---------------------|------------------------------------------------------------------------------------| +| `caller_identity` | `Identity` | The identity of the user who invoked the reducer | +| `caller_address` | `Optional[Address]` | The address of the user who invoked the reducer, or `None` for scheduled reducers. | +| `reducer_name` | `str` | The name of the reducer that was invoked | +| `status` | `str` | The status of the reducer invocation ("committed", "failed", "outofenergy") | +| `message` | `str` | The message returned by the reducer if it fails | +| `args` | `List[str]` | The arguments passed to the reducer | This class contains the information about a reducer event to be passed to row update callbacks. @@ -173,7 +199,7 @@ This function is autogenerated for each reducer in your module. It is used to in ### Function `register_on_{REDUCER_NAME}` ```python -def register_on_{REDUCER_NAME}(callback: Callable[[Identity, str, str, ARG1_TYPE, ARG1_TYPE], None]) +def register_on_{REDUCER_NAME}(callback: Callable[[Identity, Optional[Address], str, str, ARG1_TYPE, ARG1_TYPE], None]) ``` | Argument | Type | Meaning | @@ -183,6 +209,7 @@ def register_on_{REDUCER_NAME}(callback: Callable[[Identity, str, str, ARG1_TYPE Register a callback function to be executed when the reducer is invoked. Callback arguments are: - `caller_identity`: The identity of the user who invoked the reducer. +- `caller_address`: The address of the user who invoked the reducer, or `None` for scheduled reducers. - `status`: The status of the reducer invocation ("committed", "failed", "outofenergy"). - `message`: The message returned by the reducer if it fails. - `args`: Variable number of arguments passed to the reducer. @@ -326,7 +353,7 @@ spacetime_client.schedule_event(0.1, application_tick) ### API at a glance | Definition | Description | -| ---------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +|------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------| | Function [`SpacetimeDBClient::init`](#function-init) | Create a network manager instance. | | Function [`SpacetimeDBClient::subscribe`](#function-subscribe) | Subscribe to receive data and transaction updates for the provided queries. | | Function [`SpacetimeDBClient::register_on_event`](#function-register_on_event) | Register a callback function to handle transaction update events. | @@ -349,24 +376,24 @@ def init( autogen_package: module, on_connect: Callable[[], NoneType] = None, on_disconnect: Callable[[str], NoneType] = None, - on_identity: Callable[[str, Identity], NoneType] = None, + on_identity: Callable[[str, Identity, Address], NoneType] = None, on_error: Callable[[str], NoneType] = None ) ``` Create a network manager instance. -| Argument | Type | Meaning | -| ----------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| `auth_token` | `str` | This is the token generated by SpacetimeDB that matches the user's identity. If None, token will be generated | -| `host` | `str` | Hostname:port for SpacetimeDB connection | -| `address_or_name` | `str` | The name or address of the database to connect to | -| `ssl_enabled` | `bool` | Whether to use SSL when connecting to the server. | -| `autogen_package` | `ModuleType` | Python package where SpacetimeDB module generated files are located. | -| `on_connect` | `Callable[[], None]` | Optional callback called when a connection is made to the SpacetimeDB module. | -| `on_disconnect` | `Callable[[str], None]` | Optional callback called when the Python client is disconnected from the SpacetimeDB module. The argument is the close message. | -| `on_identity` | `Callable[[str, Identity], None]` | Called when the user identity is recieved from SpacetimeDB. First argument is the auth token used to login in future sessions. | -| `on_error` | `Callable[[str], None]` | Optional callback called when the Python client connection encounters an error. The argument is the error message. | +| Argument | Type | Meaning | +|-------------------|--------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `auth_token` | `str` | This is the token generated by SpacetimeDB that matches the user's identity. If None, token will be generated | +| `host` | `str` | Hostname:port for SpacetimeDB connection | +| `address_or_name` | `str` | The name or address of the database to connect to | +| `ssl_enabled` | `bool` | Whether to use SSL when connecting to the server. | +| `autogen_package` | `ModuleType` | Python package where SpacetimeDB module generated files are located. | +| `on_connect` | `Callable[[], None]` | Optional callback called when a connection is made to the SpacetimeDB module. | +| `on_disconnect` | `Callable[[str], None]` | Optional callback called when the Python client is disconnected from the SpacetimeDB module. The argument is the close message. | +| `on_identity` | `Callable[[str, Identity, Address], None]` | Called when the user identity is recieved from SpacetimeDB. First argument is the auth token used to login in future sessions. Third argument is the client connection's [`Address`](#type-address). | +| `on_error` | `Callable[[str], None]` | Optional callback called when the Python client connection encounters an error. The argument is the error message. | This function creates a new SpacetimeDBClient instance. It should be called before any other functions in the SpacetimeDBClient class. This init will call connect for you. diff --git a/docs/Client SDK Languages/Python/index.md b/docs/Client SDK Languages/Python/index.md index 29552c32..25723fcc 100644 --- a/docs/Client SDK Languages/Python/index.md +++ b/docs/Client SDK Languages/Python/index.md @@ -215,11 +215,12 @@ def print_message(message): We can also register callbacks to run each time a reducer is invoked. We register these callbacks using the `register_on_` method, which is automatically implemented for each reducer by `spacetime generate`. -Each reducer callback takes three fixed arguments: - +Each reducer callback takes four fixed arguments: + 1. The `Identity` of the client who requested the reducer invocation. -2. The `Status` of the reducer run, one of `committed`, `failed` or `outofenergy`. -3. The `Message` returned by the reducer in error cases, or `None` if the reducer succeeded. +2. The `Address` of the client who requested the reducer invocation, or `None` for scheduled reducers. +3. The `Status` of the reducer run, one of `committed`, `failed` or `outofenergy`. +4. The `Message` returned by the reducer in error cases, or `None` if the reducer succeeded. It also takes a variable number of arguments which match the calling arguments of the reducer. @@ -237,8 +238,8 @@ We'll test both that our identity matches the sender and that the status is `fai Add this function before the `register_callbacks` function: ```python -def on_set_name_reducer(sender, status, message, name): - if sender == local_identity: +def on_set_name_reducer(sender_id, sender_address, status, message, name): + if sender_id == local_identity: if status == "failed": print(f"Failed to set name: {message}") ``` @@ -250,10 +251,10 @@ We handle warnings on rejected messages the same way as rejected names, though t Add this function before the `register_callbacks` function: ```python -def on_send_message_reducer(sender, _addr, status, message, msg): - if sender == local_identity: +def on_send_message_reducer(sender_id, sender_address, status, message, msg): + if sender_id == local_identity: if status == "failed": - print(f"Failed to send message: {message}") + print(f"Failed to send message: {message}") ``` ### OnSubscriptionApplied callback @@ -301,10 +302,11 @@ def check_commands(): This callback fires after the client connects to the server. We'll use it to save our credentials to a file so that we can re-authenticate as the same user next time we connect. -The `on_connect` callback takes two arguments: +The `on_connect` callback takes three arguments: 1. The `Auth Token` is the equivalent of your private key. This is the only way to authenticate with the SpacetimeDB module as this user. 2. The `Identity` is the equivalent of your public key. This is used to uniquely identify this user and will be sent to other clients. We store this in a global variable so we can use it to identify that a given message or transaction was sent by us. +3. The `Address` is an opaque identifier modules can use to distinguish multiple concurrent connections by the same `Identity`. We don't need to know our `Address`, so we'll ignore that argument. To store our auth token, we use the optional component `local_config`, which provides a simple interface for storing and retrieving a single `Identity` from a file. We'll use the `local_config::set_string` method to store the auth token. Other projects might want to associate this token with some other identifier such as an email address or Steam ID. diff --git a/docs/Client SDK Languages/Rust/SDK Reference.md b/docs/Client SDK Languages/Rust/SDK Reference.md index c61a06f3..bd914b00 100644 --- a/docs/Client SDK Languages/Rust/SDK Reference.md +++ b/docs/Client SDK Languages/Rust/SDK Reference.md @@ -46,9 +46,11 @@ mod module_bindings; | Type [`spacetimedb_sdk::identity::Identity`](#type-identity) | A unique public identifier for a client. | | Type [`spacetimedb_sdk::identity::Token`](#type-token) | A private authentication token corresponding to an `Identity`. | | Type [`spacetimedb_sdk::identity::Credentials`](#type-credentials) | An `Identity` paired with its `Token`. | +| Type [`spacetimedb_sdk::Address`](#type-address) | An opaque identifier for differentiating connections by the same `Identity`. | | Function [`spacetimedb_sdk::identity::identity`](#function-identity) | Return the current connection's `Identity`. | | Function [`spacetimedb_sdk::identity::token`](#function-token) | Return the current connection's `Token`. | | Function [`spacetimedb_sdk::identity::credentials`](#function-credentials) | Return the current connection's [`Credentials`](#type-credentials). | +| Function [`spacetimedb_sdk::identity::address`](#function-address) | Return the current connection's [`Address`](#type-address). | | Function [`spacetimedb_sdk::identity::on_connect`](#function-on-connect) | Register a `FnMut` callback to run when the connection's [`Credentials`](#type-credentials) are verified with the database. | | Function [`spacetimedb_sdk::identity::once_on_connect`](#function-once_on_connect) | Register a `FnOnce` callback to run when the connection's [`Credentials`](#type-credentials) are verified with the database. | | Function [`spacetimedb_sdk::identity::remove_on_connect`](#function-remove_on_connect) | Cancel an `on_connect` or `once_on_connect` callback. | @@ -419,6 +421,14 @@ Credentials, including a private access token, sufficient to authenticate a clie | `identity` | [`Identity`](#type-identity) | | `token` | [`Token`](#type-token) | +### Type `Address` + +```rust +spacetimedb_sdk::Address +``` + +An opaque identifier for a client connection to a database, intended to differentiate between connections from the same [`Identity`](#type-identity). + ### Function `identity` ```rust @@ -494,21 +504,40 @@ println!("My credentials are {:?}", credentials()); // })" ``` +### Function `address` + +```rust +spacetimedb_sdk::identity::address() -> Result
+``` + +Read the current connection's [`Address`](#type-address). + +Returns an error if [`connect`](#function-connect) has not yet been called. + +```rust +connect(SPACETIMEDB_URI, DB_NAME, None) + .expect("Failed to connect"); + +sleep(Duration::from_secs(1)); + +println!("My address is {:?}", address()); +``` + ### Function `on_connect` ```rust spacetimedb_sdk::identity::on_connect( - callback: impl FnMut(&Credentials) + Send + 'static, + callback: impl FnMut(&Credentials, Address) + Send + 'static, ) -> ConnectCallbackId ``` Register a callback to be invoked upon authentication with the database. -| Argument | Type | Meaning | -| ---------- | ----------------------------------------- | ------------------------------------------------------ | -| `callback` | `impl FnMut(&Credentials) + Send + 'sync` | Callback to be invoked upon successful authentication. | - -The callback will be invoked with the [`Credentials`](#type-credentials) provided by the database to identify this connection. If [`Credentials`](#type-credentials) were supplied to [`connect`](#function-connect), those passed to the callback will be equivalent to the ones used to connect. If the initial connection was anonymous, a new set of [`Credentials`](#type-credentials) will be generated by the database to identify this user. +| Argument | Type | Meaning | +|------------|----------------------------------------------------|--------------------------------------------------------| +| `callback` | `impl FnMut(&Credentials, Address) + Send + 'sync` | Callback to be invoked upon successful authentication. | + +The callback will be invoked with the [`Credentials`](#type-credentials) and [`Address`](#type-address) provided by the database to identify this connection. If [`Credentials`](#type-credentials) were supplied to [`connect`](#function-connect), those passed to the callback will be equivalent to the ones used to connect. If the initial connection was anonymous, a new set of [`Credentials`](#type-credentials) will be generated by the database to identify this user. The [`Credentials`](#type-credentials) passed to the callback can be saved and used to authenticate the same user in future connections. @@ -516,7 +545,8 @@ The returned `ConnectCallbackId` can be passed to [`remove_on_connect`](#functio ```rust on_connect( - |creds| println!("Successfully connected! My credentials are: {:?}", creds) + |creds, addr| + println!("Successfully connected! My credentials are: {:?} and my address is: {:?}", creds, addr) ); connect(SPACETIMEDB_URI, DB_NAME, None) @@ -532,17 +562,17 @@ sleep(Duration::from_secs(1)); ```rust spacetimedb_sdk::identity::once_on_connect( - callback: impl FnOnce(&Credentials) + Send + 'static, + callback: impl FnOnce(&Credentials, Address) + Send + 'static, ) -> ConnectCallbackId ``` Register a callback to be invoked once upon authentication with the database. -| Argument | Type | Meaning | -| ---------- | ------------------------------------------ | ---------------------------------------------------------------- | -| `callback` | `impl FnOnce(&Credentials) + Send + 'sync` | Callback to be invoked once upon next successful authentication. | +| Argument | Type | Meaning | +|------------|-----------------------------------------------------|------------------------------------------------------------------| +| `callback` | `impl FnOnce(&Credentials, Address) + Send + 'sync` | Callback to be invoked once upon next successful authentication. | -The callback will be invoked with the [`Credentials`](#type-credentials) provided by the database to identify this connection. If [`Credentials`](#type-credentials) were supplied to [`connect`](#function-connect), those passed to the callback will be equivalent to the ones used to connect. If the initial connection was anonymous, a new set of [`Credentials`](#type-credentials) will be generated by the database to identify this user. +The callback will be invoked with the [`Credentials`](#type-credentials) and [`Address`](#type-address) provided by the database to identify this connection. If [`Credentials`](#type-credentials) were supplied to [`connect`](#function-connect), those passed to the callback will be equivalent to the ones used to connect. If the initial connection was anonymous, a new set of [`Credentials`](#type-credentials) will be generated by the database to identify this user. The [`Credentials`](#type-credentials) passed to the callback can be saved and used to authenticate the same user in future connections. @@ -565,7 +595,7 @@ Unregister a previously-registered [`on_connect`](#function-on_connect) or [`onc If `id` does not refer to a currently-registered callback, this operation does nothing. ```rust -let id = on_connect(|_creds| unreachable!()); +let id = on_connect(|_creds, _addr| unreachable!()); remove_on_connect(id); @@ -631,7 +661,7 @@ const CREDENTIALS_DIR = ".my-module"; let creds = load_credentials(CREDENTIALS_DIRectory) .expect("Error while loading credentials"); -on_connect(|creds| { +on_connect(|creds, _addr| { if let Err(e) = save_credentials(CREDENTIALS_DIR, creds) { eprintln!("Error while saving credentials: {:?}", e); } @@ -1068,7 +1098,7 @@ For reducers which accept a `ReducerContext` as their first argument, the `Reduc ```rust module_bindings::on_{REDUCER}( - callback: impl FnMut(&Identity, Status, {&ARGS...}) + Send + 'static, + callback: impl FnMut(&Identity, Option
, Status, {&ARGS...}) + Send + 'static, ) -> ReducerCallbackId<{REDUCER}Args> ``` @@ -1076,12 +1106,12 @@ For each reducer defined by a module, `spacetime generate` generates a function | Argument | Type | Meaning | | ---------- | ------------------------------------------------------------- | ------------------------------------------------ | -| `callback` | `impl FnMut(&Identity, &Status, {&ARGS...}) + Send + 'static` | Callback to run whenever the reducer is invoked. | +| `callback` | `impl FnMut(&Identity, Option
&Status, {&ARGS...}) + Send + 'static` | Callback to run whenever the reducer is invoked. | -The callback always accepts two arguments: +The callback always accepts three arguments: -- `caller: &Identity`, the [`Identity`](#type-identity) of the client which invoked the reducer. -- `status: &Status`, the termination [`Status`](#type-status) of the reducer run. +- `caller_id: &Identity`, the [`Identity`](#type-identity) of the client which invoked the reducer. +- `caller_address: Option
`, the [`Address`](#type-address) of the client which invoked the reducer. This may be `None` for scheduled reducers. In addition, the callback accepts a reference to each of the reducer's arguments. @@ -1096,7 +1126,7 @@ The `on_{REDUCER}` function returns a `ReducerCallbackId<{REDUCER}Args>`, where ```rust module_bindings::once_on_{REDUCER}( - callback: impl FnOnce(&Identity, &Status, {&ARGS...}) + Send + 'static, + callback: impl FnOnce(&Identity, Option
, &Status, {&ARGS...}) + Send + 'static, ) -> ReducerCallbackId<{REDUCER}Args> ``` @@ -1104,7 +1134,7 @@ For each reducer defined by a module, `spacetime generate` generates a function | Argument | Type | Meaning | | ---------- | -------------------------------------------------------------- | ----------------------------------------------------- | -| `callback` | `impl FnOnce(&Identity, &Status, {&ARGS...}) + Send + 'static` | Callback to run the next time the reducer is invoked. | +| `callback` | `impl FnOnce(&Identity, Option
, &Status, {&ARGS...}) + Send + 'static` | Callback to run the next time the reducer is invoked. | The callback accepts the same arguments as an [on-reducer callback](#function-on_reducer), but may be a `FnOnce` rather than a `FnMut`. diff --git a/docs/Client SDK Languages/Rust/index.md b/docs/Client SDK Languages/Rust/index.md index c44ab49d..f35f0829 100644 --- a/docs/Client SDK Languages/Rust/index.md +++ b/docs/Client SDK Languages/Rust/index.md @@ -28,7 +28,7 @@ cargo new client Below the `[dependencies]` line in `client/Cargo.toml`, add: ```toml -spacetimedb-sdk = "0.6" +spacetimedb-sdk = "0.7" hex = "0.4" ``` @@ -84,6 +84,7 @@ To `client/src/main.rs`, add: ```rust use spacetimedb_sdk::{ + Address, disconnect, identity::{load_credentials, once_on_connect, save_credentials, Credentials, Identity}, on_disconnect, on_subscription_applied, @@ -160,18 +161,20 @@ fn register_callbacks() { ### Save credentials -Each client has a `Credentials`, which consists of two parts: +Each user has a `Credentials`, which consists of two parts: - An `Identity`, a unique public identifier. We're using these to identify `User` rows. - A `Token`, a private key which SpacetimeDB uses to authenticate the client. `Credentials` are generated by SpacetimeDB each time a new client connects, and sent to the client so they can be saved, in order to re-connect with the same identity. The Rust SDK provides a pair of functions, `save_credentials` and `load_credentials`, for storing these credentials in a file. We'll save our credentials into a file in the directory `~/.spacetime_chat`, which should be unintrusive. If saving our credentials fails, we'll print a message to standard error, but otherwise continue normally; even though the user won't be able to reconnect with the same identity, they can still chat normally. +Each client also has an `Address`, which modules can use to distinguish multiple concurrent connections by the same `Identity`. We don't need to know our `Address`, so we'll ignore that argument. + To `client/src/main.rs`, add: ```rust /// Our `on_connect` callback: save our credentials to a file. -fn on_connected(creds: &Credentials) { +fn on_connected(creds: &Credentials, _client_address: Address) { if let Err(e) = save_credentials(CREDS_DIR, creds) { eprintln!("Failed to save credentials: {:?}", e); } @@ -303,10 +306,11 @@ fn on_sub_applied() { We can also register callbacks to run each time a reducer is invoked. We register these callbacks using the `on_reducer` method of the `Reducer` trait, which is automatically implemented for each reducer by `spacetime generate`. -Each reducer callback takes at least two arguments: +Each reducer callback takes at least three arguments: 1. The `Identity` of the client who requested the reducer invocation. -2. The `Status` of the reducer run, one of `Committed`, `Failed` or `OutOfEnergy`. `Status::Failed` holds the error which caused the reducer to fail, as a `String`. +2. The `Address` of the client who requested the reducer invocation, which may be `None` for scheduled reducers. +3. The `Status` of the reducer run, one of `Committed`, `Failed` or `OutOfEnergy`. `Status::Failed` holds the error which caused the reducer to fail, as a `String`. In addition, it takes a reference to each of the arguments passed to the reducer itself. @@ -323,7 +327,7 @@ To `client/src/main.rs`, add: ```rust /// Our `on_set_name` callback: print a warning if the reducer failed. -fn on_name_set(_sender: &Identity, status: &Status, name: &String) { +fn on_name_set(_sender_id: &Identity, _sender_address: Option
, status: &Status, name: &String) { if let Status::Failed(err) = status { eprintln!("Failed to change name to {:?}: {}", name, err); } @@ -338,7 +342,7 @@ To `client/src/main.rs`, add: ```rust /// Our `on_send_message` callback: print a warning if the reducer failed. -fn on_message_sent(_sender: &Identity, status: &Status, text: &String) { +fn on_message_sent(_sender_id: &Identity, _sender_address: Option
, status: &Status, text: &String) { if let Status::Failed(err) = status { eprintln!("Failed to send message {:?}: {}", text, err); } diff --git a/docs/Client SDK Languages/Typescript/SDK Reference.md b/docs/Client SDK Languages/Typescript/SDK Reference.md index 657115d7..fb7d5be6 100644 --- a/docs/Client SDK Languages/Typescript/SDK Reference.md +++ b/docs/Client SDK Languages/Typescript/SDK Reference.md @@ -91,12 +91,13 @@ console.log(Person, AddReducer, SayHelloReducer); ### Classes -| Class | Description | -| ----------------------------------------------- | ---------------------------------------------------------------- | -| [`SpacetimeDBClient`](#class-spacetimedbclient) | The database client connection to a SpacetimeDB server. | -| [`Identity`](#class-identity) | The user's public identity. | -| [`{Table}`](#class-table) | `{Table}` is a placeholder for each of the generated tables. | -| [`{Reducer}`](#class-reducer) | `{Reducer}` is a placeholder for each of the generated reducers. | +| Class | Description | +|-------------------------------------------------|------------------------------------------------------------------------------| +| [`SpacetimeDBClient`](#class-spacetimedbclient) | The database client connection to a SpacetimeDB server. | +| [`Identity`](#class-identity) | The user's public identity. | +| [`Address`](#class-address) | An opaque identifier for differentiating connections by the same `Identity`. | +| [`{Table}`](#class-table) | `{Table}` is a placeholder for each of the generated tables. | +| [`{Reducer}`](#class-reducer) | `{Reducer}` is a placeholder for each of the generated reducers. | ### Class `SpacetimeDBClient` @@ -288,23 +289,24 @@ Register a callback to be invoked upon authentication with the database. onConnect(callback: (token: string, identity: Identity) => void): void ``` -The callback will be invoked with the public [Identity](#class-identity) and private authentication token provided by the database to identify this connection. If credentials were supplied to [connect](#spacetimedbclient-connect), those passed to the callback will be equivalent to the ones used to connect. If the initial connection was anonymous, a new set of credentials will be generated by the database to identify this user. +The callback will be invoked with the public user [Identity](#class-identity), private authentication token and connection [`Address`](#class-address) provided by the database. If credentials were supplied to [connect](#spacetimedbclient-connect), those passed to the callback will be equivalent to the ones used to connect. If the initial connection was anonymous, a new set of credentials will be generated by the database to identify this user. The credentials passed to the callback can be saved and used to authenticate the same user in future connections. #### Parameters -| Name | Type | -| :--------- | :----------------------------------------------------------------------- | -| `callback` | (`token`: `string`, `identity`: [`Identity`](#class-identity)) => `void` | +| Name | Type | +|:-----------|:-----------------------------------------------------------------------------------------------------------------| +| `callback` | (`token`: `string`, `identity`: [`Identity`](#class-identity), `address`: [`Address`](#class-address)) => `void` | #### Example ```ts -spacetimeDBClient.onConnect((token, identity) => { - console.log("Connected to SpacetimeDB"); - console.log("Token", token); - console.log("Identity", identity); +spacetimeDBClient.onConnect((token, identity, address) => { + console.log("Connected to SpacetimeDB"); + console.log("Token", token); + console.log("Identity", identity); + console.log("Address", address); }); ``` @@ -334,7 +336,7 @@ spacetimeDBClient.onError((...args: any[]) => { ### Class `Identity` -A unique public identifier for a client connected to a database. +A unique public identifier for a user of a database. Defined in [spacetimedb-sdk.identity](https://github.com/clockworklabs/spacetimedb-typescript-sdk/blob/main/src/identity.ts): @@ -415,6 +417,89 @@ Identity.fromString(str: string): Identity [`Identity`](#class-identity) +### Class `Address` + +An opaque identifier for a client connection to a database, intended to differentiate between connections from the same [`Identity`](#type-identity). + +Defined in [spacetimedb-sdk.address](https://github.com/clockworklabs/spacetimedb-typescript-sdk/blob/main/src/address.ts): + +| Constructors | Description | +| ----------------------------------------------- | -------------------------------------------- | +| [`Address.constructor`](#address-constructor) | Creates a new `Address`. | +| Methods | | +| [`Address.isEqual`](#address-isequal) | Compare two identities for equality. | +| [`Address.toHexString`](#address-tohexstring) | Print the address as a hexadecimal string. | +| Static methods | | +| [`Address.fromString`](#address-fromstring) | Parse an Address from a hexadecimal string. | + +## Constructors + +### `Address` constructor + +```ts +new Address(data: Uint8Array) +``` + +#### Parameters + +| Name | Type | +| :----- | :----------- | +| `data` | `Uint8Array` | + +## Methods + +### `Address` isEqual + +Compare two addresses for equality. + +```ts +isEqual(other: Address): boolean +``` + +#### Parameters + +| Name | Type | +| :------ | :---------------------------- | +| `other` | [`Address`](#class-address) | + +#### Returns + +`boolean` + +___ + +### `Address` toHexString + +Print an `Address` as a hexadecimal string. + +```ts +toHexString(): string +``` + +#### Returns + +`string` + +___ + +### `Address` fromString + +Static method; parse an Address from a hexadecimal string. + +```ts +Address.fromString(str: string): Address +``` + +#### Parameters + +| Name | Type | +| :---- | :------- | +| `str` | `string` | + +#### Returns + +[`Address`](#class-address) + ### Class `{Table}` For each table defined by a module, `spacetime generate` generates a `class` in the `module_bindings` folder whose name is that table's name converted to `PascalCase`. @@ -475,7 +560,7 @@ var spacetimeDBClient = new SpacetimeDBClient( "database_name" ); -spacetimeDBClient.onConnect((token, identity) => { +spacetimeDBClient.onConnect((token, identity, address) => { spacetimeDBClient.subscribe(["SELECT * FROM Person"]); setTimeout(() => { @@ -506,7 +591,7 @@ var spacetimeDBClient = new SpacetimeDBClient( "database_name" ); -spacetimeDBClient.onConnect((token, identity) => { +spacetimeDBClient.onConnect((token, identity, address) => { spacetimeDBClient.subscribe(["SELECT * FROM Person"]); setTimeout(() => { @@ -545,7 +630,7 @@ var spacetimeDBClient = new SpacetimeDBClient( "database_name" ); -spacetimeDBClient.onConnect((token, identity) => { +spacetimeDBClient.onConnect((token, identity, address) => { spacetimeDBClient.subscribe(["SELECT * FROM Person"]); setTimeout(() => { @@ -613,7 +698,7 @@ var spacetimeDBClient = new SpacetimeDBClient( "ws://localhost:3000", "database_name" ); -spacetimeDBClient.onConnect((token, identity) => { +spacetimeDBClient.onConnect((token, identity, address) => { spacetimeDBClient.subscribe(["SELECT * FROM Person"]); }); @@ -667,7 +752,7 @@ var spacetimeDBClient = new SpacetimeDBClient( "ws://localhost:3000", "database_name" ); -spacetimeDBClient.onConnect((token, identity) => { +spacetimeDBClient.onConnect((token, identity, address) => { spacetimeDBClient.subscribe(["SELECT * FROM Person"]); }); @@ -715,7 +800,7 @@ var spacetimeDBClient = new SpacetimeDBClient( "ws://localhost:3000", "database_name" ); -spacetimeDBClient.onConnect((token, identity) => { +spacetimeDBClient.onConnect((token, identity, address) => { spacetimeDBClient.subscribe(["SELECT * FROM Person"]); }); diff --git a/docs/Client SDK Languages/Typescript/index.md b/docs/Client SDK Languages/Typescript/index.md index ae893af5..8baed6fb 100644 --- a/docs/Client SDK Languages/Typescript/index.md +++ b/docs/Client SDK Languages/Typescript/index.md @@ -170,7 +170,7 @@ We need to import these types into our `client/src/App.tsx`. While we are at it, > There is a known issue where if you do not use every type in your file, it will not pull them into the published build. To fix this, we are using `console.log` to force them to get pulled in. ```typescript -import { SpacetimeDBClient, Identity } from "@clockworklabs/spacetimedb-sdk"; +import { SpacetimeDBClient, Identity, Address } from "@clockworklabs/spacetimedb-sdk"; import Message from "./module_bindings/message"; import User from "./module_bindings/user"; @@ -224,7 +224,7 @@ We will add callbacks for each of these items in the following sections. All of On connect SpacetimeDB will provide us with our client credentials. -Each client has a credentials which consists of two parts: +Each user has a set of credentials, which consists of two parts: - An `Identity`, a unique public identifier. We're using these to identify `User` rows. - A `Token`, a private key which SpacetimeDB uses to authenticate the client. @@ -233,12 +233,14 @@ These credentials are generated by SpacetimeDB each time a new client connects, We want to store our local client identity in a stateful variable and also save our `token` to local storage for future connections. +Each client also has an `Address`, which modules can use to distinguish multiple concurrent connections by the same `Identity`. We don't need to know our `Address`, so we'll ignore that argument. + Once we are connected, we can send our subscription to the SpacetimeDB module. SpacetimeDB is set up so that each client subscribes via SQL queries to some subset of the database, and is notified about changes only to that subset. For complex apps with large databases, judicious subscriptions can save each client significant network bandwidth, memory and computation compared. For example, in [BitCraft](https://bitcraftonline.com), each player's client subscribes only to the entities in the "chunk" of the world where that player currently resides, rather than the entire game world. Our app is much simpler than BitCraft, so we'll just subscribe to the whole database. To the body of `App`, add: ```typescript -client.current.onConnect((token, identity) => { +client.current.onConnect((token, identity, address) => { console.log("Connected to SpacetimeDB"); local_identity.current = identity; diff --git a/docs/Overview/index.md b/docs/Overview/index.md index 2464e6e3..0e1a6394 100644 --- a/docs/Overview/index.md +++ b/docs/Overview/index.md @@ -44,9 +44,9 @@ SpacetimeDB syncs client and server state for you so that you can just write you ## Identities -An important concept in SpacetimeDB is that of an `Identity`. An `Identity` represents who someone is. It is a unique identifier that is used to authenticate and authorize access to the database. Importantly, while it represents who someone is, does NOT represent what they can do. Your application's logic will determine what a given identity is able to do by allowing or disallowing a transaction based on the `Identity`. +A SpacetimeDB `Identity` is a unique identifier that is used to authenticate and authorize access to the database. Importantly, while it represents who someone is, does NOT represent what they can do. Your application's logic will determine what a given identity is able to do by allowing or disallowing a transaction based on the caller's `Identity` along with any module-defined data and logic. -SpacetimeDB associates each client with a 256-bit (32-byte) integer `Identity`. These identities are usually formatted as 64-digit hexadecimal strings. Identities are public information, and applications can use them to identify users. Identities are a global resource, so a user can use the same identity with multiple applications, so long as they're hosted by the same SpacetimeDB instance. +SpacetimeDB associates each user with a 256-bit (32-byte) integer `Identity`. These identities are usually formatted as 64-digit hexadecimal strings. Identities are public information, and applications can use them to identify users. Identities are a global resource, so a user can use the same identity with multiple applications, so long as they're hosted by the same SpacetimeDB instance. Each identity has a corresponding authentication token. The authentication token is private, and should never be shared with anyone. Specifically, authentication tokens are [JSON Web Tokens](https://datatracker.ietf.org/doc/html/rfc7519) signed by a secret unique to the SpacetimeDB instance. @@ -54,6 +54,14 @@ Additionally, each database has an owner `Identity`. Many database maintenance o SpacetimeDB provides tools in the CLI and the [client SDKs](/docs/client-languages/client-sdk-overview) for managing credentials. +## Addresses + +A SpacetimeDB `Address` is an opaque identifier for a database or a client connection. An `Address` is a 128-bit integer, usually formatted as a 32-character (16-byte) hexadecimal string. + +Each SpacetimeDB database has an `Address`, generated by the SpacetimeDB host, which can be used to connect to the database or to request information about it. Databases may also have human-readable names, which are mapped to addresses internally. + +Each client connection has an `Address`. These addresses are opaque, and do not correspond to any metadata about the client. They are notably not IP addresses or device identifiers. A client connection can be uniquely identified by its `(Identity, Address)` pair, but client addresses may not be globally unique; it is possible for multiple connections with the same `Address` but different identities to co-exist. SpacetimeDB modules should treat `Identity` as differentiating users, and `Address` as differentiating connections by the same user. + ## Language Support ### Server-side Libraries diff --git a/docs/Server Module Languages/C#/ModuleReference.md b/docs/Server Module Languages/C#/ModuleReference.md index 305ea211..d655ea6d 100644 --- a/docs/Server Module Languages/C#/ModuleReference.md +++ b/docs/Server Module Languages/C#/ModuleReference.md @@ -116,7 +116,9 @@ The following types are supported out of the box and can be stored in the databa And a couple of special custom types: - `SpacetimeDB.SATS.Unit` - semantically equivalent to an empty struct, sometimes useful in generic contexts where C# doesn't permit `void`. -- `Identity` (`SpacetimeDB.Runtime.Identity`) - a unique identifier for each connected client; internally a byte blob but can be printed, hashed and compared for equality. +- `Identity` (`SpacetimeDB.Runtime.Identity`) - a unique identifier for each user; internally a byte blob but can be printed, hashed and compared for equality. +- `Address` (`SpacetimeDB.Runtime.Address`) - an identifier which disamgibuates connections by the same `Identity`; internally a byte blob but can be printed, hashed and compared for equality. + #### Custom types @@ -245,13 +247,14 @@ public static void Add(string name, int age) } ``` -If a reducer has an argument with a type `DbEventArgs` (`SpacetimeDB.Runtime.DbEventArgs`), it will be provided with event details such as the sender identity (`SpacetimeDB.Runtime.Identity`) and the time (`DateTimeOffset`) of the invocation: +If a reducer has an argument with a type `DbEventArgs` (`SpacetimeDB.Runtime.DbEventArgs`), it will be provided with event details such as the sender identity (`SpacetimeDB.Runtime.Identity`), sender address (`SpacetimeDB.Runtime.Address?`) and the time (`DateTimeOffset`) of the invocation: ```csharp [SpacetimeDB.Reducer] public static void PrintInfo(DbEventArgs e) { - Log($"Sender: {e.Sender}"); + Log($"Sender identity: {e.Sender}"); + Log($"Sender address: {e.Address}"); Log($"Time: {e.Time}"); } ``` diff --git a/docs/Server Module Languages/C#/index.md b/docs/Server Module Languages/C#/index.md index e849002f..6893a089 100644 --- a/docs/Server Module Languages/C#/index.md +++ b/docs/Server Module Languages/C#/index.md @@ -61,7 +61,8 @@ static partial class Module To get our chat server running, we'll need to store two kinds of data: information about each user, and records of all the messages that have been sent. -For each `User`, we'll store the `Identity` of their client connection, an optional name they can set to identify themselves to other users, and whether they're online or not. We'll designate the `Identity` as our primary key, which enforces that it must be unique, indexes it for faster lookup, and allows clients to track updates. +For each `User`, we'll store their `Identity`, an optional name they can set to identify themselves to other users, and whether they're online or not. We'll designate the `Identity` as our primary key, which enforces that it must be unique, indexes it for faster lookup, and allows clients to track updates. + In `server/Lib.cs`, add the definition of the table `User` to the `Module` class: @@ -94,7 +95,7 @@ In `server/Lib.cs`, add the definition of the table `Message` to the `Module` cl We want to allow users to set their names, because `Identity` is not a terribly user-friendly identifier. To that effect, we define a reducer `SetName` which clients can invoke to set their `User.Name`. It will validate the caller's chosen name, using a function `ValidateName` which we'll define next, then look up the `User` record for the caller and update it to store the validated name. If the name fails the validation, the reducer will fail. -Each reducer may accept as its first argument a `DbEventArgs`, which includes the `Identity` of the client that called the reducer, and the `Timestamp` when it was invoked. For now, we only need the `Identity`, `dbEvent.Sender`. ++Each reducer may accept as its first argument a `DbEventArgs`, which includes the `Identity` and `Address` of the client that called the reducer, and the `Timestamp` when it was invoked. For now, we only need the `Identity`, `dbEvent.Sender`. It's also possible to call `SetName` via the SpacetimeDB CLI's `spacetime call` command without a connection, in which case no `User` record will exist for the caller. We'll return an error in this case, but you could alter the reducer to insert a `User` row for the module owner. You'll have to decide whether the module owner is always online or always offline, though. diff --git a/docs/Server Module Languages/Rust/index.md b/docs/Server Module Languages/Rust/index.md index ed59d8dd..9f0a6636 100644 --- a/docs/Server Module Languages/Rust/index.md +++ b/docs/Server Module Languages/Rust/index.md @@ -55,14 +55,14 @@ From `spacetimedb`, we import: - `spacetimedb`, an attribute macro we'll use to define tables and reducers. - `ReducerContext`, a special argument passed to each reducer. -- `Identity`, a unique identifier for each connected client. +- `Identity`, a unique identifier for each user. - `Timestamp`, a point in time. Specifically, an unsigned 64-bit count of milliseconds since the UNIX epoch. ## Define tables To get our chat server running, we'll need to store two kinds of data: information about each user, and records of all the messages that have been sent. -For each `User`, we'll store the `Identity` of their client connection, an optional name they can set to identify themselves to other users, and whether they're online or not. We'll designate the `Identity` as our primary key, which enforces that it must be unique, indexes it for faster lookup, and allows clients to track updates. +For each `User`, we'll store their `Identity`, an optional name they can set to identify themselves to other users, and whether they're online or not. We'll designate the `Identity` as our primary key, which enforces that it must be unique, indexes it for faster lookup, and allows clients to track updates. To `server/src/lib.rs`, add the definition of the table `User`: @@ -93,7 +93,7 @@ pub struct Message { We want to allow users to set their names, because `Identity` is not a terribly user-friendly identifier. To that effect, we define a reducer `set_name` which clients can invoke to set their `User.name`. It will validate the caller's chosen name, using a function `validate_name` which we'll define next, then look up the `User` record for the caller and update it to store the validated name. If the name fails the validation, the reducer will fail. -Each reducer may accept as its first argument a `ReducerContext`, which includes the `Identity` of the client that called the reducer, and the `Timestamp` when it was invoked. For now, we only need the `Identity`, `ctx.sender`. +Each reducer may accept as its first argument a `ReducerContext`, which includes the `Identity` and `Address` of the client that called the reducer, and the `Timestamp` when it was invoked. For now, we only need the `Identity`, `ctx.sender`. It's also possible to call `set_name` via the SpacetimeDB CLI's `spacetime call` command without a connection, in which case no `User` record will exist for the caller. We'll return an error in this case, but you could alter the reducer to insert a `User` row for the module owner. You'll have to decide whether the module owner is always online or always offline, though. diff --git a/docs/Unity Tutorial/Part 1 - Basic Multiplayer.md b/docs/Unity Tutorial/Part 1 - Basic Multiplayer.md index 92f1a04c..4d51790f 100644 --- a/docs/Unity Tutorial/Part 1 - Basic Multiplayer.md +++ b/docs/Unity Tutorial/Part 1 - Basic Multiplayer.md @@ -345,14 +345,14 @@ We use the `connect` and `disconnect` reducers to update the logged in state of ```rust #[spacetimedb(connect)] -pub fn identity_connected(ctx: ReducerContext) { +pub fn client_connected(ctx: ReducerContext) { // called when the client connects, we update the logged_in state to true update_player_login_state(ctx, true); } #[spacetimedb(disconnect)] -pub fn identity_disconnected(ctx: ReducerContext) { +pub fn client_disconnected(ctx: ReducerContext) { // Called when the client disconnects, we update the logged_in state to false update_player_login_state(ctx, false); } @@ -545,7 +545,7 @@ The "local client cache" is a client-side view of the database, defined by the s // called when we receive the client identity from SpacetimeDB - SpacetimeDBClient.instance.onIdentityReceived += (token, identity) => { + SpacetimeDBClient.instance.onIdentityReceived += (token, identity, address) => { AuthToken.SaveToken(token); local_identity = identity; }; From f8a7b2761fca91d9ba4ae331f4c078455e8c51cf Mon Sep 17 00:00:00 2001 From: John Detter <4099508+jdetter@users.noreply.github.com> Date: Thu, 12 Oct 2023 21:01:39 -0500 Subject: [PATCH 009/164] This was an error in applying a patch (#6) Co-authored-by: John Detter --- docs/Server Module Languages/C#/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/Server Module Languages/C#/index.md b/docs/Server Module Languages/C#/index.md index 6893a089..473a8ac6 100644 --- a/docs/Server Module Languages/C#/index.md +++ b/docs/Server Module Languages/C#/index.md @@ -95,7 +95,7 @@ In `server/Lib.cs`, add the definition of the table `Message` to the `Module` cl We want to allow users to set their names, because `Identity` is not a terribly user-friendly identifier. To that effect, we define a reducer `SetName` which clients can invoke to set their `User.Name`. It will validate the caller's chosen name, using a function `ValidateName` which we'll define next, then look up the `User` record for the caller and update it to store the validated name. If the name fails the validation, the reducer will fail. -+Each reducer may accept as its first argument a `DbEventArgs`, which includes the `Identity` and `Address` of the client that called the reducer, and the `Timestamp` when it was invoked. For now, we only need the `Identity`, `dbEvent.Sender`. +Each reducer may accept as its first argument a `DbEventArgs`, which includes the `Identity` and `Address` of the client that called the reducer, and the `Timestamp` when it was invoked. For now, we only need the `Identity`, `dbEvent.Sender`. It's also possible to call `SetName` via the SpacetimeDB CLI's `spacetime call` command without a connection, in which case no `User` record will exist for the caller. We'll return an error in this case, but you could alter the reducer to insert a `User` row for the module owner. You'll have to decide whether the module owner is always online or always offline, though. From 91ace6a6a8ed7213b60d2456eb1be8d3c0e2d2f1 Mon Sep 17 00:00:00 2001 From: John Detter <4099508+jdetter@users.noreply.github.com> Date: Sat, 14 Oct 2023 03:23:44 -0500 Subject: [PATCH 010/164] Unity Tutorial Updates (#7) * Updated server side module * More simplification * More progress * More updates * Ready to start testing again * Got through tutorial with some issues, going again * Added warnings * Small fix, this is ready to be released --------- Co-authored-by: John Detter --- .../Part 1 - Basic Multiplayer.md | 861 ++++++++---------- .../Part 2 - Resources And Scheduling.md | 2 + docs/Unity Tutorial/Part 3 - BitCraft Mini.md | 2 + 3 files changed, 405 insertions(+), 460 deletions(-) diff --git a/docs/Unity Tutorial/Part 1 - Basic Multiplayer.md b/docs/Unity Tutorial/Part 1 - Basic Multiplayer.md index 4d51790f..915fd444 100644 --- a/docs/Unity Tutorial/Part 1 - Basic Multiplayer.md +++ b/docs/Unity Tutorial/Part 1 - Basic Multiplayer.md @@ -2,27 +2,42 @@ ![UnityTutorial-HeroImage](/images/unity-tutorial/UnityTutorial-HeroImage.JPG) +Need help with the tutorial? [Join our Discord server](https://discord.gg/spacetimedb)! + The objective of this tutorial is to help you become acquainted with the basic features of SpacetimeDB. By the end of this tutorial you should have a basic understanding of what SpacetimeDB offers for developers making multiplayer games. It assumes that you have a basic understanding of the Unity Editor, using a command line terminal, and coding. +In this tutorial we'll be giving you some CLI commands to execute. If you are using Windows we recommend using Git Bash or powershell. If you're on mac we recommend you use the Terminal application. If you encouter issues with any of the commands in this guide, please reach out to us through our discord server and we would be happy to help assist you. + +This tutorial has been tested against UnityEngine version 2022.3.4f1. This tutorial may work on newer versions as well. + +## Prepare Project Structure + +This project is separated into two sub-projects, one for the server (module) code and one for the client code. First we'll create the main directory, this directory name doesn't matter but we'll give you an example: + +```bash +mkdir SpacetimeDBUnityTutorial +cd SpacetimeDBUnityTutorial +``` + +In the following sections we'll be adding a client directory and a server directory, which will contain the client files and the module (server) files respectively. We'll start by populating the client directory. + ## Setting up the Tutorial Unity Project -In this section, we will guide you through the process of setting up the Unity Project that will serve as the starting point for our tutorial. By the end of this section, you will have a basic Unity project ready to integrate SpacetimeDB functionality. +In this section, we will guide you through the process of setting up a Unity Project that will serve as the starting point for our tutorial. By the end of this section, you will have a basic Unity project and be ready to implement the server functionality. ### Step 1: Create a Blank Unity Project -1. Open Unity and create a new project by selecting "New" from the Unity Hub or going to **File -> New Project**. +Open Unity and create a new project by selecting "New" from the Unity Hub or going to **File -> New Project**. ![UnityHub-NewProject](/images/unity-tutorial/UnityHub-NewProject.JPG) -2. Choose a suitable project name and location. For this tutorial, we recommend creating an empty folder for your tutorial project and selecting that as the project location, with the project being named "Client". - -This allows you to have a single subfolder that contains both the Unity project in a folder called "Client" and the SpacetimeDB server module in a folder called "Server" which we will create later in this tutorial. +For Project Name use `client`. For Project Location make sure that you use your `SpacetimeDBUnityTutorial` directory. This is the directory that we created in a previous step. -Ensure that you have selected the **3D (URP)** template for this project. +**Important: Ensure that you have selected the 3D (URP) template for this project.** If you forget to do this then Unity won't be able to properly render the materials in the scene! ![UnityHub-3DURP](/images/unity-tutorial/UnityHub-3DURP.JPG) -3. Click "Create" to generate the blank project. +Click "Create" to generate the blank project. ### Step 2: Adding Required Packages @@ -50,6 +65,7 @@ In this step, we will import the provided Unity tutorial package that contains t 3. Browse and select the downloaded tutorial package file. 4. Unity will prompt you with an import settings dialog. Ensure that all the files are selected and click "Import" to import the package into your project. +5. At this point in the project, you shouldn't have any errors. ![Unity-ImportCustomPackage2](/images/unity-tutorial/Unity-ImportCustomPackage2.JPG) @@ -77,221 +93,145 @@ Congratulations! You have successfully set up the basic single-player game proje ## Writing our SpacetimeDB Server Module -### Step 1: Create the Module - -1. It is important that you already have SpacetimeDB [installed](/install). - -2. Run the SpacetimeDB standalone using the installed CLI. In your terminal or command window, run the following command: - -```bash -spacetime start -``` +At this point you should have the single player game working. In your CLI, your current working directory should be within your `SpacetimeDBUnityTutorial` directory that we created in a previous step. -3. Make sure your CLI is pointed to your local instance of SpacetimeDB. You can do this by running the following command: +### Create the Module -```bash -spacetime server set http://localhost:3000 -``` +1. It is important that you already have the SpacetimeDB CLI tool [installed](/install). -4. Open a new command prompt or terminal and navigate to the folder where your Unity project is located using the cd command. For example: +2. Run SpacetimeDB locally using the installed CLI. In a **new** terminal or command window, run the following command: ```bash -cd path/to/tutorial_project_folder +spacetime start ``` -5. Run the following command to initialize the SpacetimeDB server project with Rust as the language: +3. Run the following command to initialize the SpacetimeDB server project with Rust as the language: ```bash -spacetime init --lang=rust ./Server +spacetime init --lang=rust server ``` -This command creates a new folder named "Server" within your Unity project directory and sets up the SpacetimeDB server project with Rust as the programming language. - -### Step 2: SpacetimeDB Tables - -1. Using your favorite code editor (we recommend VS Code) open the newly created lib.rs file in the Server folder. -2. Erase everything in the file as we are going to be writing our module from scratch. - ---- +This command creates a new folder named "server" within your Unity project directory and sets up the SpacetimeDB server project with Rust as the programming language. -**Understanding ECS** +### Understanding Entity Component Systems -ECS is a game development architecture that separates game objects into components for better flexibility and performance. You can read more about the ECS design pattern [here](https://en.wikipedia.org/wiki/Entity_component_system). +Entity Component System (ECS) is a game development architecture that separates game objects into components for better flexibility and performance. You can read more about the ECS design pattern [here](https://en.wikipedia.org/wiki/Entity_component_system). We chose ECS for this example project because it promotes scalability, modularity, and efficient data management, making it ideal for building multiplayer games with SpacetimeDB. ---- - -3. Add the following code to lib.rs. +### SpacetimeDB Tables -We are going to start by adding the global `Config` table. Right now it only contains the "message of the day" but it can be extended to store other configuration variables. +In this section we'll be making some edits to the file `server/src/lib.rs`. We recommend you open up this file in an IDE like VSCode or RustRover. -You'll notice we have a custom `spacetimedb(table)` attribute that tells SpacetimeDB that this is a SpacetimeDB table. SpacetimeDB automatically generates several functions for us for inserting, updating and querying the table created as a result of this attribute. +**Important: Open the `server/src/lib.rs` file and delete its contents. We will be writing it from scratch here.** -The `primarykey` attribute on the version not only ensures uniqueness, preventing duplicate values for the column, but also guides the client to determine whether an operation should be an insert or an update. NOTE: Our `version` column in this `Config` table is always 0. This is a trick we use to store -global variables that can be accessed from anywhere. +First we need to add some imports at the top of the file. -We also use the built in rust `derive(Clone)` function to automatically generate a clone function for this struct that we use when updating the row. +**Copy and paste into lib.rs:** ```rust -use spacetimedb::{spacetimedb, Identity, SpacetimeType, Timestamp, ReducerContext}; +use spacetimedb::{spacetimedb, Identity, SpacetimeType, ReducerContext}; use log; +``` + +Then we are going to start by adding the global `Config` table. Right now it only contains the "message of the day" but it can be extended to store other configuration variables. This also uses a couple of macros, like `#[spacetimedb(table)]` which you can learn more about in our rust module reference. Simply put, this just tells SpacetimeDB to create a table which uses this struct as the schema for the table. +**Append to the bottom of lib.rs:** + +```rust +// We're using this table as a singleton, so there should typically only be one element where the version is 0. #[spacetimedb(table)] #[derive(Clone)] pub struct Config { - // Config is a global table with a single row. This table will be used to - // store configuration or global variables - #[primarykey] - // always 0 - // having a table with a primarykey field which is always zero is a way to store singleton global state pub version: u32, - pub message_of_the_day: String, } - ``` -The next few tables are all components in the ECS system for our spawnable entities. Spawnable Entities are any objects in the game simulation that can have a world location. In this tutorial we will have only one type of spawnable entity, the Player. +Next we're going to define a new `SpacetimeType` called `StdbVector3` which we're going to use to store positions. The difference between a `#[derive(SpacetimeType)]` and a `#[spacetimedb(table)]` is that tables actually store data, whereas the deriving `SpacetimeType` just allows you to create a new column of that type in a SpacetimeDB table. So therefore, `StdbVector3` is not itself a table. -The first component is the `SpawnableEntityComponent` that allows us to access any spawnable entity in the world by its entity_id. The `autoinc` attribute designates an auto-incrementing column in SpacetimeDB, generating sequential values for new entries. When inserting 0 with this attribute, it gets replaced by the next value in the sequence. +**Append to the bottom of lib.rs:** ```rust -#[spacetimedb(table)] -pub struct SpawnableEntityComponent { - // All entities that can be spawned in the world will have this component. - // This allows us to find all objects in the world by iterating through - // this table. It also ensures that all world objects have a unique - // entity_id. +// This allows us to store 3D points in tables. +#[derive(SpacetimeType, Clone)] +pub struct StdbVector3 { + pub x: f32, + pub y: f32, + pub z: f32, +} +``` +Now we're going to create a table which actually uses the `StdbVector3` that we just defined. The `EntityComponent` is associated with all entities in the world, including players. + +```rust +// This stores information related to all entities in our game. In this tutorial +// all entities must at least have an entity_id, a position, a direction and they +// must specify whether or not they are moving. +#[spacetimedb(table)] +#[derive(Clone)] +pub struct EntityComponent { #[primarykey] + // The autoinc macro here just means every time we insert into this table + // we will receive a new row where this value will be increased by one. This + // allows us to easily get rows where `entity_id` is unique. #[autoinc] pub entity_id: u64, + pub position: StdbVector3, + pub direction: f32, + pub moving: bool, } ``` -The `PlayerComponent` table connects this entity to a SpacetimeDB identity - a user's "public key." In the context of this tutorial, each user is permitted to have just one Player entity. To guarantee this, we apply the `unique` attribute to the `owner_id` column. If a uniqueness constraint is required on a column aside from the `primarykey`, we make use of the `unique` attribute. This mechanism makes certain that no duplicate values exist within the designated column. +Next we will define the `PlayerComponent` table. The `PlayerComponent` table is used to store information related to players. Each player will have a row in this table, and will also have a row in the `EntityComponent` table with a matching `entity_id`. You'll see how this works later in the `create_player` reducer. + +**Append to the bottom of lib.rs:** ```rust +// All players have this component and it associates an entity with the user's +// Identity. It also stores their username and whether or not they're logged in. #[derive(Clone)] #[spacetimedb(table)] pub struct PlayerComponent { - // All players have this component and it associates the spawnable entity - // with the user's identity. It also stores their username. - + // An entity_id that matches an entity_id in the `EntityComponent` table. #[primarykey] pub entity_id: u64, + // The user's identity, which is unique to each player #[unique] pub owner_id: Identity, - - // username is provided to the create_player reducer pub username: String, - // this value is updated when the user logs in and out pub logged_in: bool, } ``` -The next component, `MobileLocationComponent`, is used to store the last known location and movement direction for spawnable entities that can move smoothly through the world. - -Using the `derive(SpacetimeType)` attribute, we define a custom SpacetimeType, StdbVector2, that stores 2D positions. Marking it a `SpacetimeType` allows it to be used in SpacetimeDB columns and reducer calls. - -We are also making use of the SpacetimeDB `Timestamp` type for the `move_start_timestamp` column. Timestamps represent the elapsed time since the Unix epoch (January 1, 1970, at 00:00:00 UTC) and are not dependent on any specific timezone. - -```rust -#[derive(SpacetimeType, Clone)] -pub struct StdbVector2 { - // A spacetime type which can be used in tables and reducers to represent - // a 2d position. - pub x: f32, - pub z: f32, -} - -impl StdbVector2 { - // this allows us to use StdbVector2::ZERO in reducers - pub const ZERO: StdbVector2 = StdbVector2 { x: 0.0, z: 0.0 }; -} - -#[spacetimedb(table)] -#[derive(Clone)] -pub struct MobileLocationComponent { - // This component will be created for all world objects that can move - // smoothly throughout the world. It keeps track of the position the last - // time the component was updated and the direction the mobile object is - // currently moving. - - #[primarykey] - pub entity_id: u64, - - // The last known location of this entity - pub location: StdbVector2, - // Movement direction, {0,0} if not moving at all. - pub direction: StdbVector2, - // Timestamp when movement started. Timestamp::UNIX_EPOCH if not moving. - pub move_start_timestamp: Timestamp, -} -``` - -Next we write our very first reducer, `create_player`. This reducer is called by the client after the user enters a username. - ---- - -**SpacetimeDB Reducers** - -"Reducer" is a term coined by SpacetimeDB that "reduces" a single function call into one or more database updates performed within a single transaction. Reducers can be called remotely using a client SDK or they can be scheduled to be called at some future time from another reducer call. - ---- - -The first argument to all reducers is the `ReducerContext`. This struct contains: `sender` the identity of the user that called the reducer and `timestamp` which is the `Timestamp` when the reducer was called. - -Before we begin creating the components for the player entity, we pass the sender identity to the auto-generated function `filter_by_owner_id` to see if there is already a player entity associated with this user's identity. Because the `owner_id` column is unique, the `filter_by_owner_id` function returns a `Option` that we can check to see if a matching row exists. - ---- - -**Rust Options** - -Rust programs use Option in a similar way to how C#/Unity programs use nullable types. Rust's Option is an enumeration type that represents the possibility of a value being either present (Some) or absent (None), providing a way to handle optional values and avoid null-related errors. For more information, refer to the official Rust documentation: [Rust Option](https://doc.rust-lang.org/std/option/). - ---- - -The first component we create and insert, `SpawnableEntityComponent`, automatically increments the `entity_id` property. When we use the insert function, it returns a result that includes the newly generated `entity_id`. We will utilize this generated `entity_id` in all other components associated with the player entity. +Next we write our very first reducer, `create_player`. From the client we will call this reducer when we create a new player: -Note the Result that the insert function returns can fail with a "DuplicateRow" error if we insert two rows with the same unique column value. In this example we just use the rust `expect` function to check for this. - ---- - -**Rust Results** - -A Result is like an Option where the None is augmented with a value describing the error. Rust programs use Result and return Err in situations where Unity/C# programs would signal an exception. For more information, refer to the official Rust documentation: [Rust Result](https://doc.rust-lang.org/std/result/). - ---- - -We then create and insert our `PlayerComponent` and `MobileLocationComponent` using the same `entity_id`. - -We use the log crate to write to the module log. This can be viewed using the CLI command `spacetime logs `. If you add the -f switch it will continuously tail the log. +**Append to the bottom of lib.rs:** ```rust +// This reducer is called when the user logs in for the first time and +// enters a username #[spacetimedb(reducer)] pub fn create_player(ctx: ReducerContext, username: String) -> Result<(), String> { - // This reducer is called when the user logs in for the first time and - // enters a username - + // Get the Identity of the client who called this reducer let owner_id = ctx.sender; - // We check to see if there is already a PlayerComponent with this identity. - // this should never happen because the client only calls it if no player - // is found. + + // Make sure we don't already have a player with this identity if PlayerComponent::filter_by_owner_id(&owner_id).is_some() { log::info!("Player already exists"); return Err("Player already exists".to_string()); } - // Next we create the SpawnableEntityComponent. The entity_id for this - // component automatically increments and we get it back from the result - // of the insert call and use it for all components. + // Create a new entity for this player and get a unique `entity_id`. + let entity_id = EntityComponent::insert(EntityComponent + { + entity_id: 0, + position: StdbVector3 { x: 0.0, y: 0.0, z: 0.0 }, + direction: 0.0, + moving: false, + }).expect("Failed to create a unique PlayerComponent.").entity_id; - let entity_id = SpawnableEntityComponent::insert(SpawnableEntityComponent { entity_id: 0 }) - .expect("Failed to create player spawnable entity component.") - .entity_id; // The PlayerComponent uses the same entity_id and stores the identity of // the owner, username, and whether or not they are logged in. PlayerComponent::insert(PlayerComponent { @@ -299,18 +239,7 @@ pub fn create_player(ctx: ReducerContext, username: String) -> Result<(), String owner_id, username: username.clone(), logged_in: true, - }) - .expect("Failed to insert player component."); - // The MobileLocationComponent is used to calculate the current position - // of an entity that can move smoothly in the world. We are using 2d - // positions and the client will use the terrain height for the y value. - MobileLocationComponent::insert(MobileLocationComponent { - entity_id, - location: StdbVector2::ZERO, - direction: StdbVector2::ZERO, - move_start_timestamp: Timestamp::UNIX_EPOCH, - }) - .expect("Failed to insert player mobile entity component."); + }).expect("Failed to insert player component."); log::info!("Player created: {}({})", username, entity_id); @@ -318,32 +247,41 @@ pub fn create_player(ctx: ReducerContext, username: String) -> Result<(), String } ``` -SpacetimeDB also gives you the ability to define custom reducers that automatically trigger when certain events occur. +--- + +**SpacetimeDB Reducers** + +"Reducer" is a term coined by Clockwork Labs that refers to a function which when executed "reduces" into a list of inserts and deletes, which is then packed into a single database transaction. Reducers can be called remotely using the CLI or a client SDK or they can be scheduled to be called at some future time from another reducer call. + +--- -- `init` - Called the very first time you publish your module and anytime you clear the database. We'll learn about publishing a little later. -- `connect` - Called when a user connects to the SpacetimeDB module. Their identity can be found in the `sender` member of the `ReducerContext`. +SpacetimeDB gives you the ability to define custom reducers that automatically trigger when certain events occur. + +- `init` - Called the first time you publish your module and anytime you clear the database. We'll learn about publishing later. +- `connect` - Called when a user connects to the SpacetimeDB module. Their identity can be found in the `sender` value of the `ReducerContext`. - `disconnect` - Called when a user disconnects from the SpacetimeDB module. Next we are going to write a custom `init` reducer that inserts the default message of the day into our `Config` table. The `Config` table only ever contains a single row with version 0, which we retrieve using `Config::filter_by_version(0)`. +**Append to the bottom of lib.rs:** + ```rust +// Called when the module is initially published #[spacetimedb(init)] pub fn init() { - // Called when the module is initially published - - - // Create our global config table. Config::insert(Config { version: 0, message_of_the_day: "Hello, World!".to_string(), - }) - .expect("Failed to insert config."); + }).expect("Failed to insert config."); } ``` We use the `connect` and `disconnect` reducers to update the logged in state of the player. The `update_player_login_state` helper function looks up the `PlayerComponent` row using the user's identity and if it exists, it updates the `logged_in` variable and calls the auto-generated `update` function on `PlayerComponent` to update the row. +**Append to the bottom of lib.rs:** + ```rust +// Called when the client connects, we update the logged_in state to true #[spacetimedb(connect)] pub fn client_connected(ctx: ReducerContext) { // called when the client connects, we update the logged_in state to true @@ -351,109 +289,82 @@ pub fn client_connected(ctx: ReducerContext) { } +// Called when the client disconnects, we update the logged_in state to false #[spacetimedb(disconnect)] pub fn client_disconnected(ctx: ReducerContext) { // Called when the client disconnects, we update the logged_in state to false update_player_login_state(ctx, false); } - +// This helper function gets the PlayerComponent, sets the logged +// in variable and updates the PlayerComponent table row. pub fn update_player_login_state(ctx: ReducerContext, logged_in: bool) { - // This helper function gets the PlayerComponent, sets the logged - // in variable and updates the SpacetimeDB table row. if let Some(player) = PlayerComponent::filter_by_owner_id(&ctx.sender) { - let entity_id = player.entity_id; // We clone the PlayerComponent so we can edit it and pass it back. let mut player = player.clone(); player.logged_in = logged_in; - PlayerComponent::update_by_entity_id(&entity_id, player); + PlayerComponent::update_by_entity_id(&player.entity_id.clone(), player); } } ``` -Our final two reducers handle player movement. In `move_player` we look up the `PlayerComponent` using the user identity. If we don't find one, we return an error because the client should not be sending moves without creating a player entity first. - -Using the `entity_id` in the `PlayerComponent` we retrieved, we can lookup the `MobileLocationComponent` that stores the entity's locations in the world. We update the values passed in from the client and call the auto-generated `update` function. +Our final reducer handles player movement. In `update_player_position` we look up the `PlayerComponent` using the user's Identity. If we don't find one, we return an error because the client should not be sending moves without calling `create_player` first. ---- - -**Server Validation** - -In a fully developed game, the server would typically perform server-side validation on player movements to ensure they comply with game boundaries, rules, and mechanics. This validation, which we omit for simplicity in this tutorial, is essential for maintaining game integrity, preventing cheating, and ensuring a fair gaming experience. Remember to incorporate appropriate server-side validation in your game's development to ensure a secure and fair gameplay environment. +Using the `entity_id` in the `PlayerComponent` we retrieved, we can lookup the `EntityComponent` that stores the entity's locations in the world. We update the values passed in from the client and call the auto-generated `update` function. ---- +**Append to the bottom of lib.rs:** ```rust +// Updates the position of a player. This is also called when the player stops moving. #[spacetimedb(reducer)] -pub fn move_player( +pub fn update_player_position( ctx: ReducerContext, - start: StdbVector2, - direction: StdbVector2, + position: StdbVector3, + direction: f32, + moving: bool, ) -> Result<(), String> { - // Update the MobileLocationComponent with the current movement - // values. The client will call this regularly as the direction of movement - // changes. A fully developed game should validate these moves on the server - // before committing them, but that is beyond the scope of this tutorial. - - let owner_id = ctx.sender; // First, look up the player using the sender identity, then use that - // entity_id to retrieve and update the MobileLocationComponent - if let Some(player) = PlayerComponent::filter_by_owner_id(&owner_id) { - if let Some(mut mobile) = MobileLocationComponent::filter_by_entity_id(&player.entity_id) { - mobile.location = start; - mobile.direction = direction; - mobile.move_start_timestamp = ctx.timestamp; - MobileLocationComponent::update_by_entity_id(&player.entity_id, mobile); - - + // entity_id to retrieve and update the EntityComponent + if let Some(player) = PlayerComponent::filter_by_owner_id(&ctx.sender) { + if let Some(mut entity) = EntityComponent::filter_by_entity_id(&player.entity_id) { + entity.position = position; + entity.direction = direction; + entity.moving = moving; + EntityComponent::update_by_entity_id(&player.entity_id, entity); return Ok(()); } } - - // If we can not find the PlayerComponent for this user something went wrong. - // This should never happen. + // If we can not find the PlayerComponent or EntityComponent for + // this player then something went wrong. return Err("Player not found".to_string()); } +``` +--- -#[spacetimedb(reducer)] -pub fn stop_player(ctx: ReducerContext, location: StdbVector2) -> Result<(), String> { - // Update the MobileLocationComponent when a player comes to a stop. We set - // the location to the current location and the direction to {0,0} - let owner_id = ctx.sender; - if let Some(player) = PlayerComponent::filter_by_owner_id(&owner_id) { - if let Some(mut mobile) = MobileLocationComponent::filter_by_entity_id(&player.entity_id) { - mobile.location = location; - mobile.direction = StdbVector2::ZERO; - mobile.move_start_timestamp = Timestamp::UNIX_EPOCH; - MobileLocationComponent::update_by_entity_id(&player.entity_id, mobile); - +**Server Validation** - return Ok(()); - } - } +In a fully developed game, the server would typically perform server-side validation on player movements to ensure they comply with game boundaries, rules, and mechanics. This validation, which we omit for simplicity in this tutorial, is essential for maintaining game integrity, preventing cheating, and ensuring a fair gaming experience. Remember to incorporate appropriate server-side validation in your game's development to ensure a secure and fair gameplay environment. +--- - return Err("Player not found".to_string()); -} -``` +### Publishing a Module to SpacetimeDB -4. Now that we've written the code for our server module, we need to publish it to SpacetimeDB. This will create the database and call the init reducer. Make sure your domain name is unique. You will get an error if someone has already created a database with that name. In your terminal or command window, run the following commands. +Now that we've written the code for our server module, we need to publish it to SpacetimeDB. This will create the database and call the init reducer. In your terminal or command window, run the following commands. ```bash -cd Server - -spacetime publish -c yourname-bitcraftmini +cd server +spacetime publish unity-tutorial ``` -If you get any errors from this command, double check that you correctly entered everything into lib.rs. You can also look at the Troubleshooting section at the end of this tutorial. +If you get any errors from this command, double check that you correctly entered everything into `lib.rs`. You can also look at the Troubleshooting section at the end of this tutorial. ## Updating our Unity Project to use SpacetimeDB Now we are ready to connect our bitcraft mini project to SpacetimeDB. -### Step 1: Import the SDK and Generate Module Files +### Import the SDK and Generate Module Files 1. Add the SpacetimeDB Unity Package using the Package Manager. Open the Package Manager window by clicking on Window -> Package Manager. Click on the + button in the top left corner of the window and select "Add package from git URL". Enter the following URL and click Add. @@ -466,100 +377,106 @@ https://github.com/clockworklabs/com.clockworklabs.spacetimedbsdk.git 3. The next step is to generate the module specific client files using the SpacetimeDB CLI. The files created by this command provide an interface for retrieving values from the local client cache of the database and for registering for callbacks to events. In your terminal or command window, run the following commands. ```bash -mkdir -p ../Client/Assets/module_bindings - -spacetime generate --out-dir ../Client/Assets/module_bindings --lang=csharp +mkdir -p ../client/Assets/module_bindings +spacetime generate --out-dir ../client/Assets/module_bindings --lang=csharp ``` -### Step 2: Connect to the SpacetimeDB Module +### Connect to Your SpacetimeDB Module -1. The Unity SpacetimeDB SDK relies on there being a `NetworkManager` somewhere in the scene. Click on the GameManager object in the scene, and in the inspector, add the `NetworkManager` component. +The Unity SpacetimeDB SDK relies on there being a `NetworkManager` somewhere in the scene. Click on the GameManager object in the scene, and in the inspector, add the `NetworkManager` component. ![Unity-AddNetworkManager](/images/unity-tutorial/Unity-AddNetworkManager.JPG) -2. Next we are going to connect to our SpacetimeDB module. Open BitcraftMiniGameManager.cs in your editor of choice and add the following code at the top of the file: +Next we are going to connect to our SpacetimeDB module. Open `TutorialGameManager.cs` in your editor of choice and add the following code at the top of the file: -`SpacetimeDB.Types` is the namespace that your generated code is in. You can change this by specifying a namespace in the generate command using `--namespace`. +**Append to the top of TutorialGameManager.cs** ```csharp using SpacetimeDB; using SpacetimeDB.Types; +using System.Linq; ``` -3. Inside the class definition add the following members: +At the top of the class definition add the following members: + +**Append to the top of TutorialGameManager class inside of TutorialGameManager.cs** ```csharp - // These are connection variables that are exposed on the GameManager - // inspector. The cloud version of SpacetimeDB needs sslEnabled = true - [SerializeField] private string moduleAddress = "YOUR_MODULE_DOMAIN_OR_ADDRESS"; - [SerializeField] private string hostName = "localhost:3000"; - [SerializeField] private bool sslEnabled = false; +// These are connection variables that are exposed on the GameManager +// inspector. +[SerializeField] private string moduleAddress = "unity-tutorial"; +[SerializeField] private string hostName = "localhost:3000"; - // This is the identity for this player that is automatically generated - // the first time you log in. We set this variable when the - // onIdentityReceived callback is triggered by the SDK after connecting - private Identity local_identity; +// This is the identity for this player that is automatically generated +// the first time you log in. We set this variable when the +// onIdentityReceived callback is triggered by the SDK after connecting +private Identity local_identity; ``` -The first three fields will appear in your Inspector so you can update your connection details without editing the code. The `moduleAddress` should be set to the domain you used in the publish command. You should not need to change `hostName` or `sslEnabled` if you are using the standalone version of SpacetimeDB. +The first three fields will appear in your Inspector so you can update your connection details without editing the code. The `moduleAddress` should be set to the domain you used in the publish command. You should not need to change `hostName` if you are using SpacetimeDB locally. -4. Add the following code to the `Start` function. **Be sure to remove the line `UIUsernameChooser.instance.Show();`** since we will call this after we get the local state and find that the player for us. +Now add the following code to the `Start()` function. For clarity, replace your entire `Start()` function with the function below. -In our `onConnect` callback we are calling `Subscribe` with a list of queries. This tells SpacetimeDB what rows we want in our local client cache. We will also not get row update callbacks or event callbacks for any reducer that does not modify a row that matches these queries. +**REPLACE the Start() function in TutorialGameManager.cs** ---- +```csharp +// Start is called before the first frame update +void Start() +{ + instance = this; -**Local Client Cache** + SpacetimeDBClient.instance.onConnect += () => + { + Debug.Log("Connected."); -The "local client cache" is a client-side view of the database, defined by the supplied queries to the Subscribe function. It contains relevant data, allowing efficient access without unnecessary server queries. Accessing data from the client cache is done using the auto-generated iter and filter_by functions for each table, and it ensures that update and event callbacks are limited to the subscribed rows. + // Request all tables + SpacetimeDBClient.instance.Subscribe(new List() + { + "SELECT * FROM *", + }); + }; ---- + // Called when we have an error connecting to SpacetimeDB + SpacetimeDBClient.instance.onConnectError += (error, message) => + { + Debug.LogError($"Connection error: " + message); + }; -```csharp - // When we connect to SpacetimeDB we send our subscription queries - // to tell SpacetimeDB which tables we want to get updates for. - SpacetimeDBClient.instance.onConnect += () => - { - Debug.Log("Connected."); + // Called when we are disconnected from SpacetimeDB + SpacetimeDBClient.instance.onDisconnect += (closeStatus, error) => + { + Debug.Log("Disconnected."); + }; - SpacetimeDBClient.instance.Subscribe(new List() - { - "SELECT * FROM Config", - "SELECT * FROM SpawnableEntityComponent", - "SELECT * FROM PlayerComponent", - "SELECT * FROM MobileLocationComponent", - }); - }; - - // called when we have an error connecting to SpacetimeDB - SpacetimeDBClient.instance.onConnectError += (error, message) => - { - Debug.LogError($"Connection error: " + message); - }; + // Called when we receive the client identity from SpacetimeDB + SpacetimeDBClient.instance.onIdentityReceived += (token, identity, address) => { + AuthToken.SaveToken(token); + local_identity = identity; + }; - // called when we are disconnected from SpacetimeDB - SpacetimeDBClient.instance.onDisconnect += (closeStatus, error) => - { - Debug.Log("Disconnected."); - }; + // Called after our local cache is populated from a Subscribe call + SpacetimeDBClient.instance.onSubscriptionApplied += OnSubscriptionApplied; + + // Now that we’ve registered all our callbacks, lets connect to spacetimedb + SpacetimeDBClient.instance.Connect(AuthToken.Token, hostName, moduleAddress); +} +``` +In our `onConnect` callback we are calling `Subscribe` and subscribing to all data in the database. You can also subscribe to specific tables using SQL syntax like `SELECT * FROM MyTable`. Our SQL documentation enumerates the operations that are accepted in our SQL syntax. - // called when we receive the client identity from SpacetimeDB - SpacetimeDBClient.instance.onIdentityReceived += (token, identity, address) => { - AuthToken.SaveToken(token); - local_identity = identity; - }; +Subscribing to tables tells SpacetimeDB what rows we want in our local client cache. We will also not get row update callbacks or event callbacks for any reducer that does not modify a row that matches at least one of our queries. This means that events can happen on the server and the client won't be notified unless they are subscribed to at least 1 row in the change. +--- - // called after our local cache is populated from a Subscribe call - SpacetimeDBClient.instance.onSubscriptionApplied += OnSubscriptionApplied; +**Local Client Cache** - // now that we’ve registered all our callbacks, lets connect to - // spacetimedb - SpacetimeDBClient.instance.Connect(AuthToken.Token, hostName, moduleAddress, sslEnabled); -``` +The "local client cache" is a client-side view of the database defined by the supplied queries to the `Subscribe` function. It contains the requested data which allows efficient access without unnecessary server queries. Accessing data from the client cache is done using the auto-generated iter and filter_by functions for each table, and it ensures that update and event callbacks are limited to the subscribed rows. -5. Next we write the `OnSubscriptionUpdate` callback. When this event occurs for the first time, it signifies that our local client cache is fully populated. At this point, we can verify if a player entity already exists for the corresponding user. If we do not have a player entity, we need to show the `UserNameChooser` dialog so the user can enter a username. We also put the message of the day into the chat window. Finally we unsubscribe from the callback since we only need to do this once. +--- + +Next we write the `OnSubscriptionApplied` callback. When this event occurs for the first time, it signifies that our local client cache is fully populated. At this point, we can verify if a player entity already exists for the corresponding user. If we do not have a player entity, we need to show the `UserNameChooser` dialog so the user can enter a username. We also put the message of the day into the chat window. Finally we unsubscribe from the callback since we only need to do this once. + +**Append after the Start() function in TutorialGameManager.cs** ```csharp void OnSubscriptionApplied() @@ -582,25 +499,46 @@ void OnSubscriptionApplied() } ``` -### Step 3: Adding the Multiplayer Functionality +### Adding the Multiplayer Functionality + +Now we have to change what happens when you press the "Continue" button in the name dialog window. Instead of calling start game like we did in the single player version, we call the `create_player` reducer on the SpacetimeDB module using the auto-generated code. Open `UIUsernameChooser.cs`. -1. Now we have to change what happens when you press the "Continue" button in the name dialog window. Instead of calling start game like we did in the single player version, we call the `create_player` reducer on the SpacetimeDB module using the auto-generated code. Open `UIUsernameChooser`, **add `using SpacetimeDB.Types;`** at the top of the file, and replace: +**Append to the top of UIUsernameChooser.cs** ```csharp - LocalPlayer.instance.username = _usernameField.text; - BitcraftMiniGameManager.instance.StartGame(); +using SpacetimeDB.Types; ``` -with: +Then we're doing a modification to the `ButtonPressed()` function: + +**Modify the ButtonPressed function in UIUsernameChooser.cs** ```csharp +public void ButtonPressed() +{ + CameraController.RemoveDisabler(GetHashCode()); + _panel.SetActive(false); + // Call the SpacetimeDB CreatePlayer reducer Reducer.CreatePlayer(_usernameField.text); +} ``` -2. We need to create a `RemotePlayer` component that we attach to remote player objects. In the same folder as `LocalPlayer`, create a new C# script called `RemotePlayer`. In the start function, we will register an OnUpdate callback for the `MobileLocationComponent` and query the local cache to get the player’s initial position. **Make sure you include a `using SpacetimeDB.Types;`** at the top of the file. +We need to create a `RemotePlayer` script that we attach to remote player objects. In the same folder as `LocalPlayer.cs`, create a new C# script called `RemotePlayer`. In the start function, we will register an OnUpdate callback for the `EntityComponent` and query the local cache to get the player’s initial position. **Make sure you include a `using SpacetimeDB.Types;`** at the top of the file. + +First append this using to the top of `RemotePlayer.cs` + +**Create file RemotePlayer.cs, then replace its contents:** ```csharp +using System.Collections; +using System.Collections.Generic; +using UnityEngine; +using SpacetimeDB.Types; +using TMPro; + +public class RemotePlayer : MonoBehaviour +{ public ulong EntityId; public TMP_Text UsernameElement; @@ -609,191 +547,202 @@ with: void Start() { - // initialize overhead name + // Initialize overhead name UsernameElement = GetComponentInChildren(); var canvas = GetComponentInChildren(); canvas.worldCamera = Camera.main; - // get the username from the PlayerComponent for this object and set it in the UI + // Get the username from the PlayerComponent for this object and set it in the UI PlayerComponent playerComp = PlayerComponent.FilterByEntityId(EntityId); Username = playerComp.Username; - // get the last location for this player and set the initial - // position - MobileLocationComponent mobPos = MobileLocationComponent.FilterByEntityId(EntityId); - Vector3 playerPos = new Vector3(mobPos.Location.X, 0.0f, mobPos.Location.Z); - transform.position = new Vector3(playerPos.x, MathUtil.GetTerrainHeight(playerPos), playerPos.z); + // Get the last location for this player and set the initial position + EntityComponent entity = EntityComponent.FilterByEntityId(EntityId); + transform.position = new Vector3(entity.Position.X, entity.Position.Y, entity.Position.Z); - // register for a callback that is called when the client gets an - // update for a row in the MobileLocationComponent table - MobileLocationComponent.OnUpdate += MobileLocationComponent_OnUpdate; + // Register for a callback that is called when the client gets an + // update for a row in the EntityComponent table + EntityComponent.OnUpdate += EntityComponent_OnUpdate; } +} ``` -3. We now write the `MobileLocationComponent_OnUpdate` callback which sets the movement direction in the `MovementController` for this player. We also set the position to the current location when we stop moving (`DirectionVec` is zero) +We now write the `EntityComponent_OnUpdate` callback which sets the movement direction in the `MovementController` for this player. We also set the target position to the current location in the latest update. + +**Append to bottom of RemotePlayer class in RemotePlayer.cs:** ```csharp - private void MobileLocationComponent_OnUpdate(MobileLocationComponent oldObj, MobileLocationComponent obj, ReducerEvent callInfo) +private void EntityComponent_OnUpdate(EntityComponent oldObj, EntityComponent obj, ReducerEvent callInfo) +{ + // If the update was made to this object + if(obj.EntityId == EntityId) { - // if the update was made to this object - if(obj.EntityId == EntityId) - { - // update the DirectionVec in the PlayerMovementController component with the updated values - var movementController = GetComponent(); - movementController.DirectionVec = new Vector3(obj.Direction.X, 0.0f, obj.Direction.Z); - // if DirectionVec is {0,0,0} then we came to a stop so correct our position to match the server - if (movementController.DirectionVec == Vector3.zero) - { - Vector3 playerPos = new Vector3(obj.Location.X, 0.0f, obj.Location.Z); - transform.position = new Vector3(playerPos.x, MathUtil.GetTerrainHeight(playerPos), playerPos.z); - } - } + var movementController = GetComponent(); + + // Update target position, rotation, etc. + movementController.RemoteTargetPosition = new Vector3(obj.Position.X, obj.Position.Y, obj.Position.Z); + movementController.RemoteTargetRotation = obj.Direction; + movementController.SetMoving(obj.Moving); } +} ``` -4. Next we need to handle what happens when a `PlayerComponent` is added to our local cache. We will handle it differently based on if it’s our local player entity or a remote player. We are going to register for the `OnInsert` event for our `PlayerComponent` table. Add the following code to the `Start` function in `BitcraftMiniGameManager`. +Next we need to handle what happens when a `PlayerComponent` is added to our local cache. We will handle it differently based on if it’s our local player entity or a remote player. We are going to register for the `OnInsert` event for our `PlayerComponent` table. Add the following code to the `Start` function in `TutorialGameManager`. + +**Append to bottom of Start() function in TutorialGameManager.cs:** ```csharp - PlayerComponent.OnInsert += PlayerComponent_OnInsert; +PlayerComponent.OnInsert += PlayerComponent_OnInsert; ``` -5. Create the `PlayerComponent_OnInsert` function which does something different depending on if it's the component for the local player or a remote player. If it's the local player, we set the local player object's initial position and call `StartGame`. If it's a remote player, we instantiate a `PlayerPrefab` with the `RemotePlayer` component. The start function of `RemotePlayer` handles initializing the player position. +Create the `PlayerComponent_OnInsert` function which does something different depending on if it's the component for the local player or a remote player. If it's the local player, we set the local player object's initial position and call `StartGame`. If it's a remote player, we instantiate a `PlayerPrefab` with the `RemotePlayer` component. The start function of `RemotePlayer` handles initializing the player position. + +**Append to bottom of TutorialGameManager class in TutorialGameManager.cs:** ```csharp - private void PlayerComponent_OnInsert(PlayerComponent obj, ReducerEvent callInfo) +private void PlayerComponent_OnInsert(PlayerComponent obj, ReducerEvent callInfo) +{ + // If the identity of the PlayerComponent matches our user identity then this is the local player + if(obj.OwnerId == local_identity) { - // if the identity of the PlayerComponent matches our user identity then this is the local player - if(obj.OwnerId == local_identity) - { - // Set the local player username - LocalPlayer.instance.Username = obj.Username; - - // Get the MobileLocationComponent for this object and update the position to match the server - MobileLocationComponent mobPos = MobileLocationComponent.FilterByEntityId(obj.EntityId); - Vector3 playerPos = new Vector3(mobPos.Location.X, 0.0f, mobPos.Location.Z); - LocalPlayer.instance.transform.position = new Vector3(playerPos.x, MathUtil.GetTerrainHeight(playerPos), playerPos.z); - - // Now that we have our initial position we can start the game - StartGame(); - } - // otherwise this is a remote player - else - { - // spawn the player object and attach the RemotePlayer component - var remotePlayer = Instantiate(PlayerPrefab); - remotePlayer.AddComponent().EntityId = obj.EntityId; - } + // Now that we have our initial position we can start the game + StartGame(); + } + else + { + // Spawn the player object and attach the RemotePlayer component + var remotePlayer = Instantiate(PlayerPrefab); + // Lookup and apply the position for this new player + var entity = EntityComponent.FilterByEntityId(obj.EntityId); + var position = new Vector3(entity.Position.X, entity.Position.Y, entity.Position.Z); + remotePlayer.transform.position = position; + var movementController = remotePlayer.GetComponent(); + movementController.RemoteTargetPosition = position; + movementController.RemoteTargetRotation = entity.Direction; + remotePlayer.AddComponent().EntityId = obj.EntityId; } +} ``` -6. Next, we need to update the `FixedUpdate` function in `LocalPlayer` to call the `move_player` and `stop_player` reducers using the auto-generated functions. **Don’t forget to add `using SpacetimeDB.Types;`** to LocalPlayer.cs +Next, we will add a `FixedUpdate()` function to the `LocalPlayer` class so that we can send the local player's position to SpacetimeDB. We will do this by calling the auto-generated reducer function `Reducer.UpdatePlayerPosition(...)`. When we invoke this reducer from the client, a request is sent to SpacetimeDB and the reducer `update_player_position(...)` is executed on the server and a transaction is produced. All clients connected to SpacetimeDB will start receiving the results of these transactions. + +**Append to the top of LocalPlayer.cs** ```csharp - private Vector3? lastUpdateDirection; +using SpacetimeDB.Types; +using SpacetimeDB; +``` + +**Append to the bottom of LocalPlayer class in LocalPlayer.cs** - private void FixedUpdate() +```csharp +private float? lastUpdateTime; +private void FixedUpdate() +{ + if ((lastUpdateTime.HasValue && Time.time - lastUpdateTime.Value > 1.0f / movementUpdateSpeed) || !SpacetimeDBClient.instance.IsConnected()) { - var directionVec = GetDirectionVec(); - PlayerMovementController.Local.DirectionVec = directionVec; + return; + } - // first get the position of the player - var ourPos = PlayerMovementController.Local.GetModelTransform().position; - // if we are moving , and we haven't updated our destination yet, or we've moved more than .1 units, update our destination - if (directionVec.sqrMagnitude != 0 && (!lastUpdateDirection.HasValue || (directionVec - lastUpdateDirection.Value).sqrMagnitude > .1f)) - { - Reducer.MovePlayer(new StdbVector2() { X = ourPos.x, Z = ourPos.z }, new StdbVector2() { X = directionVec.x, Z = directionVec.z }); - lastUpdateDirection = directionVec; - } - // if we stopped moving, send the update - else if(directionVec.sqrMagnitude == 0 && lastUpdateDirection != null) + lastUpdateTime = Time.time; + var p = PlayerMovementController.Local.GetModelPosition(); + Reducer.UpdatePlayerPosition(new StdbVector3 { - Reducer.StopPlayer(new StdbVector2() { X = ourPos.x, Z = ourPos.z }); - lastUpdateDirection = null; - } - } + X = p.x, + Y = p.y, + Z = p.z, + }, + PlayerMovementController.Local.GetModelRotation(), + PlayerMovementController.Local.IsMoving()); +} ``` -7. Finally, we need to update our connection settings in the inspector for our GameManager object in the scene. Click on the GameManager in the Hierarchy tab. The the inspector tab you should now see fields for `Module Address`, `Host Name` and `SSL Enabled`. Set the `Module Address` to the name you used when you ran `spacetime publish`. If you don't remember, you can go back to your terminal and run `spacetime publish` again from the `Server` folder. +Finally, we need to update our connection settings in the inspector for our GameManager object in the scene. Click on the GameManager in the Hierarchy tab. The the inspector tab you should now see fields for `Module Address` and `Host Name`. Set the `Module Address` to the name you used when you ran `spacetime publish`. This is likely `unity-tutorial`. If you don't remember, you can go back to your terminal and run `spacetime publish` again from the `server` folder. ![GameManager-Inspector2](/images/unity-tutorial/GameManager-Inspector2.JPG) -### Step 4: Play the Game! +### Play the Game! -1. Go to File -> Build Settings... Replace the SampleScene with the Main scene we have been working in. +Go to File -> Build Settings... Replace the SampleScene with the Main scene we have been working in. ![Unity-AddOpenScenes](/images/unity-tutorial/Unity-AddOpenScenes.JPG) When you hit the `Build` button, it will kick off a build of the game which will use a different identity than the Unity Editor. Create your character in the build and in the Unity Editor by entering a name and clicking `Continue`. Now you can see each other in game running around the map. -### Step 5: Implement Player Logout +### Implement Player Logout So far we have not handled the `logged_in` variable of the `PlayerComponent`. This means that remote players will not despawn on your screen when they disconnect. To fix this we need to handle the `OnUpdate` event for the `PlayerComponent` table in addition to `OnInsert`. We are going to use a common function that handles any time the `PlayerComponent` changes. -1. Open `BitcraftMiniGameManager.cs` and add the following code to the `Start` function: - +**Append to the bottom of Start() function in TutorialGameManager.cs** ```csharp - PlayerComponent.OnUpdate += PlayerComponent_OnUpdate; +PlayerComponent.OnUpdate += PlayerComponent_OnUpdate; ``` -2. We are going to add a check to determine if the player is logged for remote players. If the player is not logged in, we search for the RemotePlayer object with the corresponding `EntityId` and destroy it. Add `using System.Linq;` to the top of the file and replace the `PlayerComponent_OnInsert` function with the following code. +We are going to add a check to determine if the player is logged for remote players. If the player is not logged in, we search for the `RemotePlayer` object with the corresponding `EntityId` and destroy it. +Next we'll be updating some of the code in `PlayerComponent_OnInsert`. For simplicity, just replace the entire function. + +**REPLACE PlayerComponent_OnInsert in TutorialGameManager.cs** ```csharp - private void PlayerComponent_OnUpdate(PlayerComponent oldValue, PlayerComponent newValue, ReducerEvent dbEvent) - { - OnPlayerComponentChanged(newValue); - } +private void PlayerComponent_OnUpdate(PlayerComponent oldValue, PlayerComponent newValue, ReducerEvent dbEvent) +{ + OnPlayerComponentChanged(newValue); +} + +private void PlayerComponent_OnInsert(PlayerComponent obj, ReducerEvent dbEvent) +{ + OnPlayerComponentChanged(obj); +} - private void PlayerComponent_OnInsert(PlayerComponent obj, ReducerEvent dbEvent) +private void OnPlayerComponentChanged(PlayerComponent obj) +{ + // If the identity of the PlayerComponent matches our user identity then this is the local player + if(obj.OwnerId == local_identity) { - OnPlayerComponentChanged(obj); + // Now that we have our initial position we can start the game + StartGame(); } - - private void OnPlayerComponentChanged(PlayerComponent obj) + else { - // if the identity of the PlayerComponent matches our user identity then this is the local player - if (obj.OwnerId == local_identity) - { - // Set the local player username - LocalPlayer.instance.Username = obj.Username; - - // Get the MobileLocationComponent for this object and update the position to match the server - MobileLocationComponent mobPos = MobileLocationComponent.FilterByEntityId(obj.EntityId); - Vector3 playerPos = new Vector3(mobPos.Location.X, 0.0f, mobPos.Location.Z); - LocalPlayer.instance.transform.position = new Vector3(playerPos.x, MathUtil.GetTerrainHeight(playerPos), playerPos.z); - - // Now that we have our initial position we can start the game - StartGame(); - } - // otherwise this is a remote player - else + // otherwise we need to look for the remote player object in the scene (if it exists) and destroy it + var existingPlayer = FindObjectsOfType().FirstOrDefault(item => item.EntityId == obj.EntityId); + if (obj.LoggedIn) { - // if the remote player is logged in, spawn it - if (obj.LoggedIn) + // Only spawn remote players who aren't already spawned + if (existingPlayer == null) { - // spawn the player object and attach the RemotePlayer component + // Spawn the player object and attach the RemotePlayer component var remotePlayer = Instantiate(PlayerPrefab); + // Lookup and apply the position for this new player + var entity = EntityComponent.FilterByEntityId(obj.EntityId); + var position = new Vector3(entity.Position.X, entity.Position.Y, entity.Position.Z); + remotePlayer.transform.position = position; + var movementController = remotePlayer.GetComponent(); + movementController.RemoteTargetPosition = position; + movementController.RemoteTargetRotation = entity.Direction; remotePlayer.AddComponent().EntityId = obj.EntityId; } - // otherwise we need to look for the remote player object in the scene (if it exists) and destroy it - else + } + else + { + if (existingPlayer != null) { - var remotePlayer = FindObjectsOfType().FirstOrDefault(item => item.EntityId == obj.EntityId); - if (remotePlayer != null) - { - Destroy(remotePlayer.gameObject); - } + Destroy(existingPlayer.gameObject); } } } +} ``` -3. Now you when you play the game you should see remote players disappear when they log out. +Now you when you play the game you should see remote players disappear when they log out. -### Step 6: Add Chat Support +### Finally, Add Chat Support The project has a chat window but so far all it's used for is the message of the day. We are going to add the ability for players to send chat messages to each other. -1. First lets add a new `ChatMessage` table to the SpacetimeDB module. Add the following code to lib.rs. +First lets add a new `ChatMessage` table to the SpacetimeDB module. Add the following code to ``lib.rs``. + +**Append to the bottom of server/src/lib.rs:** ```rust #[spacetimedb(table)] @@ -801,34 +750,30 @@ pub struct ChatMessage { // The primary key for this table will be auto-incremented #[primarykey] #[autoinc] - pub chat_entity_id: u64, + pub message_id: u64, - // The entity id of the player (or NPC) that sent the message - pub source_entity_id: u64, + // The entity id of the player that sent the message + pub sender_id: u64, // Message contents - pub chat_text: String, - // Timestamp of when the message was sent - pub timestamp: Timestamp, + pub text: String, } ``` -2. Now we need to add a reducer to handle inserting new chat messages. Add the following code to lib.rs. +Now we need to add a reducer to handle inserting new chat messages. + +**Append to the bottom of server/src/lib.rs:** ```rust +// Adds a chat entry to the ChatMessage table #[spacetimedb(reducer)] -pub fn chat_message(ctx: ReducerContext, message: String) -> Result<(), String> { - // Add a chat entry to the ChatMessage table - - // Get the player component based on the sender identity - let owner_id = ctx.sender; - if let Some(player) = PlayerComponent::filter_by_owner_id(&owner_id) { +pub fn send_chat_message(ctx: ReducerContext, text: String) -> Result<(), String> { + if let Some(player) = PlayerComponent::filter_by_owner_id(&ctx.sender) { // Now that we have the player we can insert the chat message using the player entity id. ChatMessage::insert(ChatMessage { // this column auto-increments so we can set it to 0 - chat_entity_id: 0, - source_entity_id: player.entity_id, - chat_text: message, - timestamp: ctx.timestamp, + message_id: 0, + sender_id: player.entity_id, + text, }) .unwrap(); @@ -839,57 +784,53 @@ pub fn chat_message(ctx: ReducerContext, message: String) -> Result<(), String> } ``` -3. Before updating the client, let's generate the client files and publish our module. +Before updating the client, let's generate the client files and update publish our module. +**Execute commands in the server/ directory** ```bash -spacetime generate --out-dir ../Client/Assets/module_bindings --lang=csharp +spacetime generate --out-dir ../client/Assets/module_bindings --lang=csharp +spacetime publish -c unity-tutorial +``` + +On the client, let's add code to send the message when the chat button or enter is pressed. Update the `OnChatButtonPress` function in `UIChatController.cs`. -spacetime publish -c yourname-bitcraftmini +**Append to the top of UIChatController.cs:** +```csharp +using SpacetimeDB.Types; ``` -4. On the client, let's add code to send the message when the chat button or enter is pressed. Update the `OnChatButtonPress` function in `UIChatController.cs`. +**REPLACE the OnChatButtonPress function in UIChatController.cs:** ```csharp public void OnChatButtonPress() { - Reducer.ChatMessage(_chatInput.text); + Reducer.SendChatMessage(_chatInput.text); _chatInput.text = ""; } ``` -5. Next let's add the `ChatMessage` table to our list of subscriptions. +Now we need to add a reducer to handle inserting new chat messages. First register for the ChatMessage reducer in the `Start()` function using the auto-generated function: +**Append to the bottom of the Start() function in TutorialGameManager.cs:** ```csharp - SpacetimeDBClient.instance.Subscribe(new List() - { - "SELECT * FROM Config", - "SELECT * FROM SpawnableEntityComponent", - "SELECT * FROM PlayerComponent", - "SELECT * FROM MobileLocationComponent", - "SELECT * FROM ChatMessage", - }); +Reducer.OnSendChatMessageEvent += OnSendChatMessageEvent; ``` -6. Now we need to add a reducer to handle inserting new chat messages. First register for the ChatMessage reducer in the `Start` function using the auto-generated function: +Now we write the `OnSendChatMessageEvent` function. We can find the `PlayerComponent` for the player who sent the message using the `Identity` of the sender. Then we get the `Username` and prepend it to the message before sending it to the chat window. +**Append after the Start() function in TutorialGameManager.cs** ```csharp - Reducer.OnChatMessageEvent += OnChatMessageEvent; -``` - -Then we write the `OnChatMessageEvent` function. We can find the `PlayerComponent` for the player who sent the message using the `Identity` of the sender. Then we get the `Username` and prepend it to the message before sending it to the chat window. - -```csharp - private void OnChatMessageEvent(ReducerEvent dbEvent, string message) +private void OnSendChatMessageEvent(ReducerEvent dbEvent, string message) +{ + var player = PlayerComponent.FilterByOwnerId(dbEvent.Identity); + if (player != null) { - var player = PlayerComponent.FilterByOwnerId(dbEvent.Identity); - if (player != null) - { - UIChatController.instance.OnChatMessageReceived(player.Username + ": " + message); - } + UIChatController.instance.OnChatMessageReceived(player.Username + ": " + message); } +} ``` -7. Now when you run the game you should be able to send chat messages to other players. Be sure to make a new Unity client build and run it in a separate window so you can test chat between two clients. +Now when you run the game you should be able to send chat messages to other players. Be sure to make a new Unity client build and run it in a separate window so you can test chat between two clients. ## Conclusion @@ -905,7 +846,7 @@ This concludes the first part of the tutorial. We've learned about the basics of ``` NullReferenceException: Object reference not set to an instance of an object -BitcraftMiniGameManager.Start () (at Assets/_Project/Game/BitcraftMiniGameManager.cs:26) +TutorialGameManager.Start () (at Assets/_Project/Game/TutorialGameManager.cs:26) ``` Check to see if your GameManager object in the Scene has the NetworkManager component attached. diff --git a/docs/Unity Tutorial/Part 2 - Resources And Scheduling.md b/docs/Unity Tutorial/Part 2 - Resources And Scheduling.md index 5cd205ef..537edd44 100644 --- a/docs/Unity Tutorial/Part 2 - Resources And Scheduling.md +++ b/docs/Unity Tutorial/Part 2 - Resources And Scheduling.md @@ -1,5 +1,7 @@ # Part 2 - Resources and Scheduling +**Oct 14th, 2023: This tutorial has not yet been updated for the recent 0.7.0 release, it will be updated asap!** + In this second part of the lesson, we'll add resource nodes to our project and learn about scheduled reducers. Then we will spawn the nodes on the client so they are visible to the player. ## Add Resource Node Spawner diff --git a/docs/Unity Tutorial/Part 3 - BitCraft Mini.md b/docs/Unity Tutorial/Part 3 - BitCraft Mini.md index e1f5e3eb..16816dd6 100644 --- a/docs/Unity Tutorial/Part 3 - BitCraft Mini.md +++ b/docs/Unity Tutorial/Part 3 - BitCraft Mini.md @@ -1,5 +1,7 @@ # Part 3 - BitCraft Mini +**Oct 14th, 2023: This tutorial has not yet been updated for the recent 0.7.0 release, it will be updated asap!** + BitCraft Mini is a game that we developed which extends the code you've already developed in this tutorial. It is inspired by our game [BitCraft](https://bitcraftonline.com) and illustrates how you could build a more complex game from just the components we've discussed. Right now you can walk around, mine ore, and manage your inventory. ## 1. Download From f2e1fbd22bddb7ac7be5921a796e7defed234e69 Mon Sep 17 00:00:00 2001 From: John Detter <4099508+jdetter@users.noreply.github.com> Date: Sat, 14 Oct 2023 21:17:33 -0500 Subject: [PATCH 011/164] Addressing Chippy's feedback (#8) Co-authored-by: John Detter --- docs/Server Module Languages/C#/index.md | 2 +- docs/Unity Tutorial/Part 1 - Basic Multiplayer.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/Server Module Languages/C#/index.md b/docs/Server Module Languages/C#/index.md index 473a8ac6..dd818b07 100644 --- a/docs/Server Module Languages/C#/index.md +++ b/docs/Server Module Languages/C#/index.md @@ -184,7 +184,7 @@ You could extend the validation in `ValidateMessage` in similar ways to `Validat In C# modules, you can register for OnConnect and OnDisconnect events in a special initializer function that uses the attribute `ModuleInitializer`. We'll use the `OnConnect` event to create a `User` record for the client if it doesn't yet exist, and to set its online status. -We'll use `User.FilterByOwnerIdentity` to look up a `User` row for `dbEvent.Sender`, if one exists. If we find one, we'll use `User.UpdateByOwnerIdentity` to overwrite it with a row that has `Online: true`. If not, we'll use `User.Insert` to insert a new row for our new user. All three of these methods are generated by the `[SpacetimeDB.Table]` attribute, with rows and behavior based on the row attributes. `FilterByOwnerIdentity` returns a nullable `User`, because the unique constraint from the `[SpacetimeDB.Column(ColumnAttrs.PrimaryKey)]` attribute means there will be either zero or one matching rows. `Insert` will throw an exception if the insert violates this constraint; if we want to overwrite a `User` row, we need to do so explicitly using `UpdateByOwnerIdentity`. +We'll use `User.FilterByIdentity` to look up a `User` row for `dbEvent.Sender`, if one exists. If we find one, we'll use `User.UpdateByIdentity` to overwrite it with a row that has `Online: true`. If not, we'll use `User.Insert` to insert a new row for our new user. All three of these methods are generated by the `[SpacetimeDB.Table]` attribute, with rows and behavior based on the row attributes. `FilterByIdentity` returns a nullable `User`, because the unique constraint from the `[SpacetimeDB.Column(ColumnAttrs.PrimaryKey)]` attribute means there will be either zero or one matching rows. `Insert` will throw an exception if the insert violates this constraint; if we want to overwrite a `User` row, we need to do so explicitly using `UpdateByIdentity`. In `server/Lib.cs`, add the definition of the connect reducer to the `Module` class: diff --git a/docs/Unity Tutorial/Part 1 - Basic Multiplayer.md b/docs/Unity Tutorial/Part 1 - Basic Multiplayer.md index 915fd444..30bd3137 100644 --- a/docs/Unity Tutorial/Part 1 - Basic Multiplayer.md +++ b/docs/Unity Tutorial/Part 1 - Basic Multiplayer.md @@ -355,7 +355,7 @@ Now that we've written the code for our server module, we need to publish it to ```bash cd server -spacetime publish unity-tutorial +spacetime publish -c unity-tutorial ``` If you get any errors from this command, double check that you correctly entered everything into `lib.rs`. You can also look at the Troubleshooting section at the end of this tutorial. From 2968a4e9c2e61681ac52966f5cb25a68db621212 Mon Sep 17 00:00:00 2001 From: Ingvar Stepanyan Date: Wed, 25 Oct 2023 11:53:25 +0100 Subject: [PATCH 012/164] Update C# docs for connect/disconnect (#9) Update after the change in https://github.com/clockworklabs/SpacetimeDB/pull/309. --- .../C#/ModuleReference.md | 16 +--- docs/Server Module Languages/C#/index.md | 80 +++++++++---------- 2 files changed, 42 insertions(+), 54 deletions(-) diff --git a/docs/Server Module Languages/C#/ModuleReference.md b/docs/Server Module Languages/C#/ModuleReference.md index d655ea6d..36a9618a 100644 --- a/docs/Server Module Languages/C#/ModuleReference.md +++ b/docs/Server Module Languages/C#/ModuleReference.md @@ -289,6 +289,9 @@ These are two special kinds of reducers that can be used to respond to module li - `ReducerKind.Init` - this reducer will be invoked when the module is first published. - `ReducerKind.Update` - this reducer will be invoked when the module is updated. +- `ReducerKind.Connect` - this reducer will be invoked when a client connects to the database. +- `ReducerKind.Disconnect` - this reducer will be invoked when a client disconnects from the database. + Example: @@ -299,16 +302,3 @@ public static void Init() Log("...and we're live!"); } ``` - -### Connection events - -`OnConnect` and `OnDisconnect` `SpacetimeDB.Runtime` events are triggered when a client connects or disconnects from the database. They can be used to initialize per-client state or to clean up after the client disconnects. They get passed an instance of the earlier mentioned `DbEventArgs` which can be used to distinguish clients via its `Sender` field. - -```csharp -[SpacetimeDB.Reducer(ReducerKind.Init)] -public static void Init() -{ - OnConnect += (e) => Log($"Client {e.Sender} connected!"); - OnDisconnect += (e) => Log($"Client {e.Sender} disconnected!"); -} -``` diff --git a/docs/Server Module Languages/C#/index.md b/docs/Server Module Languages/C#/index.md index dd818b07..03937466 100644 --- a/docs/Server Module Languages/C#/index.md +++ b/docs/Server Module Languages/C#/index.md @@ -182,64 +182,62 @@ You could extend the validation in `ValidateMessage` in similar ways to `Validat ## Set users' online status -In C# modules, you can register for OnConnect and OnDisconnect events in a special initializer function that uses the attribute `ModuleInitializer`. We'll use the `OnConnect` event to create a `User` record for the client if it doesn't yet exist, and to set its online status. +In C# modules, you can register for `Connect` and `Disconnect` events by using a special `ReducerKind`. We'll use the `Connect` event to create a `User` record for the client if it doesn't yet exist, and to set its online status. We'll use `User.FilterByIdentity` to look up a `User` row for `dbEvent.Sender`, if one exists. If we find one, we'll use `User.UpdateByIdentity` to overwrite it with a row that has `Online: true`. If not, we'll use `User.Insert` to insert a new row for our new user. All three of these methods are generated by the `[SpacetimeDB.Table]` attribute, with rows and behavior based on the row attributes. `FilterByIdentity` returns a nullable `User`, because the unique constraint from the `[SpacetimeDB.Column(ColumnAttrs.PrimaryKey)]` attribute means there will be either zero or one matching rows. `Insert` will throw an exception if the insert violates this constraint; if we want to overwrite a `User` row, we need to do so explicitly using `UpdateByIdentity`. In `server/Lib.cs`, add the definition of the connect reducer to the `Module` class: ```C# - [ModuleInitializer] - public static void Init() + [SpacetimeDB.Reducer(ReducerKind.Connect)] + public static void OnConnect(DbEventArgs dbEventArgs) { - OnConnect += (dbEventArgs) => - { - Log($"Connect {dbEventArgs.Sender}"); - var user = User.FindByIdentity(dbEventArgs.Sender); + Log($"Connect {dbEventArgs.Sender}"); + var user = User.FindByIdentity(dbEventArgs.Sender); - if (user is not null) - { - // 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.Online = true; - User.UpdateByIdentity(dbEventArgs.Sender, user); - } - else + if (user is not null) + { + // 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.Online = true; + User.UpdateByIdentity(dbEventArgs.Sender, user); + } + else + { + // If this is a new user, create a `User` object for the `Identity`, + // which is online, but hasn't set a name. + new User { - // If this is a new user, create a `User` object for the `Identity`, - // which is online, but hasn't set a name. - new User - { - Name = null, - Identity = dbEventArgs.Sender, - Online = true, - }.Insert(); - } - }; + Name = null, + Identity = dbEventArgs.Sender, + Online = true, + }.Insert(); + } } ``` -Similarly, whenever a client disconnects, the module will execute the `OnDisconnect` event if it's registered. We'll use it to un-set the `Online` status of the `User` for the disconnected client. +Similarly, whenever a client disconnects, the module will execute the `OnDisconnect` event if it's registered with `ReducerKind.Disconnect`. We'll use it to un-set the `Online` status of the `User` for the disconnected client. Add the following code after the `OnConnect` lambda: ```C# - OnDisconnect += (dbEventArgs) => - { - var user = User.FindByIdentity(dbEventArgs.Sender); + [SpacetimeDB.Reducer(ReducerKind.Disconnect)] + public static void OnDisconnect(DbEventArgs dbEventArgs) + { + var user = User.FindByIdentity(dbEventArgs.Sender); - if (user is not null) - { - // This user should exist, so set `Online: false`. - user.Online = false; - User.UpdateByIdentity(dbEventArgs.Sender, user); - } - else - { - // User does not exist, log warning - Log($"Warning: No user found for disconnected client."); - } - }; + if (user is not null) + { + // This user should exist, so set `Online: false`. + user.Online = false; + User.UpdateByIdentity(dbEventArgs.Sender, user); + } + else + { + // User does not exist, log warning + Log($"Warning: No user found for disconnected client."); + } + } ``` ## Publish the module From 1cafac1f8a7e9c603dc4b458c111071f0e5243e4 Mon Sep 17 00:00:00 2001 From: John Detter <4099508+jdetter@users.noreply.github.com> Date: Thu, 26 Oct 2023 10:53:49 -0500 Subject: [PATCH 013/164] Fix syntax highlighting (#10) Co-authored-by: John Detter --- docs/Server Module Languages/C#/index.md | 192 +++++++++++------------ 1 file changed, 96 insertions(+), 96 deletions(-) diff --git a/docs/Server Module Languages/C#/index.md b/docs/Server Module Languages/C#/index.md index 03937466..0346157f 100644 --- a/docs/Server Module Languages/C#/index.md +++ b/docs/Server Module Languages/C#/index.md @@ -39,7 +39,7 @@ spacetime init --lang csharp server To the top of `server/Lib.cs`, add some imports we'll be using: -```C# +```csharp using System.Runtime.CompilerServices; using SpacetimeDB.Module; using static SpacetimeDB.Runtime; @@ -66,29 +66,29 @@ For each `User`, we'll store their `Identity`, an optional name they can set to In `server/Lib.cs`, add the definition of the table `User` to the `Module` class: -```C# - [SpacetimeDB.Table] - public partial class User - { - [SpacetimeDB.Column(ColumnAttrs.PrimaryKey)] - public Identity Identity; - public string? Name; - public bool Online; - } +```csharp +[SpacetimeDB.Table] +public partial class User +{ + [SpacetimeDB.Column(ColumnAttrs.PrimaryKey)] + public Identity Identity; + public string? Name; + public bool Online; +} ``` For each `Message`, we'll store the `Identity` of the user who sent it, the `Timestamp` when it was sent, and the text of the message. In `server/Lib.cs`, add the definition of the table `Message` to the `Module` class: -```C# - [SpacetimeDB.Table] - public partial class Message - { - public Identity Sender; - public long Sent; - public string Text = ""; - } +```csharp +[SpacetimeDB.Table] +public partial class Message +{ + public Identity Sender; + public long Sent; + public string Text = ""; +} ``` ## Set users' names @@ -101,19 +101,19 @@ It's also possible to call `SetName` via the SpacetimeDB CLI's `spacetime call` In `server/Lib.cs`, add to the `Module` class: -```C# - [SpacetimeDB.Reducer] - public static void SetName(DbEventArgs dbEvent, string name) - { - name = ValidateName(name); +```csharp +[SpacetimeDB.Reducer] +public static void SetName(DbEventArgs dbEvent, string name) +{ + name = ValidateName(name); - var user = User.FindByIdentity(dbEvent.Sender); - if (user is not null) - { - user.Name = name; - User.UpdateByIdentity(dbEvent.Sender, user); - } + var user = User.FindByIdentity(dbEvent.Sender); + if (user is not null) + { + user.Name = name; + User.UpdateByIdentity(dbEvent.Sender, user); } +} ``` For now, we'll just do a bare minimum of validation, rejecting the empty name. You could extend this in various ways, like: @@ -126,16 +126,16 @@ For now, we'll just do a bare minimum of validation, rejecting the empty name. Y In `server/Lib.cs`, add to the `Module` class: -```C# - /// Takes a name and checks if it's acceptable as a user's name. - public static string ValidateName(string name) +```csharp +/// Takes a name and checks if it's acceptable as a user's name. +public static string ValidateName(string name) +{ + if (string.IsNullOrEmpty(name)) { - if (string.IsNullOrEmpty(name)) - { - throw new Exception("Names must not be empty"); - } - return name; + throw new Exception("Names must not be empty"); } + return name; +} ``` ## Send messages @@ -144,35 +144,35 @@ We define a reducer `SendMessage`, which clients will call to send messages. It In `server/Lib.cs`, add to the `Module` class: -```C# - [SpacetimeDB.Reducer] - public static void SendMessage(DbEventArgs dbEvent, string text) +```csharp +[SpacetimeDB.Reducer] +public static void SendMessage(DbEventArgs dbEvent, string text) +{ + text = ValidateMessage(text); + Log(text); + new Message { - text = ValidateMessage(text); - Log(text); - new Message - { - Sender = dbEvent.Sender, - Text = text, - Sent = dbEvent.Time.ToUnixTimeMilliseconds(), - }.Insert(); - } + Sender = dbEvent.Sender, + Text = text, + Sent = dbEvent.Time.ToUnixTimeMilliseconds(), + }.Insert(); +} ``` We'll want to validate messages' texts in much the same way we validate users' chosen names. As above, we'll do the bare minimum, rejecting only empty messages. In `server/Lib.cs`, add to the `Module` class: -```C# - /// Takes a message's text and checks if it's acceptable to send. - public static string ValidateMessage(string text) +```csharp +/// Takes a message's text and checks if it's acceptable to send. +public static string ValidateMessage(string text) +{ + if (string.IsNullOrEmpty(text)) { - if (string.IsNullOrEmpty(text)) - { - throw new ArgumentException("Messages must not be empty"); - } - return text; + throw new ArgumentException("Messages must not be empty"); } + return text; +} ``` You could extend the validation in `ValidateMessage` in similar ways to `ValidateName`, or add additional checks to `SendMessage`, like: @@ -188,56 +188,56 @@ We'll use `User.FilterByIdentity` to look up a `User` row for `dbEvent.Sender`, In `server/Lib.cs`, add the definition of the connect reducer to the `Module` class: -```C# - [SpacetimeDB.Reducer(ReducerKind.Connect)] - public static void OnConnect(DbEventArgs dbEventArgs) - { - Log($"Connect {dbEventArgs.Sender}"); - var user = User.FindByIdentity(dbEventArgs.Sender); +```csharp +[SpacetimeDB.Reducer(ReducerKind.Connect)] +public static void OnConnect(DbEventArgs dbEventArgs) +{ + Log($"Connect {dbEventArgs.Sender}"); + var user = User.FindByIdentity(dbEventArgs.Sender); - if (user is not null) - { - // 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.Online = true; - User.UpdateByIdentity(dbEventArgs.Sender, user); - } - else + if (user is not null) + { + // 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.Online = true; + User.UpdateByIdentity(dbEventArgs.Sender, user); + } + else + { + // If this is a new user, create a `User` object for the `Identity`, + // which is online, but hasn't set a name. + new User { - // If this is a new user, create a `User` object for the `Identity`, - // which is online, but hasn't set a name. - new User - { - Name = null, - Identity = dbEventArgs.Sender, - Online = true, - }.Insert(); - } + Name = null, + Identity = dbEventArgs.Sender, + Online = true, + }.Insert(); } +} ``` Similarly, whenever a client disconnects, the module will execute the `OnDisconnect` event if it's registered with `ReducerKind.Disconnect`. We'll use it to un-set the `Online` status of the `User` for the disconnected client. Add the following code after the `OnConnect` lambda: -```C# - [SpacetimeDB.Reducer(ReducerKind.Disconnect)] - public static void OnDisconnect(DbEventArgs dbEventArgs) - { - var user = User.FindByIdentity(dbEventArgs.Sender); +```csharp +[SpacetimeDB.Reducer(ReducerKind.Disconnect)] +public static void OnDisconnect(DbEventArgs dbEventArgs) +{ + var user = User.FindByIdentity(dbEventArgs.Sender); - if (user is not null) - { - // This user should exist, so set `Online: false`. - user.Online = false; - User.UpdateByIdentity(dbEventArgs.Sender, user); - } - else - { - // User does not exist, log warning - Log($"Warning: No user found for disconnected client."); - } + if (user is not null) + { + // This user should exist, so set `Online: false`. + user.Online = false; + User.UpdateByIdentity(dbEventArgs.Sender, user); } + else + { + // User does not exist, log warning + Log($"Warning: No user found for disconnected client."); + } +} ``` ## Publish the module From dae1640c938ac64f5e39ce97f9f37141c55a69a6 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Mon, 20 Nov 2023 17:49:34 -0800 Subject: [PATCH 014/164] Update index.md Fixes a margin for the figure that kinda breaks it on mobile. --- docs/Overview/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/Overview/index.md b/docs/Overview/index.md index 0e1a6394..35ebbcb7 100644 --- a/docs/Overview/index.md +++ b/docs/Overview/index.md @@ -24,7 +24,7 @@ This means that you can write your entire application in a single language, Rust
SpacetimeDB Architecture -
+
SpacetimeDB application architecture (elements in white are provided by SpacetimeDB)
From ba8a6f7bc19e022a95c3333dff8a092905787f35 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Tue, 21 Nov 2023 19:34:27 -0800 Subject: [PATCH 015/164] Update index.md --- docs/Client SDK Languages/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/Client SDK Languages/index.md b/docs/Client SDK Languages/index.md index 27c9284f..2e3e0740 100644 --- a/docs/Client SDK Languages/index.md +++ b/docs/Client SDK Languages/index.md @@ -1,4 +1,4 @@ -# Welcome to Client SDK Languages# SpacetimeDB Client SDKs Overview + SpacetimeDB Client SDKs Overview The SpacetimeDB Client SDKs provide a comprehensive interface to interact with the SpacetimeDB server engine from various programming languages. Currently, SDKs are available for From 0c5cc3d5890b22cee1ddb59ce911e54f7bf44252 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Tue, 21 Nov 2023 22:39:35 -0800 Subject: [PATCH 016/164] Added nav.ts --- docs/nav.ts | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 docs/nav.ts diff --git a/docs/nav.ts b/docs/nav.ts new file mode 100644 index 00000000..caccf9d1 --- /dev/null +++ b/docs/nav.ts @@ -0,0 +1,75 @@ +export type Nav = { + items: NavItem[]; +}; +export type NavItem = NavPage | NavSection; +export type NavPage = { + type: "page"; + path: string; + title: string; + disabled?: boolean; + href?: string; +}; +type NavSection = { + type: "section"; + title: string; +}; + +function page(path: string, title: string, props?: { disabled?: boolean; href?: string; description?: string }): NavPage { + return { type: "page", path: path, title, ...props }; +} +function section(title: string): NavSection { + return { type: "section", title }; +} + +export default { + items: [ + section("Intro"), + page("Overview/index.md", "Overview"), + page("Getting Started/index.md", "Getting Started"), + + section("Deploying"), + page("Cloud Testnet/index.md", "Testnet"), + + section("Unity Tutorial"), + page("Unity Tutorial/Part 1 - Basic Multiplayer.md", "Part 1 - Basic Multiplayer"), + page("Unity Tutorial/Part 2 - Resources And Scheduling.md", "Part 2 - Resources And Scheduling"), + page("Unity Tutorial/Part 3 - BitCraft Mini.md", "Part 3 - BitCraft Mini"), + + section("Server Module Languages"), + page("Server Module Languages/index.md", "Overview"), + page("Server Module Languages/Rust/index.md", "Rust Quickstart"), + page("Server Module Languages/Rust/ModuleReference.md", "Rust Reference"), + page("Server Module Languages/C#/index.md", "C# Quickstart"), + page("Server Module Languages/C#/ModuleReference.md", "C# Reference"), + + section("Client SDK Languages"), + page("Client SDK Languages/index.md", "Overview"), + page("Client SDK Languages/Typescript/index.md", "Typescript Quickstart"), + page("Client SDK Languages/Typescript/SDK Reference.md", "Typescript Reference"), + page("Client SDK Languages/Rust/index.md", "Rust Quickstart"), + page("Client SDK Languages/Rust/SDK Reference.md", "Rust Reference"), + page("Client SDK Languages/Python/index.md", "Python Quickstart"), + page("Client SDK Languages/Python/SDK Reference.md", "Python Reference"), + page("Client SDK Languages/C#/index.md", "C# Quickstart"), + page("Client SDK Languages/C#/SDK Reference.md", "C# Reference"), + + section("WebAssembly ABI"), + page("Module ABI Reference/index.md", "Module ABI Reference"), + + section("HTTP API"), + page("HTTP API Reference/index.md", "HTTP"), + page("HTTP API Reference/Identities.md", "`/identity`"), + page("HTTP API Reference/Databases.md", "`/database`"), + page("HTTP API Reference/Energy.md", "`/energy`"), + + section("WebSocket API Reference"), + page("WebSocket API Reference/index.md", "WebSocket"), + + section("Data Format"), + page("SATN Reference/index.md", "SATN"), + page("SATN Reference/Binary Format.md", "BSATN"), + + section("SQL"), + page("SQL Reference/index.md", "SQL Reference"), + ], +} satisfies Nav; From 98810a9f82e697481dc72a11c6a41017be9fe355 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Tue, 21 Nov 2023 22:52:36 -0800 Subject: [PATCH 017/164] Removed satisfies keyword for better Typescript compat --- docs/nav.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/nav.ts b/docs/nav.ts index caccf9d1..7a1a032f 100644 --- a/docs/nav.ts +++ b/docs/nav.ts @@ -21,7 +21,7 @@ function section(title: string): NavSection { return { type: "section", title }; } -export default { +const nav: Nav = { items: [ section("Intro"), page("Overview/index.md", "Overview"), @@ -72,4 +72,6 @@ export default { section("SQL"), page("SQL Reference/index.md", "SQL Reference"), ], -} satisfies Nav; +}; + +export default nav; From 02184d9f5c2cd8e536ba8789fba79683fda0cef2 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Wed, 22 Nov 2023 20:14:10 -0800 Subject: [PATCH 018/164] Added slugs to nav --- docs/nav.ts | 63 +++++++++++++++++++++++++++-------------------------- 1 file changed, 32 insertions(+), 31 deletions(-) diff --git a/docs/nav.ts b/docs/nav.ts index 7a1a032f..f7681728 100644 --- a/docs/nav.ts +++ b/docs/nav.ts @@ -5,6 +5,7 @@ export type NavItem = NavPage | NavSection; export type NavPage = { type: "page"; path: string; + slug: string; title: string; disabled?: boolean; href?: string; @@ -14,8 +15,8 @@ type NavSection = { title: string; }; -function page(path: string, title: string, props?: { disabled?: boolean; href?: string; description?: string }): NavPage { - return { type: "page", path: path, title, ...props }; +function page(title: string, slug: string, path: string, props?: { disabled?: boolean; href?: string; description?: string }): NavPage { + return { type: "page", path, slug, title, ...props }; } function section(title: string): NavSection { return { type: "section", title }; @@ -24,53 +25,53 @@ function section(title: string): NavSection { const nav: Nav = { items: [ section("Intro"), - page("Overview/index.md", "Overview"), - page("Getting Started/index.md", "Getting Started"), + page("Overview", "index", "Overview/index.md"), + page("Getting Started", "getting-started", "Getting Started/index.md"), section("Deploying"), - page("Cloud Testnet/index.md", "Testnet"), + page("Testnet", "deploying/testnet", "Cloud Testnet/index.md"), section("Unity Tutorial"), - page("Unity Tutorial/Part 1 - Basic Multiplayer.md", "Part 1 - Basic Multiplayer"), - page("Unity Tutorial/Part 2 - Resources And Scheduling.md", "Part 2 - Resources And Scheduling"), - page("Unity Tutorial/Part 3 - BitCraft Mini.md", "Part 3 - BitCraft Mini"), + page("Part 1 - Basic Multiplayer", "unity/part-1", "Unity Tutorial/Part 1 - Basic Multiplayer.md"), + page("Part 2 - Resources And Scheduling", "unity/part-2", "Unity Tutorial/Part 2 - Resources And Scheduling.md"), + page("Part 3 - BitCraft Mini", "unity/part-3", "Unity Tutorial/Part 3 - BitCraft Mini.md"), section("Server Module Languages"), - page("Server Module Languages/index.md", "Overview"), - page("Server Module Languages/Rust/index.md", "Rust Quickstart"), - page("Server Module Languages/Rust/ModuleReference.md", "Rust Reference"), - page("Server Module Languages/C#/index.md", "C# Quickstart"), - page("Server Module Languages/C#/ModuleReference.md", "C# Reference"), + page("Overview", "modules", "Server Module Languages/index.md"), + page("Rust Quickstart", "modules/rust/quickstart", "Server Module Languages/Rust/index.md"), + page("Rust Reference", "modules/rust", "Server Module Languages/Rust/ModuleReference.md"), + page("C# Quickstart", "modules/c-sharp/quickstart", "Server Module Languages/C#/index.md"), + page("C# Reference", "modules/c-sharp", "Server Module Languages/C#/ModuleReference.md"), section("Client SDK Languages"), - page("Client SDK Languages/index.md", "Overview"), - page("Client SDK Languages/Typescript/index.md", "Typescript Quickstart"), - page("Client SDK Languages/Typescript/SDK Reference.md", "Typescript Reference"), - page("Client SDK Languages/Rust/index.md", "Rust Quickstart"), - page("Client SDK Languages/Rust/SDK Reference.md", "Rust Reference"), - page("Client SDK Languages/Python/index.md", "Python Quickstart"), - page("Client SDK Languages/Python/SDK Reference.md", "Python Reference"), - page("Client SDK Languages/C#/index.md", "C# Quickstart"), - page("Client SDK Languages/C#/SDK Reference.md", "C# Reference"), + page("Overview", "sdks", "Client SDK Languages/index.md"), + page("Typescript Quickstart", "sdks/typescript/quickstart", "Client SDK Languages/Typescript/index.md"), + page("Typescript Reference", "sdks/typescript", "Client SDK Languages/Typescript/SDK Reference.md"), + page("Rust Quickstart", "sdks/rust/quickstart", "Client SDK Languages/Rust/index.md"), + page("Rust Reference", "sdks/rust", "Client SDK Languages/Rust/SDK Reference.md"), + page("Python Quickstart", "sdks/python/quickstart", "Client SDK Languages/Python/index.md"), + page("Python Reference", "sdks/python", "Client SDK Languages/Python/SDK Reference.md"), + page("C# Quickstart", "sdks/c-sharp/quickstart", "Client SDK Languages/C#/index.md"), + page("C# Reference", "sdks/c-sharp", "Client SDK Languages/C#/SDK Reference.md"), section("WebAssembly ABI"), - page("Module ABI Reference/index.md", "Module ABI Reference"), + page("Module ABI Reference", "webassembly-abi", "Module ABI Reference/index.md"), section("HTTP API"), - page("HTTP API Reference/index.md", "HTTP"), - page("HTTP API Reference/Identities.md", "`/identity`"), - page("HTTP API Reference/Databases.md", "`/database`"), - page("HTTP API Reference/Energy.md", "`/energy`"), + page("HTTP", "http", "HTTP API Reference/index.md"), + page("`/identity`", "http/identity", "HTTP API Reference/Identities.md"), + page("`/database`", "http/database", "HTTP API Reference/Databases.md"), + page("`/energy`", "http/energy", "HTTP API Reference/Energy.md"), section("WebSocket API Reference"), - page("WebSocket API Reference/index.md", "WebSocket"), + page("WebSocket", "ws", "WebSocket API Reference/index.md"), section("Data Format"), - page("SATN Reference/index.md", "SATN"), - page("SATN Reference/Binary Format.md", "BSATN"), + page("SATN", "satn", "SATN Reference/index.md"), + page("BSATN", "bsatn", "SATN Reference/Binary Format.md"), section("SQL"), - page("SQL Reference/index.md", "SQL Reference"), + page("SQL Reference", "sql", "SQL Reference/index.md"), ], }; From adfd936dd7c8fe86ac85a2471f221e92260c90a5 Mon Sep 17 00:00:00 2001 From: Ingvar Stepanyan Date: Mon, 27 Nov 2023 20:41:01 +0000 Subject: [PATCH 019/164] Ask users to install .NET 8 --- docs/Server Module Languages/C#/index.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/Server Module Languages/C#/index.md b/docs/Server Module Languages/C#/index.md index 0346157f..d5638423 100644 --- a/docs/Server Module Languages/C#/index.md +++ b/docs/Server Module Languages/C#/index.md @@ -14,9 +14,9 @@ A reducer is a function which traverses and updates the database. Each reducer c If you haven't already, start by [installing SpacetimeDB](/install). This will install the `spacetime` command line interface (CLI), which contains all the functionality for interacting with SpacetimeDB. -## Install .NET +## Install .NET 8 -Next we need to [install .NET](https://dotnet.microsoft.com/en-us/download/dotnet) so that we can build and publish our module. +Next we need to [install .NET 8 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/8.0) so that we can build and publish our module. .NET 8.0 is the earliest to have the `wasi-experimental` workload that we rely on. ## Project structure From f43e54971655c429b782f9362632a7e589abbb02 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Mon, 27 Nov 2023 23:58:02 -0800 Subject: [PATCH 020/164] Fix most (but possibly not all) links in the docs --- docs/Client SDK Languages/C#/SDK Reference.md | 6 ++-- docs/Client SDK Languages/C#/index.md | 2 +- .../Python/SDK Reference.md | 2 +- docs/Client SDK Languages/Python/index.md | 2 +- docs/Client SDK Languages/Rust/index.md | 4 +-- docs/Client SDK Languages/Typescript/index.md | 2 +- docs/Client SDK Languages/index.md | 8 ++--- docs/Getting Started/index.md | 14 ++++---- docs/HTTP API Reference/Databases.md | 34 +++++++++---------- docs/HTTP API Reference/Energy.md | 2 +- docs/HTTP API Reference/Identities.md | 6 ++-- docs/HTTP API Reference/index.md | 4 +-- docs/Overview/index.md | 18 +++++----- docs/SATN Reference/index.md | 4 +-- docs/SQL Reference/index.md | 2 +- docs/Server Module Languages/C#/index.md | 2 +- docs/Server Module Languages/Rust/index.md | 4 +-- docs/Server Module Languages/index.md | 8 ++--- docs/Unity Tutorial/Part 3 - BitCraft Mini.md | 2 +- docs/WebSocket API Reference/index.md | 12 +++---- 20 files changed, 69 insertions(+), 69 deletions(-) diff --git a/docs/Client SDK Languages/C#/SDK Reference.md b/docs/Client SDK Languages/C#/SDK Reference.md index ad4c8c48..473ca1ba 100644 --- a/docs/Client SDK Languages/C#/SDK Reference.md +++ b/docs/Client SDK Languages/C#/SDK Reference.md @@ -44,7 +44,7 @@ The SpacetimeDB client C# for Rust contains all the tools you need to build nati - [Static Property `AuthToken.Token`](#static-property-authtokentoken) - [Static Method `AuthToken.SaveToken`](#static-method-authtokensavetoken) - [Class `Identity`](#class-identity) - - [Class `Address`](#class-address) + - [Class `Identity`](#class-identity-1) - [Customizing logging](#customizing-logging) - [Interface `ISpacetimeDBLogger`](#interface-ispacetimedblogger) - [Class `ConsoleLogger`](#class-consolelogger) @@ -60,7 +60,7 @@ If you would like to create a console application using .NET, you can create a n dotnet add package spacetimedbsdk ``` -(See also the [CSharp Quickstart](./CSharpSDKQuickStart) for an in-depth example of such a console application.) +(See also the [CSharp Quickstart](/docs/modules/c-sharp/quickstart) for an in-depth example of such a console application.) ### Using Unity @@ -70,7 +70,7 @@ https://sdk.spacetimedb.com/SpacetimeDBUnitySDK.unitypackage In Unity navigate to the `Assets > Import Package > Custom Package...` menu in the menu bar. Select your `SpacetimeDBUnitySDK.unitypackage` file and leave all folders checked. -(See also the [Unity Quickstart](./UnityQuickStart) and [Unity Tutorial](./UnityTutorialPart1).) +(See also the [Unity Tutorial](/docs/unity/part-1).) ## Generate module bindings diff --git a/docs/Client SDK Languages/C#/index.md b/docs/Client SDK Languages/C#/index.md index f4d8b7ee..f7565019 100644 --- a/docs/Client SDK Languages/C#/index.md +++ b/docs/Client SDK Languages/C#/index.md @@ -6,7 +6,7 @@ We'll implement a command-line client for the module created in our Rust or C# M ## Project structure -Enter the directory `quickstart-chat` you created in the [Rust Module Quickstart](/docs/server-languages/rust/rust-module-quickstart-guide) or [C# Module Quickstart](/docs/server-languages/csharp/csharp-module-quickstart-guide) guides: +Enter the directory `quickstart-chat` you created in the [Rust Module Quickstart](/docs/modules/rust/quickstart) or [C# Module Quickstart](/docs/modules/c-sharp/quickstart) guides: ```bash cd quickstart-chat diff --git a/docs/Client SDK Languages/Python/SDK Reference.md b/docs/Client SDK Languages/Python/SDK Reference.md index 276d59df..8b1ceb8b 100644 --- a/docs/Client SDK Languages/Python/SDK Reference.md +++ b/docs/Client SDK Languages/Python/SDK Reference.md @@ -253,7 +253,7 @@ Run the client. This function will not return until the client is closed. If `auth_token` is not None, they will be passed to the new connection to identify and authenticate the user. Otherwise, a new Identity and auth token will be generated by the server. An optional [local_config](#local_config) module can be used to store the user's auth token to local storage. -If you are connecting to SpacetimeDB Cloud `testnet` the host should be `testnet.spacetimedb.com` and `ssl_enabled` should be `True`. If you are connecting to SpacetimeDB Standalone locally, the host should be `localhost:3000` and `ssl_enabled` should be `False`. For instructions on how to deploy to these environments, see the [Deployment Section](/docs/DeploymentOverview.md) +If you are connecting to SpacetimeDB Cloud `testnet` the host should be `testnet.spacetimedb.com` and `ssl_enabled` should be `True`. If you are connecting to SpacetimeDB Standalone locally, the host should be `localhost:3000` and `ssl_enabled` should be `False`. For instructions on how to deploy to these environments, see the [Deployment Section](/docs/deploying/testnet) ```python asyncio.run( diff --git a/docs/Client SDK Languages/Python/index.md b/docs/Client SDK Languages/Python/index.md index 25723fcc..2b9d7aa1 100644 --- a/docs/Client SDK Languages/Python/index.md +++ b/docs/Client SDK Languages/Python/index.md @@ -2,7 +2,7 @@ In this guide, we'll show you how to get up and running with a simple SpacetimDB app with a client written in Python. -We'll implement a command-line client for the module created in our [Rust Module Quickstart](/docs/languages/rust/rust-module-quickstart-guide) or [C# Module Quickstart](/docs/languages/csharp/csharp-module-reference) guides. Make sure you follow one of these guides before you start on this one. +We'll implement a command-line client for the module created in our [Rust Module Quickstart](/docs/modules/rust/quickstart) or [C# Module Quickstart](/docs/modules/c-charp/quickstart) guides. Make sure you follow one of these guides before you start on this one. ## Install the SpacetimeDB SDK Python Package diff --git a/docs/Client SDK Languages/Rust/index.md b/docs/Client SDK Languages/Rust/index.md index f35f0829..d1969fc3 100644 --- a/docs/Client SDK Languages/Rust/index.md +++ b/docs/Client SDK Languages/Rust/index.md @@ -6,7 +6,7 @@ We'll implement a command-line client for the module created in our Rust or C# M ## Project structure -Enter the directory `quickstart-chat` you created in the [Rust Module Quickstart](/docs/server-languages/rust/rust-module-quickstart-guide) or [C# Module Quickstart](/docs/server-languages/csharp/csharp-module-reference) guides: +Enter the directory `quickstart-chat` you created in the [Rust Module Quickstart](/docs/modules/rust/quickstart) or [C# Module Quickstart](/docs/modules/c-sharp/quickstart) guides: ```bash cd quickstart-chat @@ -471,7 +471,7 @@ User connected. You can find the full code for this client [in the Rust SDK's examples](https://github.com/clockworklabs/SpacetimeDB/tree/master/crates/sdk/examples/quickstart-chat). -Check out the [Rust SDK Reference](/docs/client-languages/rust/rust-sdk-reference) for a more comprehensive view of the SpacetimeDB Rust SDK. +Check out the [Rust SDK Reference](/docs/sdks/rust) for a more comprehensive view of the SpacetimeDB Rust SDK. Our bare-bones terminal interface has some quirks. Incoming messages can appear while the user is typing and be spliced into the middle of user input, which is less than ideal. Also, the user's input is interspersed with the program's output, so messages the user sends will seem to appear twice. Why not try building a better interface using [Rustyline](https://crates.io/crates/rustyline), [Cursive](https://crates.io/crates/cursive), or even a full-fledged GUI? We went for the Cursive route, and you can check out what we came up with [in the Rust SDK's examples](https://github.com/clockworklabs/SpacetimeDB/tree/master/crates/sdk/examples/cursive-chat). diff --git a/docs/Client SDK Languages/Typescript/index.md b/docs/Client SDK Languages/Typescript/index.md index 8baed6fb..ab7cfe89 100644 --- a/docs/Client SDK Languages/Typescript/index.md +++ b/docs/Client SDK Languages/Typescript/index.md @@ -6,7 +6,7 @@ We'll implement a basic single page web app for the module created in our Rust o ## Project structure -Enter the directory `quickstart-chat` you created in the [Rust Module Quickstart](/docs/server-languages/rust/rust-module-quickstart-guide) or [C# Module Quickstart](/docs/server-languages/csharp/csharp-module-reference) guides: +Enter the directory `quickstart-chat` you created in the [Rust Module Quickstart](/docs/modules/rust/quickstart) or [C# Module Quickstart](/docs/modules/c-sharp/quickstart) guides: ```bash cd quickstart-chat diff --git a/docs/Client SDK Languages/index.md b/docs/Client SDK Languages/index.md index 2e3e0740..6357e653 100644 --- a/docs/Client SDK Languages/index.md +++ b/docs/Client SDK Languages/index.md @@ -2,10 +2,10 @@ The SpacetimeDB Client SDKs provide a comprehensive interface to interact with the SpacetimeDB server engine from various programming languages. Currently, SDKs are available for -- [Rust](/docs/client-languages/rust/rust-sdk-reference) - [(Quickstart)](/docs/client-languages/rust/rust-sdk-quickstart-guide) -- [C#](/docs/client-languages/csharp/csharp-sdk-reference) - [(Quickstart)](/docs/client-languages/csharp/csharp-sdk-quickstart-guide) -- [TypeScript](/docs/client-languages/typescript/typescript-sdk-reference) - [(Quickstart)](client-languages/typescript/typescript-sdk-quickstart-guide) -- [Python](/docs/client-languages/python/python-sdk-reference) - [(Quickstart)](/docs/python/python-sdk-quickstart-guide) +- [Rust](/docs/sdks/rust) - [(Quickstart)](/docs/sdks/rust/quickstart) +- [C#](/docs/sdks/c-sharp) - [(Quickstart)](/docs/sdks/c-sharp/quickstart) +- [TypeScript](/docs/sdks/typescript) - [(Quickstart)](/docs/sdks/typescript/quickstart) +- [Python](/docs/sdks/python) - [(Quickstart)](/docs/sdks/python/quickstart) ## Key Features diff --git a/docs/Getting Started/index.md b/docs/Getting Started/index.md index 854d227c..54337d08 100644 --- a/docs/Getting Started/index.md +++ b/docs/Getting Started/index.md @@ -23,14 +23,14 @@ spacetime server set "http://localhost:3000" You are ready to start developing SpacetimeDB modules. We have a quickstart guide for each supported server-side language: -- [Rust](/docs/server-languages/rust/rust-module-quickstart-guide) -- [C#](/docs/server-languages/csharp/csharp-module-quickstart-guide) +- [Rust](/docs/modules/rust/quickstart) +- [C#](/docs/modules/c-sharp/quickstart) Then you can write your client application. We have a quickstart guide for each supported client-side language: -- [Rust](/docs/client-languages/rust/rust-sdk-quickstart-guide) -- [C#](/docs/client-languages/csharp/csharp-sdk-quickstart-guide) -- [Typescript](/docs/client-languages/typescript/typescript-sdk-quickstart-guide) -- [Python](/docs/client-languages/python/python-sdk-quickstart-guide) +- [Rust](/docs/sdks/rust/quickstart) +- [C#](/docs/sdks/c-sharp/quickstart) +- [Typescript](/docs/sdks/typescript/quickstart) +- [Python](/docs/sdks/python/quickstart) -We also have a [step-by-step tutorial](/docs/unity-tutorial/unity-tutorial-part-1) for building a multiplayer game in Unity3d. +We also have a [step-by-step tutorial](/docs/unity/part-1) for building a multiplayer game in Unity3d. diff --git a/docs/HTTP API Reference/Databases.md b/docs/HTTP API Reference/Databases.md index 91e7d0a9..2d55188a 100644 --- a/docs/HTTP API Reference/Databases.md +++ b/docs/HTTP API Reference/Databases.md @@ -15,7 +15,7 @@ The HTTP endpoints in `/database` allow clients to interact with Spacetime datab | [`/database/confirm_recovery_code GET`](#databaseconfirm_recovery_code-get) | Recover a login token from a recovery code. | | [`/database/publish POST`](#databasepublish-post) | Publish a database given its module code. | | [`/database/delete/:address POST`](#databasedeleteaddress-post) | Delete a database. | -| [`/database/subscribe/:name_or_address GET`](#databasesubscribename_or_address-get) | Begin a [WebSocket connection](/docs/websocket-api-reference). | +| [`/database/subscribe/:name_or_address GET`](#databasesubscribename_or_address-get) | Begin a [WebSocket connection](/docs/ws). | | [`/database/call/:name_or_address/:reducer POST`](#databasecallname_or_addressreducer-post) | Invoke a reducer in a database. | | [`/database/schema/:name_or_address GET`](#databaseschemaname_or_address-get) | Get the schema for a database. | | [`/database/schema/:name_or_address/:entity_type/:entity GET`](#databaseschemaname_or_addressentity_typeentity-get) | Get a schema for a particular table or reducer. | @@ -94,7 +94,7 @@ Accessible through the CLI as `spacetime dns set-name
`. | Name | Value | | --------------- | ------------------------------------------------------------------------------------------- | -| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). | +| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http). | #### Returns @@ -147,7 +147,7 @@ Accessible through the CLI as `spacetime dns register-tld `. | Name | Value | | --------------- | ------------------------------------------------------------------------------------------- | -| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). | +| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http). | #### Returns @@ -186,7 +186,7 @@ Accessible through the CLI as `spacetime identity recover `. | Name | Value | | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `identity` | The identity whose token should be recovered. | -| `email` | The email to send the recovery code or link to. This email must be associated with the identity, either during creation via [`/identity`](/docs/http-api-reference/identities#identity-post) or afterwards via [`/identity/:identity/set-email`](/docs/http-api-reference/identities#identityidentityset_email-post). | +| `email` | The email to send the recovery code or link to. This email must be associated with the identity, either during creation via [`/identity`](/docs/http/identity#identity-post) or afterwards via [`/identity/:identity/set-email`](/docs/http/identity#identityidentityset_email-post). | | `link` | A boolean; whether to send a clickable link rather than a recovery code. | ## `/database/confirm_recovery_code GET` @@ -231,7 +231,7 @@ Accessible through the CLI as `spacetime publish`. | Name | Value | | --------------- | ------------------------------------------------------------------------------------------- | -| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). | +| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http). | #### Data @@ -283,11 +283,11 @@ Accessible through the CLI as `spacetime delete
`. | Name | Value | | --------------- | ------------------------------------------------------------------------------------------- | -| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). | +| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http). | ## `/database/subscribe/:name_or_address GET` -Begin a [WebSocket connection](/docs/websocket-api-reference) with a database. +Begin a [WebSocket connection](/docs/ws) with a database. #### Parameters @@ -301,7 +301,7 @@ For more information about WebSocket headers, see [RFC 6455](https://datatracker | Name | Value | | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| `Sec-WebSocket-Protocol` | [`v1.bin.spacetimedb`](/docs/websocket-api-reference#binary-protocol) or [`v1.text.spacetimedb`](/docs/websocket-api-reference#text-protocol). | +| `Sec-WebSocket-Protocol` | [`v1.bin.spacetimedb`](/docs/ws#binary-protocol) or [`v1.text.spacetimedb`](/docs/ws#text-protocol). | | `Connection` | `Updgrade` | | `Upgrade` | `websocket` | | `Sec-WebSocket-Version` | `13` | @@ -311,7 +311,7 @@ For more information about WebSocket headers, see [RFC 6455](https://datatracker | Name | Value | | --------------- | ------------------------------------------------------------------------------------------- | -| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). | +| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http). | ## `/database/call/:name_or_address/:reducer POST` @@ -328,7 +328,7 @@ Invoke a reducer in a database. | Name | Value | | --------------- | ------------------------------------------------------------------------------------------- | -| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). | +| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http). | #### Data @@ -448,9 +448,9 @@ The `"entities"` will be an object whose keys are table and reducer names, and w | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `arity` | For tables, the number of colums; for reducers, the number of arguments. | | `type` | For tables, `"table"`; for reducers, `"reducer"`. | -| `schema` | A [JSON-encoded `ProductType`](/docs/satn-reference/satn-reference-json-format); for tables, the table schema; for reducers, the argument schema. Only present if `expand` is supplied and true. | +| `schema` | A [JSON-encoded `ProductType`](/docs/satn); for tables, the table schema; for reducers, the argument schema. Only present if `expand` is supplied and true. | -The `"typespace"` will be a JSON array of [`AlgebraicType`s](/docs/satn-reference/satn-reference-json-format) referenced by the module. This can be used to resolve `Ref` types within the schema; the type `{ "Ref": n }` refers to `response["typespace"][n]`. +The `"typespace"` will be a JSON array of [`AlgebraicType`s](/docs/satn) referenced by the module. This can be used to resolve `Ref` types within the schema; the type `{ "Ref": n }` refers to `response["typespace"][n]`. ## `/database/schema/:name_or_address/:entity_type/:entity GET` @@ -488,7 +488,7 @@ Returns a single entity in the same format as in the `"entities"` returned by [t | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `arity` | For tables, the number of colums; for reducers, the number of arguments. | | `type` | For tables, `"table"`; for reducers, `"reducer"`. | -| `schema` | A [JSON-encoded `ProductType`](/docs/satn-reference/satn-reference-json-format); for tables, the table schema; for reducers, the argument schema. Only present if `expand` is supplied and true. | +| `schema` | A [JSON-encoded `ProductType`](/docs/satn); for tables, the table schema; for reducers, the argument schema. Only present if `expand` is supplied and true. | ## `/database/info/:name_or_address GET` @@ -545,7 +545,7 @@ Accessible through the CLI as `spacetime logs `. | Name | Value | | --------------- | ------------------------------------------------------------------------------------------- | -| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). | +| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http). | #### Returns @@ -567,7 +567,7 @@ Accessible through the CLI as `spacetime sql `. | Name | Value | | --------------- | ------------------------------------------------------------------------------------------- | -| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). | +| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http). | #### Data @@ -584,6 +584,6 @@ Returns a JSON array of statement results, each of which takes the form: } ``` -The `schema` will be a [JSON-encoded `ProductType`](/docs/satn-reference/satn-reference-json-format) describing the type of the returned rows. +The `schema` will be a [JSON-encoded `ProductType`](/docs/satn) describing the type of the returned rows. -The `rows` will be an array of [JSON-encoded `ProductValue`s](/docs/satn-reference/satn-reference-json-format), each of which conforms to the `schema`. +The `rows` will be an array of [JSON-encoded `ProductValue`s](/docs/satn), each of which conforms to the `schema`. diff --git a/docs/HTTP API Reference/Energy.md b/docs/HTTP API Reference/Energy.md index a7b6d05a..b49a1ee7 100644 --- a/docs/HTTP API Reference/Energy.md +++ b/docs/HTTP API Reference/Energy.md @@ -59,7 +59,7 @@ Accessible through the CLI as `spacetime energy set-balance | Name | Value | | --------------- | ------------------------------------------------------------------------------------------- | -| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). | +| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http). | #### Returns diff --git a/docs/HTTP API Reference/Identities.md b/docs/HTTP API Reference/Identities.md index 87411759..5fb45867 100644 --- a/docs/HTTP API Reference/Identities.md +++ b/docs/HTTP API Reference/Identities.md @@ -73,7 +73,7 @@ Generate a short-lived access token which can be used in untrusted contexts, e.g | Name | Value | | --------------- | ------------------------------------------------------------------------------------------- | -| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). | +| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http). | #### Returns @@ -109,7 +109,7 @@ Accessible through the CLI as `spacetime identity set-email `. | Name | Value | | --------------- | ------------------------------------------------------------------------------------------- | -| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). | +| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http). | ## `/identity/:identity/databases GET` @@ -147,7 +147,7 @@ Verify the validity of an identity/token pair. | Name | Value | | --------------- | ------------------------------------------------------------------------------------------- | -| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). | +| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http). | #### Returns diff --git a/docs/HTTP API Reference/index.md b/docs/HTTP API Reference/index.md index 224aaf77..a4e885b1 100644 --- a/docs/HTTP API Reference/index.md +++ b/docs/HTTP API Reference/index.md @@ -6,9 +6,9 @@ Rather than a password, each Spacetime identity is associated with a private tok ### Generating identities and tokens -Clients can request a new identity and token via [the `/identity POST` HTTP endpoint](/docs/http-api-reference/identities#identity-post). +Clients can request a new identity and token via [the `/identity POST` HTTP endpoint](/docs/http/identity#identity-post). -Alternately, a new identity and token will be generated during an anonymous connection via the [WebSocket API](/docs/websocket-api-reference), and passed to the client as [an `IdentityToken` message](/docs/websocket-api-reference#identitytoken). +Alternately, a new identity and token will be generated during an anonymous connection via the [WebSocket API](/docs/ws), and passed to the client as [an `IdentityToken` message](/docs/ws#identitytoken). ### Encoding `Authorization` headers diff --git a/docs/Overview/index.md b/docs/Overview/index.md index 35ebbcb7..7a95f4f8 100644 --- a/docs/Overview/index.md +++ b/docs/Overview/index.md @@ -52,7 +52,7 @@ Each identity has a corresponding authentication token. The authentication token Additionally, each database has an owner `Identity`. Many database maintenance operations, like publishing a new version or evaluating arbitrary SQL queries, are restricted to only authenticated connections by the owner. -SpacetimeDB provides tools in the CLI and the [client SDKs](/docs/client-languages/client-sdk-overview) for managing credentials. +SpacetimeDB provides tools in the CLI and the [client SDKs](/docs/sdks) for managing credentials. ## Addresses @@ -68,8 +68,8 @@ Each client connection has an `Address`. These addresses are opaque, and do not Currently, Rust is the best-supported language for writing SpacetimeDB modules. Support for lots of other languages is in the works! -- [Rust](/docs/server-languages/rust/rust-module-reference) - [(Quickstart)](/docs/server-languages/rust/rust-module-quickstart-guide) -- [C#](/docs/server-languages/csharp/csharp-module-reference) - [(Quickstart)](/docs/server-languages/csharp/csharp-module-quickstart-guide) +- [Rust](/docs/modules/rust) - [(Quickstart)](/docs/modules/rust/quickstart) +- [C#](/docs/modules/c-sharp) - [(Quickstart)](/docs/modules/c-sharp/quickstart) - Python (Coming soon) - C# (Coming soon) - Typescript (Coming soon) @@ -78,16 +78,16 @@ Currently, Rust is the best-supported language for writing SpacetimeDB modules. ### Client-side SDKs -- [Rust](/docs/client-languages/rust/rust-sdk-reference) - [(Quickstart)](/docs/client-languages/rust/rust-sdk-quickstart-guide) -- [C#](/docs/client-languages/csharp/csharp-sdk-reference) - [(Quickstart)](/docs/client-languages/csharp/csharp-sdk-quickstart-guide) -- [TypeScript](/docs/client-languages/typescript/typescript-sdk-reference) - [(Quickstart)](client-languages/typescript/typescript-sdk-quickstart-guide) -- [Python](/docs/client-languages/python/python-sdk-reference) - [(Quickstart)](/docs/python/python-sdk-quickstart-guide) +- [Rust](/docs/sdks/rust) - [(Quickstart)](/docs/sdks/rust/quickstart) +- [C#](/docs/sdks/c-sharp) - [(Quickstart)](/docs/sdks/c-sharp/quickstart) +- [TypeScript](/docs/sdks/typescript) - [(Quickstart)](/docs/sdks/typescript/quickstart) +- [Python](/docs/sdks/python) - [(Quickstart)](/docs/sdks/python/quickstart) - C++ (Planned) - Lua (Planned) ### Unity -SpacetimeDB was designed first and foremost as the backend for multiplayer Unity games. To learn more about using SpacetimeDB with Unity, jump on over to the [SpacetimeDB Unity Tutorial](/docs/unity-tutorial/unity-tutorial-part-1). +SpacetimeDB was designed first and foremost as the backend for multiplayer Unity games. To learn more about using SpacetimeDB with Unity, jump on over to the [SpacetimeDB Unity Tutorial](/docs/unity/part-1). ## FAQ @@ -101,7 +101,7 @@ SpacetimeDB was designed first and foremost as the backend for multiplayer Unity Just install our command line tool and then upload your application to the cloud. 1. How do I create a new database with SpacetimeDB? - Follow our [Quick Start](/docs/quick-start) guide! + Follow our [Quick Start](/docs/getting-started) guide! TL;DR in an empty directory: diff --git a/docs/SATN Reference/index.md b/docs/SATN Reference/index.md index cedc496a..f21e9b30 100644 --- a/docs/SATN Reference/index.md +++ b/docs/SATN Reference/index.md @@ -1,6 +1,6 @@ # SATN JSON Format -The Spacetime Algebraic Type Notation JSON format defines how Spacetime `AlgebraicType`s and `AlgebraicValue`s are encoded as JSON. Algebraic types and values are JSON-encoded for transport via the [HTTP Databases API](/docs/http-api-reference/databases) and the [WebSocket text protocol](/docs/websocket-api-reference#text-protocol). +The Spacetime Algebraic Type Notation JSON format defines how Spacetime `AlgebraicType`s and `AlgebraicValue`s are encoded as JSON. Algebraic types and values are JSON-encoded for transport via the [HTTP Databases API](/docs/http/database) and the [WebSocket text protocol](/docs/ws#text-protocol). ## Values @@ -160,4 +160,4 @@ SATS array and map types are homogeneous, meaning that each array has a single e ### `AlgebraicTypeRef` -`AlgebraicTypeRef`s are JSON-encoded as non-negative integers. These are indices into a typespace, like the one returned by the [`/database/schema/:name_or_address GET` HTTP endpoint](/docs/http-api-reference/databases#databaseschemaname_or_address-get). +`AlgebraicTypeRef`s are JSON-encoded as non-negative integers. These are indices into a typespace, like the one returned by the [`/database/schema/:name_or_address GET` HTTP endpoint](/docs/http/database#databaseschemaname_or_address-get). diff --git a/docs/SQL Reference/index.md b/docs/SQL Reference/index.md index 08f9536a..66097209 100644 --- a/docs/SQL Reference/index.md +++ b/docs/SQL Reference/index.md @@ -1,6 +1,6 @@ # SQL Support -SpacetimeDB supports a subset of SQL as a query language. Developers can evaluate SQL queries against a Spacetime database via the `spacetime sql` command-line tool and the [`/database/sql/:name_or_address POST` HTTP endpoint](/docs/http-api-reference/databases#databasesqlname_or_address-post). Client developers also write SQL queries when subscribing to events in the [WebSocket API](/docs/websocket-api-reference#subscribe) or via an SDK `subscribe` function. +SpacetimeDB supports a subset of SQL as a query language. Developers can evaluate SQL queries against a Spacetime database via the `spacetime sql` command-line tool and the [`/database/sql/:name_or_address POST` HTTP endpoint](/docs/http/database#databasesqlname_or_address-post). Client developers also write SQL queries when subscribing to events in the [WebSocket API](/docs/ws#subscribe) or via an SDK `subscribe` function. SpacetimeDB aims to support much of the [SQL 2016 standard](https://www.iso.org/standard/63555.html), and in particular aims to be compatible with [PostgreSQL](https://www.postgresql.org/). diff --git a/docs/Server Module Languages/C#/index.md b/docs/Server Module Languages/C#/index.md index 0346157f..3d543f4a 100644 --- a/docs/Server Module Languages/C#/index.md +++ b/docs/Server Module Languages/C#/index.md @@ -288,4 +288,4 @@ spacetime sql "SELECT * FROM Message" You've just set up your first database in SpacetimeDB! The next step would be to create a client module that interacts with this module. You can use any of SpacetimDB's supported client languages to do this. Take a look at the quick start guide for your client language of choice: [Rust](/docs/languages/rust/rust-sdk-quickstart-guide), [C#](/docs/languages/csharp/csharp-sdk-quickstart-guide), [TypeScript](/docs/languages/typescript/typescript-sdk-quickstart-guide) or [Python](/docs/languages/python/python-sdk-quickstart-guide). -If you are planning to use SpacetimeDB with the Unity3d game engine, you can skip right to the [Unity Comprehensive Tutorial](/docs/game-dev/unity-tutorial) or check out our example game, [BitcraftMini](/docs/game-dev/unity-tutorial-bitcraft-mini). +If you are planning to use SpacetimeDB with the Unity3d game engine, you can skip right to the [Unity Comprehensive Tutorial](/docs/unity/part-1) or check out our example game, [BitcraftMini](/docs/unity/part-3). diff --git a/docs/Server Module Languages/Rust/index.md b/docs/Server Module Languages/Rust/index.md index 9f0a6636..6e0f1747 100644 --- a/docs/Server Module Languages/Rust/index.md +++ b/docs/Server Module Languages/Rust/index.md @@ -267,6 +267,6 @@ spacetime sql "SELECT * FROM Message" You can find the full code for this module [in the SpacetimeDB module examples](https://github.com/clockworklabs/SpacetimeDB/tree/master/modules/quickstart-chat). -You've just set up your first database in SpacetimeDB! The next step would be to create a client module that interacts with this module. You can use any of SpacetimDB's supported client languages to do this. Take a look at the quickstart guide for your client language of choice: [Rust](/docs/client-languages/rust/rust-sdk-quickstart-guide), [C#](/docs/client-languages/csharp/csharp-sdk-quickstart-guide), [TypeScript](/docs/client-languages/typescript/typescript-sdk-quickstart-guide) or [Python](/docs/client-languages/python/python-sdk-quickstart-guide). +You've just set up your first database in SpacetimeDB! The next step would be to create a client module that interacts with this module. You can use any of SpacetimDB's supported client languages to do this. Take a look at the quickstart guide for your client language of choice: [Rust](/docs/sdks/rust/quickstart), [C#](/docs/sdks/c-sharp/quickstart), [TypeScript](/docs/sdks/typescript/quickstart) or [Python](/docs/sdks/python/quickstart). -If you are planning to use SpacetimeDB with the Unity3d game engine, you can skip right to the [Unity Comprehensive Tutorial](/docs/game-dev/unity-tutorial) or check out our example game, [BitcraftMini](/docs/game-dev/unity-tutorial-bitcraft-mini). +If you are planning to use SpacetimeDB with the Unity3d game engine, you can skip right to the [Unity Comprehensive Tutorial](/docs/unity/part-1) or check out our example game, [BitcraftMini](/docs/unity/part-3). diff --git a/docs/Server Module Languages/index.md b/docs/Server Module Languages/index.md index d6668131..d7d13685 100644 --- a/docs/Server Module Languages/index.md +++ b/docs/Server Module Languages/index.md @@ -10,15 +10,15 @@ In the following sections, we'll cover the basics of server modules and how to c As of SpacetimeDB 0.6, Rust is the only fully supported language for server modules. Rust is a great option for server modules because it is fast, safe, and has a small runtime. -- [Rust Module Reference](/docs/server-languages/rust/rust-module-reference) -- [Rust Module Quickstart Guide](/docs/server-languages/rust/rust-module-quickstart-guide) +- [Rust Module Reference](/docs/modules/rust) +- [Rust Module Quickstart Guide](/docs/modules/rust/quickstart) ### C# We have C# support available in experimental status. C# can be a good choice for developers who are already using Unity or .net for their client applications. -- [C# Module Reference](/docs/server-languages/csharp/csharp-module-reference) -- [C# Module Quickstart Guide](/docs/server-languages/csharp/csharp-module-quickstart-guide) +- [C# Module Reference](/docs/modules/c-sharp) +- [C# Module Quickstart Guide](/docs/modules/c-sharp/quickstart) ### Coming Soon diff --git a/docs/Unity Tutorial/Part 3 - BitCraft Mini.md b/docs/Unity Tutorial/Part 3 - BitCraft Mini.md index 16816dd6..b49b5a5d 100644 --- a/docs/Unity Tutorial/Part 3 - BitCraft Mini.md +++ b/docs/Unity Tutorial/Part 3 - BitCraft Mini.md @@ -79,7 +79,7 @@ Open the Main scene in Unity and click on the `GameManager` object in the heirar ![GameManager-Inspector](/images/unity-tutorial/GameManager-Inspector.JPG) -Update the module address with the address you got from the `spacetime publish` command. If you are using SpacetimeDB Cloud `testnet`, the host name should be `testnet.spacetimedb.com` and SSL Enabled should be checked. If you are running SpacetimeDB Standalone locally, the host name should be `localhost:3000` and SSL Enabled should be unchecked. For instructions on how to deploy to these environments, see the [Deployment Section](/docs/DeploymentOverview.md) +Update the module address with the address you got from the `spacetime publish` command. If you are using SpacetimeDB Cloud `testnet`, the host name should be `testnet.spacetimedb.com` and SSL Enabled should be checked. If you are running SpacetimeDB Standalone locally, the host name should be `localhost:3000` and SSL Enabled should be unchecked. For instructions on how to deploy to these environments, see the [Deployment Section](/docs/deploying/testnet) ## 4. Play Mode diff --git a/docs/WebSocket API Reference/index.md b/docs/WebSocket API Reference/index.md index dd8fbc39..76240163 100644 --- a/docs/WebSocket API Reference/index.md +++ b/docs/WebSocket API Reference/index.md @@ -6,9 +6,9 @@ The SpacetimeDB SDKs comminicate with their corresponding database using the Web ## Connecting -To initiate a WebSocket connection, send a `GET` request to the [`/database/subscribe/:name_or_address` endpoint](/docs/http-api-reference/databases#databasesubscribename_or_address-get) with headers appropriate to upgrade to a WebSocket connection as per [RFC 6455](https://datatracker.ietf.org/doc/html/rfc6455). +To initiate a WebSocket connection, send a `GET` request to the [`/database/subscribe/:name_or_address` endpoint](/docs/http/database#databasesubscribename_or_address-get) with headers appropriate to upgrade to a WebSocket connection as per [RFC 6455](https://datatracker.ietf.org/doc/html/rfc6455). -To re-connect with an existing identity, include its token in a [SpacetimeDB Authorization header](/docs/http-api-reference/authorization). Otherwise, a new identity and token will be generated for the client. +To re-connect with an existing identity, include its token in a [SpacetimeDB Authorization header](/docs/http). Otherwise, a new identity and token will be generated for the client. ## Protocols @@ -21,13 +21,13 @@ Clients connecting via WebSocket can choose between two protocols, [`v1.bin.spac ### Binary Protocol -The SpacetimeDB binary WebSocket protocol, `v1.bin.spacetimedb`, encodes messages using [ProtoBuf 3](https://protobuf.dev), and reducer and row data using [BSATN](/docs/satn-reference/satn-reference-binary-format). +The SpacetimeDB binary WebSocket protocol, `v1.bin.spacetimedb`, encodes messages using [ProtoBuf 3](https://protobuf.dev), and reducer and row data using [BSATN](/docs/bsatn). The binary protocol's messages are defined in [`client_api.proto`](https://github.com/clockworklabs/SpacetimeDB/blob/master/crates/client-api-messages/protobuf/client_api.proto). ### Text Protocol -The SpacetimeDB text WebSocket protocol, `v1.text.spacetimedb`, encodes messages, reducer and row data as JSON. Reducer arguments and table rows are JSON-encoded according to the [SATN JSON format](/docs/satn-reference/satn-reference-json-format). +The SpacetimeDB text WebSocket protocol, `v1.text.spacetimedb`, encodes messages, reducer and row data as JSON. Reducer arguments and table rows are JSON-encoded according to the [SATN JSON format](/docs/satn). ## Messages @@ -82,7 +82,7 @@ SpacetimeDB responds to each `Subscribe` message with a [`SubscriptionUpdate` me Each `Subscribe` message establishes a new set of subscriptions, replacing all previous subscriptions. Clients which want to add a query to an existing subscription must send a `Subscribe` message containing all the previous queries in addition to the new query. In this case, the returned [`SubscriptionUpdate`](#subscriptionupdate) will contain all previously-subscribed rows in addition to the newly-subscribed rows. -Each query must be a SQL `SELECT * FROM` statement on a single table with an optional `WHERE` clause. See the [SQL Reference](/docs/sql-reference) for the subset of SQL supported by SpacetimeDB. +Each query must be a SQL `SELECT * FROM` statement on a single table with an optional `WHERE` clause. See the [SQL Reference](/docs/sql) for the subset of SQL supported by SpacetimeDB. ##### Binary: ProtoBuf definition @@ -120,7 +120,7 @@ message Subscribe { #### `IdentityToken` -Upon establishing a WebSocket connection, the server will send an `IdentityToken` message containing the client's identity and token. If the client included a [SpacetimeDB Authorization header](/docs/http-api-reference/authorization) in their connection request, the `IdentityToken` message will contain the same token used to connect, and its corresponding identity. If the client connected anonymously, SpacetimeDB will generate a new identity and token for the client. +Upon establishing a WebSocket connection, the server will send an `IdentityToken` message containing the client's identity and token. If the client included a [SpacetimeDB Authorization header](/docs/http) in their connection request, the `IdentityToken` message will contain the same token used to connect, and its corresponding identity. If the client connected anonymously, SpacetimeDB will generate a new identity and token for the client. ##### Binary: ProtoBuf definition From a7ca89ccd09a655e57d9e9f5cc2c62853be0228a Mon Sep 17 00:00:00 2001 From: Nathaniel Richards <46858886+NateTheDev1@users.noreply.github.com> Date: Tue, 28 Nov 2023 13:57:48 -0500 Subject: [PATCH 021/164] Update nav.ts --- docs/nav.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/nav.ts b/docs/nav.ts index f7681728..b9a64ee0 100644 --- a/docs/nav.ts +++ b/docs/nav.ts @@ -1,8 +1,8 @@ -export type Nav = { +type Nav = { items: NavItem[]; }; -export type NavItem = NavPage | NavSection; -export type NavPage = { +type NavItem = NavPage | NavSection; +type NavPage = { type: "page"; path: string; slug: string; From 0f86be29c1d827b1263c1b3e88b737d4c48d7646 Mon Sep 17 00:00:00 2001 From: Nathaniel Richards Date: Tue, 28 Nov 2023 15:01:39 -0500 Subject: [PATCH 022/164] Created buildeR --- .gitignore | 3 +-- docs/nav.js | 52 +++++++++++++++++++++++++++++++++++++++++++ docs/nav.ts => nav.ts | 0 package.json | 15 +++++++++++++ tsconfig.json | 8 +++++++ yarn.lock | 8 +++++++ 6 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 docs/nav.js rename docs/nav.ts => nav.ts (100%) create mode 100644 package.json create mode 100644 tsconfig.json create mode 100644 yarn.lock diff --git a/.gitignore b/.gitignore index 55f71abd..589c396e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ **/.vscode .idea *.log -node_modules -dist \ No newline at end of file +node_modules \ No newline at end of file diff --git a/docs/nav.js b/docs/nav.js new file mode 100644 index 00000000..b62e9b7f --- /dev/null +++ b/docs/nav.js @@ -0,0 +1,52 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +function page(title, slug, path, props) { + return { type: "page", path, slug, title, ...props }; +} +function section(title) { + return { type: "section", title }; +} +const nav = { + items: [ + section("Intro"), + page("Overview", "index", "Overview/index.md"), + page("Getting Started", "getting-started", "Getting Started/index.md"), + section("Deploying"), + page("Testnet", "deploying/testnet", "Cloud Testnet/index.md"), + section("Unity Tutorial"), + page("Part 1 - Basic Multiplayer", "unity/part-1", "Unity Tutorial/Part 1 - Basic Multiplayer.md"), + page("Part 2 - Resources And Scheduling", "unity/part-2", "Unity Tutorial/Part 2 - Resources And Scheduling.md"), + page("Part 3 - BitCraft Mini", "unity/part-3", "Unity Tutorial/Part 3 - BitCraft Mini.md"), + section("Server Module Languages"), + page("Overview", "modules", "Server Module Languages/index.md"), + page("Rust Quickstart", "modules/rust/quickstart", "Server Module Languages/Rust/index.md"), + page("Rust Reference", "modules/rust", "Server Module Languages/Rust/ModuleReference.md"), + page("C# Quickstart", "modules/c-sharp/quickstart", "Server Module Languages/C#/index.md"), + page("C# Reference", "modules/c-sharp", "Server Module Languages/C#/ModuleReference.md"), + section("Client SDK Languages"), + page("Overview", "sdks", "Client SDK Languages/index.md"), + page("Typescript Quickstart", "sdks/typescript/quickstart", "Client SDK Languages/Typescript/index.md"), + page("Typescript Reference", "sdks/typescript", "Client SDK Languages/Typescript/SDK Reference.md"), + page("Rust Quickstart", "sdks/rust/quickstart", "Client SDK Languages/Rust/index.md"), + page("Rust Reference", "sdks/rust", "Client SDK Languages/Rust/SDK Reference.md"), + page("Python Quickstart", "sdks/python/quickstart", "Client SDK Languages/Python/index.md"), + page("Python Reference", "sdks/python", "Client SDK Languages/Python/SDK Reference.md"), + page("C# Quickstart", "sdks/c-sharp/quickstart", "Client SDK Languages/C#/index.md"), + page("C# Reference", "sdks/c-sharp", "Client SDK Languages/C#/SDK Reference.md"), + section("WebAssembly ABI"), + page("Module ABI Reference", "webassembly-abi", "Module ABI Reference/index.md"), + section("HTTP API"), + page("HTTP", "http", "HTTP API Reference/index.md"), + page("`/identity`", "http/identity", "HTTP API Reference/Identities.md"), + page("`/database`", "http/database", "HTTP API Reference/Databases.md"), + page("`/energy`", "http/energy", "HTTP API Reference/Energy.md"), + section("WebSocket API Reference"), + page("WebSocket", "ws", "WebSocket API Reference/index.md"), + section("Data Format"), + page("SATN", "satn", "SATN Reference/index.md"), + page("BSATN", "bsatn", "SATN Reference/Binary Format.md"), + section("SQL"), + page("SQL Reference", "sql", "SQL Reference/index.md"), + ], +}; +exports.default = nav; diff --git a/docs/nav.ts b/nav.ts similarity index 100% rename from docs/nav.ts rename to nav.ts diff --git a/package.json b/package.json new file mode 100644 index 00000000..a56ea4e8 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "spacetime-docs", + "version": "1.0.0", + "description": "This repository contains the markdown files which are used to display documentation on our [website](https://spacetimedb.com/docs).", + "main": "index.js", + "dependencies": {}, + "devDependencies": { + "typescript": "^5.3.2" + }, + "scripts": { + "build": "tsc" + }, + "author": "Clockwork Labs", + "license": "ISC" +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..2a5ee7d2 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "commonjs", + "outDir": "./docs", + "skipLibCheck": true + } +} diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 00000000..fce89544 --- /dev/null +++ b/yarn.lock @@ -0,0 +1,8 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +typescript@^5.3.2: + version "5.3.2" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.2.tgz#00d1c7c1c46928c5845c1ee8d0cc2791031d4c43" + integrity sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ== From 182284bcf0808889da8f3a26762def4d90c7830a Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Tue, 28 Nov 2023 17:21:24 -0800 Subject: [PATCH 023/164] Reorganized the doc paths to match slugs and removed _category.json files --- docs/Client SDK Languages/C#/_category.json | 5 -- .../Python/_category.json | 5 -- docs/Client SDK Languages/Rust/_category.json | 5 -- .../Typescript/_category.json | 5 -- docs/Client SDK Languages/_category.json | 1 - docs/Cloud Testnet/_category.json | 1 - docs/Getting Started/_category.json | 1 - docs/HTTP API Reference/_category.json | 1 - docs/Module ABI Reference/_category.json | 1 - docs/Overview/_category.json | 1 - docs/SATN Reference/_category.json | 1 - docs/SQL Reference/_category.json | 1 - .../Server Module Languages/C#/_category.json | 6 -- .../Rust/_category.json | 5 -- docs/Server Module Languages/_category.json | 1 - docs/Unity Tutorial/_category.json | 5 -- docs/WebSocket API Reference/_category.json | 1 - .../Binary Format.md => bsatn.md} | 0 .../index.md => deploying/testnet.md} | 0 .../index.md => getting-started.md} | 0 .../Databases.md => http/database.md} | 0 .../Energy.md => http/energy.md} | 0 .../Identities.md => http/identity.md} | 0 docs/{HTTP API Reference => http}/index.md | 0 docs/{Overview => }/index.md | 0 .../c-sharp/index.md} | 0 .../c-sharp/quickstart.md} | 0 .../index.md | 0 .../rust/index.md} | 0 .../index.md => modules/rust/quickstart.md} | 0 docs/nav.js | 58 +++++++++---------- docs/{SATN Reference/index.md => satn.md} | 0 .../c-sharp/index.md} | 0 .../index.md => sdks/c-sharp/quickstart.md} | 0 docs/{Client SDK Languages => sdks}/index.md | 0 .../SDK Reference.md => sdks/python/index.md} | 0 .../index.md => sdks/python/quickstart.md} | 0 .../SDK Reference.md => sdks/rust/index.md} | 0 .../Rust/index.md => sdks/rust/quickstart.md} | 0 .../typescript/index.md} | 0 .../typescript/quickstart.md} | 0 docs/{SQL Reference => sql}/index.md | 0 .../part-1.md} | 0 .../part-2.md} | 0 .../part-3.md} | 0 .../index.md | 0 docs/{WebSocket API Reference => ws}/index.md | 0 nav.ts | 58 +++++++++---------- 48 files changed, 58 insertions(+), 104 deletions(-) delete mode 100644 docs/Client SDK Languages/C#/_category.json delete mode 100644 docs/Client SDK Languages/Python/_category.json delete mode 100644 docs/Client SDK Languages/Rust/_category.json delete mode 100644 docs/Client SDK Languages/Typescript/_category.json delete mode 100644 docs/Client SDK Languages/_category.json delete mode 100644 docs/Cloud Testnet/_category.json delete mode 100644 docs/Getting Started/_category.json delete mode 100644 docs/HTTP API Reference/_category.json delete mode 100644 docs/Module ABI Reference/_category.json delete mode 100644 docs/Overview/_category.json delete mode 100644 docs/SATN Reference/_category.json delete mode 100644 docs/SQL Reference/_category.json delete mode 100644 docs/Server Module Languages/C#/_category.json delete mode 100644 docs/Server Module Languages/Rust/_category.json delete mode 100644 docs/Server Module Languages/_category.json delete mode 100644 docs/Unity Tutorial/_category.json delete mode 100644 docs/WebSocket API Reference/_category.json rename docs/{SATN Reference/Binary Format.md => bsatn.md} (100%) rename docs/{Cloud Testnet/index.md => deploying/testnet.md} (100%) rename docs/{Getting Started/index.md => getting-started.md} (100%) rename docs/{HTTP API Reference/Databases.md => http/database.md} (100%) rename docs/{HTTP API Reference/Energy.md => http/energy.md} (100%) rename docs/{HTTP API Reference/Identities.md => http/identity.md} (100%) rename docs/{HTTP API Reference => http}/index.md (100%) rename docs/{Overview => }/index.md (100%) rename docs/{Server Module Languages/C#/ModuleReference.md => modules/c-sharp/index.md} (100%) rename docs/{Server Module Languages/C#/index.md => modules/c-sharp/quickstart.md} (100%) rename docs/{Server Module Languages => modules}/index.md (100%) rename docs/{Server Module Languages/Rust/ModuleReference.md => modules/rust/index.md} (100%) rename docs/{Server Module Languages/Rust/index.md => modules/rust/quickstart.md} (100%) rename docs/{SATN Reference/index.md => satn.md} (100%) rename docs/{Client SDK Languages/C#/SDK Reference.md => sdks/c-sharp/index.md} (100%) rename docs/{Client SDK Languages/C#/index.md => sdks/c-sharp/quickstart.md} (100%) rename docs/{Client SDK Languages => sdks}/index.md (100%) rename docs/{Client SDK Languages/Python/SDK Reference.md => sdks/python/index.md} (100%) rename docs/{Client SDK Languages/Python/index.md => sdks/python/quickstart.md} (100%) rename docs/{Client SDK Languages/Rust/SDK Reference.md => sdks/rust/index.md} (100%) rename docs/{Client SDK Languages/Rust/index.md => sdks/rust/quickstart.md} (100%) rename docs/{Client SDK Languages/Typescript/SDK Reference.md => sdks/typescript/index.md} (100%) rename docs/{Client SDK Languages/Typescript/index.md => sdks/typescript/quickstart.md} (100%) rename docs/{SQL Reference => sql}/index.md (100%) rename docs/{Unity Tutorial/Part 1 - Basic Multiplayer.md => unity/part-1.md} (100%) rename docs/{Unity Tutorial/Part 2 - Resources And Scheduling.md => unity/part-2.md} (100%) rename docs/{Unity Tutorial/Part 3 - BitCraft Mini.md => unity/part-3.md} (100%) rename docs/{Module ABI Reference => webassembly-abi}/index.md (100%) rename docs/{WebSocket API Reference => ws}/index.md (100%) diff --git a/docs/Client SDK Languages/C#/_category.json b/docs/Client SDK Languages/C#/_category.json deleted file mode 100644 index 60238f8e..00000000 --- a/docs/Client SDK Languages/C#/_category.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "title": "C#", - "disabled": false, - "index": "index.md" -} \ No newline at end of file diff --git a/docs/Client SDK Languages/Python/_category.json b/docs/Client SDK Languages/Python/_category.json deleted file mode 100644 index 4e08cfa1..00000000 --- a/docs/Client SDK Languages/Python/_category.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "title": "Python", - "disabled": false, - "index": "index.md" -} \ No newline at end of file diff --git a/docs/Client SDK Languages/Rust/_category.json b/docs/Client SDK Languages/Rust/_category.json deleted file mode 100644 index 6280366c..00000000 --- a/docs/Client SDK Languages/Rust/_category.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "title": "Rust", - "disabled": false, - "index": "index.md" -} \ No newline at end of file diff --git a/docs/Client SDK Languages/Typescript/_category.json b/docs/Client SDK Languages/Typescript/_category.json deleted file mode 100644 index 590d44a2..00000000 --- a/docs/Client SDK Languages/Typescript/_category.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "title": "Typescript", - "disabled": false, - "index": "index.md" -} \ No newline at end of file diff --git a/docs/Client SDK Languages/_category.json b/docs/Client SDK Languages/_category.json deleted file mode 100644 index 530c17aa..00000000 --- a/docs/Client SDK Languages/_category.json +++ /dev/null @@ -1 +0,0 @@ -{"title":"Client SDK Languages","disabled":false,"index":"index.md"} \ No newline at end of file diff --git a/docs/Cloud Testnet/_category.json b/docs/Cloud Testnet/_category.json deleted file mode 100644 index e6fa11b9..00000000 --- a/docs/Cloud Testnet/_category.json +++ /dev/null @@ -1 +0,0 @@ -{"title":"Cloud Testnet","disabled":false,"index":"index.md"} \ No newline at end of file diff --git a/docs/Getting Started/_category.json b/docs/Getting Started/_category.json deleted file mode 100644 index a68dc36c..00000000 --- a/docs/Getting Started/_category.json +++ /dev/null @@ -1 +0,0 @@ -{"title":"Getting Started","disabled":false,"index":"index.md"} \ No newline at end of file diff --git a/docs/HTTP API Reference/_category.json b/docs/HTTP API Reference/_category.json deleted file mode 100644 index c8ad821b..00000000 --- a/docs/HTTP API Reference/_category.json +++ /dev/null @@ -1 +0,0 @@ -{"title":"HTTP API Reference","disabled":false,"index":"index.md"} \ No newline at end of file diff --git a/docs/Module ABI Reference/_category.json b/docs/Module ABI Reference/_category.json deleted file mode 100644 index 7583598d..00000000 --- a/docs/Module ABI Reference/_category.json +++ /dev/null @@ -1 +0,0 @@ -{"title":"Module ABI Reference","disabled":false,"index":"index.md"} \ No newline at end of file diff --git a/docs/Overview/_category.json b/docs/Overview/_category.json deleted file mode 100644 index 35164a50..00000000 --- a/docs/Overview/_category.json +++ /dev/null @@ -1 +0,0 @@ -{"title":"Overview","disabled":false,"index":"index.md"} \ No newline at end of file diff --git a/docs/SATN Reference/_category.json b/docs/SATN Reference/_category.json deleted file mode 100644 index e26b2f05..00000000 --- a/docs/SATN Reference/_category.json +++ /dev/null @@ -1 +0,0 @@ -{"title":"SATN Reference","disabled":false,"index":"index.md"} \ No newline at end of file diff --git a/docs/SQL Reference/_category.json b/docs/SQL Reference/_category.json deleted file mode 100644 index 73d7df23..00000000 --- a/docs/SQL Reference/_category.json +++ /dev/null @@ -1 +0,0 @@ -{"title":"SQL Reference","disabled":false,"index":"index.md"} \ No newline at end of file diff --git a/docs/Server Module Languages/C#/_category.json b/docs/Server Module Languages/C#/_category.json deleted file mode 100644 index 71ae9015..00000000 --- a/docs/Server Module Languages/C#/_category.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "title": "C#", - "disabled": false, - "index": "index.md", - "tag": "Expiremental" -} \ No newline at end of file diff --git a/docs/Server Module Languages/Rust/_category.json b/docs/Server Module Languages/Rust/_category.json deleted file mode 100644 index 6280366c..00000000 --- a/docs/Server Module Languages/Rust/_category.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "title": "Rust", - "disabled": false, - "index": "index.md" -} \ No newline at end of file diff --git a/docs/Server Module Languages/_category.json b/docs/Server Module Languages/_category.json deleted file mode 100644 index 3bfa0e87..00000000 --- a/docs/Server Module Languages/_category.json +++ /dev/null @@ -1 +0,0 @@ -{"title":"Server Module Languages","disabled":false,"index":"index.md"} \ No newline at end of file diff --git a/docs/Unity Tutorial/_category.json b/docs/Unity Tutorial/_category.json deleted file mode 100644 index a3c837ad..00000000 --- a/docs/Unity Tutorial/_category.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "title": "Unity Tutorial", - "disabled": false, - "index": "Part 1 - Basic Multiplayer.md" -} \ No newline at end of file diff --git a/docs/WebSocket API Reference/_category.json b/docs/WebSocket API Reference/_category.json deleted file mode 100644 index d2797306..00000000 --- a/docs/WebSocket API Reference/_category.json +++ /dev/null @@ -1 +0,0 @@ -{"title":"WebSocket API Reference","disabled":false,"index":"index.md"} \ No newline at end of file diff --git a/docs/SATN Reference/Binary Format.md b/docs/bsatn.md similarity index 100% rename from docs/SATN Reference/Binary Format.md rename to docs/bsatn.md diff --git a/docs/Cloud Testnet/index.md b/docs/deploying/testnet.md similarity index 100% rename from docs/Cloud Testnet/index.md rename to docs/deploying/testnet.md diff --git a/docs/Getting Started/index.md b/docs/getting-started.md similarity index 100% rename from docs/Getting Started/index.md rename to docs/getting-started.md diff --git a/docs/HTTP API Reference/Databases.md b/docs/http/database.md similarity index 100% rename from docs/HTTP API Reference/Databases.md rename to docs/http/database.md diff --git a/docs/HTTP API Reference/Energy.md b/docs/http/energy.md similarity index 100% rename from docs/HTTP API Reference/Energy.md rename to docs/http/energy.md diff --git a/docs/HTTP API Reference/Identities.md b/docs/http/identity.md similarity index 100% rename from docs/HTTP API Reference/Identities.md rename to docs/http/identity.md diff --git a/docs/HTTP API Reference/index.md b/docs/http/index.md similarity index 100% rename from docs/HTTP API Reference/index.md rename to docs/http/index.md diff --git a/docs/Overview/index.md b/docs/index.md similarity index 100% rename from docs/Overview/index.md rename to docs/index.md diff --git a/docs/Server Module Languages/C#/ModuleReference.md b/docs/modules/c-sharp/index.md similarity index 100% rename from docs/Server Module Languages/C#/ModuleReference.md rename to docs/modules/c-sharp/index.md diff --git a/docs/Server Module Languages/C#/index.md b/docs/modules/c-sharp/quickstart.md similarity index 100% rename from docs/Server Module Languages/C#/index.md rename to docs/modules/c-sharp/quickstart.md diff --git a/docs/Server Module Languages/index.md b/docs/modules/index.md similarity index 100% rename from docs/Server Module Languages/index.md rename to docs/modules/index.md diff --git a/docs/Server Module Languages/Rust/ModuleReference.md b/docs/modules/rust/index.md similarity index 100% rename from docs/Server Module Languages/Rust/ModuleReference.md rename to docs/modules/rust/index.md diff --git a/docs/Server Module Languages/Rust/index.md b/docs/modules/rust/quickstart.md similarity index 100% rename from docs/Server Module Languages/Rust/index.md rename to docs/modules/rust/quickstart.md diff --git a/docs/nav.js b/docs/nav.js index b62e9b7f..cb8d22f1 100644 --- a/docs/nav.js +++ b/docs/nav.js @@ -9,44 +9,44 @@ function section(title) { const nav = { items: [ section("Intro"), - page("Overview", "index", "Overview/index.md"), - page("Getting Started", "getting-started", "Getting Started/index.md"), + page("Overview", "index", "index.md"), + page("Getting Started", "getting-started", "getting-started.md"), section("Deploying"), - page("Testnet", "deploying/testnet", "Cloud Testnet/index.md"), + page("Testnet", "deploying/testnet", "deploying/testnet.md"), section("Unity Tutorial"), - page("Part 1 - Basic Multiplayer", "unity/part-1", "Unity Tutorial/Part 1 - Basic Multiplayer.md"), - page("Part 2 - Resources And Scheduling", "unity/part-2", "Unity Tutorial/Part 2 - Resources And Scheduling.md"), - page("Part 3 - BitCraft Mini", "unity/part-3", "Unity Tutorial/Part 3 - BitCraft Mini.md"), + page("Part 1 - Basic Multiplayer", "unity/part-1", "unity/part-1.md"), + page("Part 2 - Resources And Scheduling", "unity/part-2", "unity/part-2.md"), + page("Part 3 - BitCraft Mini", "unity/part-3", "unity/part-3.md"), section("Server Module Languages"), - page("Overview", "modules", "Server Module Languages/index.md"), - page("Rust Quickstart", "modules/rust/quickstart", "Server Module Languages/Rust/index.md"), - page("Rust Reference", "modules/rust", "Server Module Languages/Rust/ModuleReference.md"), - page("C# Quickstart", "modules/c-sharp/quickstart", "Server Module Languages/C#/index.md"), - page("C# Reference", "modules/c-sharp", "Server Module Languages/C#/ModuleReference.md"), + page("Overview", "modules", "modules/index.md"), + page("Rust Quickstart", "modules/rust/quickstart", "modules/rust/quickstart.md"), + page("Rust Reference", "modules/rust", "modules/rust/index.md"), + page("C# Quickstart", "modules/c-sharp/quickstart", "modules/c-sharp/quickstart.md"), + page("C# Reference", "modules/c-sharp", "modules/c-sharp/index.md"), section("Client SDK Languages"), - page("Overview", "sdks", "Client SDK Languages/index.md"), - page("Typescript Quickstart", "sdks/typescript/quickstart", "Client SDK Languages/Typescript/index.md"), - page("Typescript Reference", "sdks/typescript", "Client SDK Languages/Typescript/SDK Reference.md"), - page("Rust Quickstart", "sdks/rust/quickstart", "Client SDK Languages/Rust/index.md"), - page("Rust Reference", "sdks/rust", "Client SDK Languages/Rust/SDK Reference.md"), - page("Python Quickstart", "sdks/python/quickstart", "Client SDK Languages/Python/index.md"), - page("Python Reference", "sdks/python", "Client SDK Languages/Python/SDK Reference.md"), - page("C# Quickstart", "sdks/c-sharp/quickstart", "Client SDK Languages/C#/index.md"), - page("C# Reference", "sdks/c-sharp", "Client SDK Languages/C#/SDK Reference.md"), + page("Overview", "sdks", "sdks/index.md"), + page("Typescript Quickstart", "sdks/typescript/quickstart", "sdks/typescript/quickstart.md"), + page("Typescript Reference", "sdks/typescript", "sdks/typescript/index.md"), + page("Rust Quickstart", "sdks/rust/quickstart", "sdks/rust/quickstart.md"), + page("Rust Reference", "sdks/rust", "sdks/rust/index.md"), + page("Python Quickstart", "sdks/python/quickstart", "sdks/python/quickstart.md"), + page("Python Reference", "sdks/python", "sdks/python/index.md"), + page("C# Quickstart", "sdks/c-sharp/quickstart", "sdks/c-sharp/quickstart.md"), + page("C# Reference", "sdks/c-sharp", "sdks/c-sharp/index.md"), section("WebAssembly ABI"), - page("Module ABI Reference", "webassembly-abi", "Module ABI Reference/index.md"), + page("Module ABI Reference", "webassembly-abi", "webassembly-abi/index.md"), section("HTTP API"), - page("HTTP", "http", "HTTP API Reference/index.md"), - page("`/identity`", "http/identity", "HTTP API Reference/Identities.md"), - page("`/database`", "http/database", "HTTP API Reference/Databases.md"), - page("`/energy`", "http/energy", "HTTP API Reference/Energy.md"), + page("HTTP", "http", "http/index.md"), + page("`/identity`", "http/identity", "http/identity.md"), + page("`/database`", "http/database", "http/database.md"), + page("`/energy`", "http/energy", "http/energy.md"), section("WebSocket API Reference"), - page("WebSocket", "ws", "WebSocket API Reference/index.md"), + page("WebSocket", "ws", "ws/index.md"), section("Data Format"), - page("SATN", "satn", "SATN Reference/index.md"), - page("BSATN", "bsatn", "SATN Reference/Binary Format.md"), + page("SATN", "satn", "satn.md"), + page("BSATN", "bsatn", "bsatn.md"), section("SQL"), - page("SQL Reference", "sql", "SQL Reference/index.md"), + page("SQL Reference", "sql", "sql/index.md"), ], }; exports.default = nav; diff --git a/docs/SATN Reference/index.md b/docs/satn.md similarity index 100% rename from docs/SATN Reference/index.md rename to docs/satn.md diff --git a/docs/Client SDK Languages/C#/SDK Reference.md b/docs/sdks/c-sharp/index.md similarity index 100% rename from docs/Client SDK Languages/C#/SDK Reference.md rename to docs/sdks/c-sharp/index.md diff --git a/docs/Client SDK Languages/C#/index.md b/docs/sdks/c-sharp/quickstart.md similarity index 100% rename from docs/Client SDK Languages/C#/index.md rename to docs/sdks/c-sharp/quickstart.md diff --git a/docs/Client SDK Languages/index.md b/docs/sdks/index.md similarity index 100% rename from docs/Client SDK Languages/index.md rename to docs/sdks/index.md diff --git a/docs/Client SDK Languages/Python/SDK Reference.md b/docs/sdks/python/index.md similarity index 100% rename from docs/Client SDK Languages/Python/SDK Reference.md rename to docs/sdks/python/index.md diff --git a/docs/Client SDK Languages/Python/index.md b/docs/sdks/python/quickstart.md similarity index 100% rename from docs/Client SDK Languages/Python/index.md rename to docs/sdks/python/quickstart.md diff --git a/docs/Client SDK Languages/Rust/SDK Reference.md b/docs/sdks/rust/index.md similarity index 100% rename from docs/Client SDK Languages/Rust/SDK Reference.md rename to docs/sdks/rust/index.md diff --git a/docs/Client SDK Languages/Rust/index.md b/docs/sdks/rust/quickstart.md similarity index 100% rename from docs/Client SDK Languages/Rust/index.md rename to docs/sdks/rust/quickstart.md diff --git a/docs/Client SDK Languages/Typescript/SDK Reference.md b/docs/sdks/typescript/index.md similarity index 100% rename from docs/Client SDK Languages/Typescript/SDK Reference.md rename to docs/sdks/typescript/index.md diff --git a/docs/Client SDK Languages/Typescript/index.md b/docs/sdks/typescript/quickstart.md similarity index 100% rename from docs/Client SDK Languages/Typescript/index.md rename to docs/sdks/typescript/quickstart.md diff --git a/docs/SQL Reference/index.md b/docs/sql/index.md similarity index 100% rename from docs/SQL Reference/index.md rename to docs/sql/index.md diff --git a/docs/Unity Tutorial/Part 1 - Basic Multiplayer.md b/docs/unity/part-1.md similarity index 100% rename from docs/Unity Tutorial/Part 1 - Basic Multiplayer.md rename to docs/unity/part-1.md diff --git a/docs/Unity Tutorial/Part 2 - Resources And Scheduling.md b/docs/unity/part-2.md similarity index 100% rename from docs/Unity Tutorial/Part 2 - Resources And Scheduling.md rename to docs/unity/part-2.md diff --git a/docs/Unity Tutorial/Part 3 - BitCraft Mini.md b/docs/unity/part-3.md similarity index 100% rename from docs/Unity Tutorial/Part 3 - BitCraft Mini.md rename to docs/unity/part-3.md diff --git a/docs/Module ABI Reference/index.md b/docs/webassembly-abi/index.md similarity index 100% rename from docs/Module ABI Reference/index.md rename to docs/webassembly-abi/index.md diff --git a/docs/WebSocket API Reference/index.md b/docs/ws/index.md similarity index 100% rename from docs/WebSocket API Reference/index.md rename to docs/ws/index.md diff --git a/nav.ts b/nav.ts index b9a64ee0..6d5a304b 100644 --- a/nav.ts +++ b/nav.ts @@ -25,53 +25,53 @@ function section(title: string): NavSection { const nav: Nav = { items: [ section("Intro"), - page("Overview", "index", "Overview/index.md"), - page("Getting Started", "getting-started", "Getting Started/index.md"), + page("Overview", "index", "index.md"), + page("Getting Started", "getting-started", "getting-started.md"), section("Deploying"), - page("Testnet", "deploying/testnet", "Cloud Testnet/index.md"), + page("Testnet", "deploying/testnet", "deploying/testnet.md"), section("Unity Tutorial"), - page("Part 1 - Basic Multiplayer", "unity/part-1", "Unity Tutorial/Part 1 - Basic Multiplayer.md"), - page("Part 2 - Resources And Scheduling", "unity/part-2", "Unity Tutorial/Part 2 - Resources And Scheduling.md"), - page("Part 3 - BitCraft Mini", "unity/part-3", "Unity Tutorial/Part 3 - BitCraft Mini.md"), + page("Part 1 - Basic Multiplayer", "unity/part-1", "unity/part-1.md"), + page("Part 2 - Resources And Scheduling", "unity/part-2", "unity/part-2.md"), + page("Part 3 - BitCraft Mini", "unity/part-3", "unity/part-3.md"), section("Server Module Languages"), - page("Overview", "modules", "Server Module Languages/index.md"), - page("Rust Quickstart", "modules/rust/quickstart", "Server Module Languages/Rust/index.md"), - page("Rust Reference", "modules/rust", "Server Module Languages/Rust/ModuleReference.md"), - page("C# Quickstart", "modules/c-sharp/quickstart", "Server Module Languages/C#/index.md"), - page("C# Reference", "modules/c-sharp", "Server Module Languages/C#/ModuleReference.md"), + page("Overview", "modules", "modules/index.md"), + page("Rust Quickstart", "modules/rust/quickstart", "modules/rust/quickstart.md"), + page("Rust Reference", "modules/rust", "modules/rust/index.md"), + page("C# Quickstart", "modules/c-sharp/quickstart", "modules/c-sharp/quickstart.md"), + page("C# Reference", "modules/c-sharp", "modules/c-sharp/index.md"), section("Client SDK Languages"), - page("Overview", "sdks", "Client SDK Languages/index.md"), - page("Typescript Quickstart", "sdks/typescript/quickstart", "Client SDK Languages/Typescript/index.md"), - page("Typescript Reference", "sdks/typescript", "Client SDK Languages/Typescript/SDK Reference.md"), - page("Rust Quickstart", "sdks/rust/quickstart", "Client SDK Languages/Rust/index.md"), - page("Rust Reference", "sdks/rust", "Client SDK Languages/Rust/SDK Reference.md"), - page("Python Quickstart", "sdks/python/quickstart", "Client SDK Languages/Python/index.md"), - page("Python Reference", "sdks/python", "Client SDK Languages/Python/SDK Reference.md"), - page("C# Quickstart", "sdks/c-sharp/quickstart", "Client SDK Languages/C#/index.md"), - page("C# Reference", "sdks/c-sharp", "Client SDK Languages/C#/SDK Reference.md"), + page("Overview", "sdks", "sdks/index.md"), + page("Typescript Quickstart", "sdks/typescript/quickstart", "sdks/typescript/quickstart.md"), + page("Typescript Reference", "sdks/typescript", "sdks/typescript/index.md"), + page("Rust Quickstart", "sdks/rust/quickstart", "sdks/rust/quickstart.md"), + page("Rust Reference", "sdks/rust", "sdks/rust/index.md"), + page("Python Quickstart", "sdks/python/quickstart", "sdks/python/quickstart.md"), + page("Python Reference", "sdks/python", "sdks/python/index.md"), + page("C# Quickstart", "sdks/c-sharp/quickstart", "sdks/c-sharp/quickstart.md"), + page("C# Reference", "sdks/c-sharp", "sdks/c-sharp/index.md"), section("WebAssembly ABI"), - page("Module ABI Reference", "webassembly-abi", "Module ABI Reference/index.md"), + page("Module ABI Reference", "webassembly-abi", "webassembly-abi/index.md"), section("HTTP API"), - page("HTTP", "http", "HTTP API Reference/index.md"), - page("`/identity`", "http/identity", "HTTP API Reference/Identities.md"), - page("`/database`", "http/database", "HTTP API Reference/Databases.md"), - page("`/energy`", "http/energy", "HTTP API Reference/Energy.md"), + page("HTTP", "http", "http/index.md"), + page("`/identity`", "http/identity", "http/identity.md"), + page("`/database`", "http/database", "http/database.md"), + page("`/energy`", "http/energy", "http/energy.md"), section("WebSocket API Reference"), - page("WebSocket", "ws", "WebSocket API Reference/index.md"), + page("WebSocket", "ws", "ws/index.md"), section("Data Format"), - page("SATN", "satn", "SATN Reference/index.md"), - page("BSATN", "bsatn", "SATN Reference/Binary Format.md"), + page("SATN", "satn", "satn.md"), + page("BSATN", "bsatn", "bsatn.md"), section("SQL"), - page("SQL Reference", "sql", "SQL Reference/index.md"), + page("SQL Reference", "sql", "sql/index.md"), ], }; From 1fb1e0fba655e08914c5f3d4243ac840424ba640 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Tue, 28 Nov 2023 20:03:42 -0800 Subject: [PATCH 024/164] Revert "Reorganized the doc paths to match slugs and removed _category.json files" --- .../C#/SDK Reference.md} | 0 docs/Client SDK Languages/C#/_category.json | 5 ++ .../C#/index.md} | 0 .../Python/SDK Reference.md} | 0 .../Python/_category.json | 5 ++ .../Python/index.md} | 0 .../Rust/SDK Reference.md} | 0 docs/Client SDK Languages/Rust/_category.json | 5 ++ .../Rust/index.md} | 0 .../Typescript/SDK Reference.md} | 0 .../Typescript/_category.json | 5 ++ .../Typescript/index.md} | 0 docs/Client SDK Languages/_category.json | 1 + docs/{sdks => Client SDK Languages}/index.md | 0 docs/Cloud Testnet/_category.json | 1 + .../testnet.md => Cloud Testnet/index.md} | 0 docs/Getting Started/_category.json | 1 + .../index.md} | 0 .../Databases.md} | 0 .../Energy.md} | 0 .../Identities.md} | 0 docs/HTTP API Reference/_category.json | 1 + docs/{http => HTTP API Reference}/index.md | 0 docs/Module ABI Reference/_category.json | 1 + .../index.md | 0 docs/Overview/_category.json | 1 + docs/{ => Overview}/index.md | 0 .../Binary Format.md} | 0 docs/SATN Reference/_category.json | 1 + docs/{satn.md => SATN Reference/index.md} | 0 docs/SQL Reference/_category.json | 1 + docs/{sql => SQL Reference}/index.md | 0 .../C#/ModuleReference.md} | 0 .../Server Module Languages/C#/_category.json | 6 ++ .../C#/index.md} | 0 .../Rust/ModuleReference.md} | 0 .../Rust/_category.json | 5 ++ .../Rust/index.md} | 0 docs/Server Module Languages/_category.json | 1 + .../index.md | 0 .../Part 1 - Basic Multiplayer.md} | 0 .../Part 2 - Resources And Scheduling.md} | 0 .../Part 3 - BitCraft Mini.md} | 0 docs/Unity Tutorial/_category.json | 5 ++ docs/WebSocket API Reference/_category.json | 1 + docs/{ws => WebSocket API Reference}/index.md | 0 docs/nav.js | 58 +++++++++---------- nav.ts | 58 +++++++++---------- 48 files changed, 104 insertions(+), 58 deletions(-) rename docs/{sdks/c-sharp/index.md => Client SDK Languages/C#/SDK Reference.md} (100%) create mode 100644 docs/Client SDK Languages/C#/_category.json rename docs/{sdks/c-sharp/quickstart.md => Client SDK Languages/C#/index.md} (100%) rename docs/{sdks/python/index.md => Client SDK Languages/Python/SDK Reference.md} (100%) create mode 100644 docs/Client SDK Languages/Python/_category.json rename docs/{sdks/python/quickstart.md => Client SDK Languages/Python/index.md} (100%) rename docs/{sdks/rust/index.md => Client SDK Languages/Rust/SDK Reference.md} (100%) create mode 100644 docs/Client SDK Languages/Rust/_category.json rename docs/{sdks/rust/quickstart.md => Client SDK Languages/Rust/index.md} (100%) rename docs/{sdks/typescript/index.md => Client SDK Languages/Typescript/SDK Reference.md} (100%) create mode 100644 docs/Client SDK Languages/Typescript/_category.json rename docs/{sdks/typescript/quickstart.md => Client SDK Languages/Typescript/index.md} (100%) create mode 100644 docs/Client SDK Languages/_category.json rename docs/{sdks => Client SDK Languages}/index.md (100%) create mode 100644 docs/Cloud Testnet/_category.json rename docs/{deploying/testnet.md => Cloud Testnet/index.md} (100%) create mode 100644 docs/Getting Started/_category.json rename docs/{getting-started.md => Getting Started/index.md} (100%) rename docs/{http/database.md => HTTP API Reference/Databases.md} (100%) rename docs/{http/energy.md => HTTP API Reference/Energy.md} (100%) rename docs/{http/identity.md => HTTP API Reference/Identities.md} (100%) create mode 100644 docs/HTTP API Reference/_category.json rename docs/{http => HTTP API Reference}/index.md (100%) create mode 100644 docs/Module ABI Reference/_category.json rename docs/{webassembly-abi => Module ABI Reference}/index.md (100%) create mode 100644 docs/Overview/_category.json rename docs/{ => Overview}/index.md (100%) rename docs/{bsatn.md => SATN Reference/Binary Format.md} (100%) create mode 100644 docs/SATN Reference/_category.json rename docs/{satn.md => SATN Reference/index.md} (100%) create mode 100644 docs/SQL Reference/_category.json rename docs/{sql => SQL Reference}/index.md (100%) rename docs/{modules/c-sharp/index.md => Server Module Languages/C#/ModuleReference.md} (100%) create mode 100644 docs/Server Module Languages/C#/_category.json rename docs/{modules/c-sharp/quickstart.md => Server Module Languages/C#/index.md} (100%) rename docs/{modules/rust/index.md => Server Module Languages/Rust/ModuleReference.md} (100%) create mode 100644 docs/Server Module Languages/Rust/_category.json rename docs/{modules/rust/quickstart.md => Server Module Languages/Rust/index.md} (100%) create mode 100644 docs/Server Module Languages/_category.json rename docs/{modules => Server Module Languages}/index.md (100%) rename docs/{unity/part-1.md => Unity Tutorial/Part 1 - Basic Multiplayer.md} (100%) rename docs/{unity/part-2.md => Unity Tutorial/Part 2 - Resources And Scheduling.md} (100%) rename docs/{unity/part-3.md => Unity Tutorial/Part 3 - BitCraft Mini.md} (100%) create mode 100644 docs/Unity Tutorial/_category.json create mode 100644 docs/WebSocket API Reference/_category.json rename docs/{ws => WebSocket API Reference}/index.md (100%) diff --git a/docs/sdks/c-sharp/index.md b/docs/Client SDK Languages/C#/SDK Reference.md similarity index 100% rename from docs/sdks/c-sharp/index.md rename to docs/Client SDK Languages/C#/SDK Reference.md diff --git a/docs/Client SDK Languages/C#/_category.json b/docs/Client SDK Languages/C#/_category.json new file mode 100644 index 00000000..60238f8e --- /dev/null +++ b/docs/Client SDK Languages/C#/_category.json @@ -0,0 +1,5 @@ +{ + "title": "C#", + "disabled": false, + "index": "index.md" +} \ No newline at end of file diff --git a/docs/sdks/c-sharp/quickstart.md b/docs/Client SDK Languages/C#/index.md similarity index 100% rename from docs/sdks/c-sharp/quickstart.md rename to docs/Client SDK Languages/C#/index.md diff --git a/docs/sdks/python/index.md b/docs/Client SDK Languages/Python/SDK Reference.md similarity index 100% rename from docs/sdks/python/index.md rename to docs/Client SDK Languages/Python/SDK Reference.md diff --git a/docs/Client SDK Languages/Python/_category.json b/docs/Client SDK Languages/Python/_category.json new file mode 100644 index 00000000..4e08cfa1 --- /dev/null +++ b/docs/Client SDK Languages/Python/_category.json @@ -0,0 +1,5 @@ +{ + "title": "Python", + "disabled": false, + "index": "index.md" +} \ No newline at end of file diff --git a/docs/sdks/python/quickstart.md b/docs/Client SDK Languages/Python/index.md similarity index 100% rename from docs/sdks/python/quickstart.md rename to docs/Client SDK Languages/Python/index.md diff --git a/docs/sdks/rust/index.md b/docs/Client SDK Languages/Rust/SDK Reference.md similarity index 100% rename from docs/sdks/rust/index.md rename to docs/Client SDK Languages/Rust/SDK Reference.md diff --git a/docs/Client SDK Languages/Rust/_category.json b/docs/Client SDK Languages/Rust/_category.json new file mode 100644 index 00000000..6280366c --- /dev/null +++ b/docs/Client SDK Languages/Rust/_category.json @@ -0,0 +1,5 @@ +{ + "title": "Rust", + "disabled": false, + "index": "index.md" +} \ No newline at end of file diff --git a/docs/sdks/rust/quickstart.md b/docs/Client SDK Languages/Rust/index.md similarity index 100% rename from docs/sdks/rust/quickstart.md rename to docs/Client SDK Languages/Rust/index.md diff --git a/docs/sdks/typescript/index.md b/docs/Client SDK Languages/Typescript/SDK Reference.md similarity index 100% rename from docs/sdks/typescript/index.md rename to docs/Client SDK Languages/Typescript/SDK Reference.md diff --git a/docs/Client SDK Languages/Typescript/_category.json b/docs/Client SDK Languages/Typescript/_category.json new file mode 100644 index 00000000..590d44a2 --- /dev/null +++ b/docs/Client SDK Languages/Typescript/_category.json @@ -0,0 +1,5 @@ +{ + "title": "Typescript", + "disabled": false, + "index": "index.md" +} \ No newline at end of file diff --git a/docs/sdks/typescript/quickstart.md b/docs/Client SDK Languages/Typescript/index.md similarity index 100% rename from docs/sdks/typescript/quickstart.md rename to docs/Client SDK Languages/Typescript/index.md diff --git a/docs/Client SDK Languages/_category.json b/docs/Client SDK Languages/_category.json new file mode 100644 index 00000000..530c17aa --- /dev/null +++ b/docs/Client SDK Languages/_category.json @@ -0,0 +1 @@ +{"title":"Client SDK Languages","disabled":false,"index":"index.md"} \ No newline at end of file diff --git a/docs/sdks/index.md b/docs/Client SDK Languages/index.md similarity index 100% rename from docs/sdks/index.md rename to docs/Client SDK Languages/index.md diff --git a/docs/Cloud Testnet/_category.json b/docs/Cloud Testnet/_category.json new file mode 100644 index 00000000..e6fa11b9 --- /dev/null +++ b/docs/Cloud Testnet/_category.json @@ -0,0 +1 @@ +{"title":"Cloud Testnet","disabled":false,"index":"index.md"} \ No newline at end of file diff --git a/docs/deploying/testnet.md b/docs/Cloud Testnet/index.md similarity index 100% rename from docs/deploying/testnet.md rename to docs/Cloud Testnet/index.md diff --git a/docs/Getting Started/_category.json b/docs/Getting Started/_category.json new file mode 100644 index 00000000..a68dc36c --- /dev/null +++ b/docs/Getting Started/_category.json @@ -0,0 +1 @@ +{"title":"Getting Started","disabled":false,"index":"index.md"} \ No newline at end of file diff --git a/docs/getting-started.md b/docs/Getting Started/index.md similarity index 100% rename from docs/getting-started.md rename to docs/Getting Started/index.md diff --git a/docs/http/database.md b/docs/HTTP API Reference/Databases.md similarity index 100% rename from docs/http/database.md rename to docs/HTTP API Reference/Databases.md diff --git a/docs/http/energy.md b/docs/HTTP API Reference/Energy.md similarity index 100% rename from docs/http/energy.md rename to docs/HTTP API Reference/Energy.md diff --git a/docs/http/identity.md b/docs/HTTP API Reference/Identities.md similarity index 100% rename from docs/http/identity.md rename to docs/HTTP API Reference/Identities.md diff --git a/docs/HTTP API Reference/_category.json b/docs/HTTP API Reference/_category.json new file mode 100644 index 00000000..c8ad821b --- /dev/null +++ b/docs/HTTP API Reference/_category.json @@ -0,0 +1 @@ +{"title":"HTTP API Reference","disabled":false,"index":"index.md"} \ No newline at end of file diff --git a/docs/http/index.md b/docs/HTTP API Reference/index.md similarity index 100% rename from docs/http/index.md rename to docs/HTTP API Reference/index.md diff --git a/docs/Module ABI Reference/_category.json b/docs/Module ABI Reference/_category.json new file mode 100644 index 00000000..7583598d --- /dev/null +++ b/docs/Module ABI Reference/_category.json @@ -0,0 +1 @@ +{"title":"Module ABI Reference","disabled":false,"index":"index.md"} \ No newline at end of file diff --git a/docs/webassembly-abi/index.md b/docs/Module ABI Reference/index.md similarity index 100% rename from docs/webassembly-abi/index.md rename to docs/Module ABI Reference/index.md diff --git a/docs/Overview/_category.json b/docs/Overview/_category.json new file mode 100644 index 00000000..35164a50 --- /dev/null +++ b/docs/Overview/_category.json @@ -0,0 +1 @@ +{"title":"Overview","disabled":false,"index":"index.md"} \ No newline at end of file diff --git a/docs/index.md b/docs/Overview/index.md similarity index 100% rename from docs/index.md rename to docs/Overview/index.md diff --git a/docs/bsatn.md b/docs/SATN Reference/Binary Format.md similarity index 100% rename from docs/bsatn.md rename to docs/SATN Reference/Binary Format.md diff --git a/docs/SATN Reference/_category.json b/docs/SATN Reference/_category.json new file mode 100644 index 00000000..e26b2f05 --- /dev/null +++ b/docs/SATN Reference/_category.json @@ -0,0 +1 @@ +{"title":"SATN Reference","disabled":false,"index":"index.md"} \ No newline at end of file diff --git a/docs/satn.md b/docs/SATN Reference/index.md similarity index 100% rename from docs/satn.md rename to docs/SATN Reference/index.md diff --git a/docs/SQL Reference/_category.json b/docs/SQL Reference/_category.json new file mode 100644 index 00000000..73d7df23 --- /dev/null +++ b/docs/SQL Reference/_category.json @@ -0,0 +1 @@ +{"title":"SQL Reference","disabled":false,"index":"index.md"} \ No newline at end of file diff --git a/docs/sql/index.md b/docs/SQL Reference/index.md similarity index 100% rename from docs/sql/index.md rename to docs/SQL Reference/index.md diff --git a/docs/modules/c-sharp/index.md b/docs/Server Module Languages/C#/ModuleReference.md similarity index 100% rename from docs/modules/c-sharp/index.md rename to docs/Server Module Languages/C#/ModuleReference.md diff --git a/docs/Server Module Languages/C#/_category.json b/docs/Server Module Languages/C#/_category.json new file mode 100644 index 00000000..71ae9015 --- /dev/null +++ b/docs/Server Module Languages/C#/_category.json @@ -0,0 +1,6 @@ +{ + "title": "C#", + "disabled": false, + "index": "index.md", + "tag": "Expiremental" +} \ No newline at end of file diff --git a/docs/modules/c-sharp/quickstart.md b/docs/Server Module Languages/C#/index.md similarity index 100% rename from docs/modules/c-sharp/quickstart.md rename to docs/Server Module Languages/C#/index.md diff --git a/docs/modules/rust/index.md b/docs/Server Module Languages/Rust/ModuleReference.md similarity index 100% rename from docs/modules/rust/index.md rename to docs/Server Module Languages/Rust/ModuleReference.md diff --git a/docs/Server Module Languages/Rust/_category.json b/docs/Server Module Languages/Rust/_category.json new file mode 100644 index 00000000..6280366c --- /dev/null +++ b/docs/Server Module Languages/Rust/_category.json @@ -0,0 +1,5 @@ +{ + "title": "Rust", + "disabled": false, + "index": "index.md" +} \ No newline at end of file diff --git a/docs/modules/rust/quickstart.md b/docs/Server Module Languages/Rust/index.md similarity index 100% rename from docs/modules/rust/quickstart.md rename to docs/Server Module Languages/Rust/index.md diff --git a/docs/Server Module Languages/_category.json b/docs/Server Module Languages/_category.json new file mode 100644 index 00000000..3bfa0e87 --- /dev/null +++ b/docs/Server Module Languages/_category.json @@ -0,0 +1 @@ +{"title":"Server Module Languages","disabled":false,"index":"index.md"} \ No newline at end of file diff --git a/docs/modules/index.md b/docs/Server Module Languages/index.md similarity index 100% rename from docs/modules/index.md rename to docs/Server Module Languages/index.md diff --git a/docs/unity/part-1.md b/docs/Unity Tutorial/Part 1 - Basic Multiplayer.md similarity index 100% rename from docs/unity/part-1.md rename to docs/Unity Tutorial/Part 1 - Basic Multiplayer.md diff --git a/docs/unity/part-2.md b/docs/Unity Tutorial/Part 2 - Resources And Scheduling.md similarity index 100% rename from docs/unity/part-2.md rename to docs/Unity Tutorial/Part 2 - Resources And Scheduling.md diff --git a/docs/unity/part-3.md b/docs/Unity Tutorial/Part 3 - BitCraft Mini.md similarity index 100% rename from docs/unity/part-3.md rename to docs/Unity Tutorial/Part 3 - BitCraft Mini.md diff --git a/docs/Unity Tutorial/_category.json b/docs/Unity Tutorial/_category.json new file mode 100644 index 00000000..a3c837ad --- /dev/null +++ b/docs/Unity Tutorial/_category.json @@ -0,0 +1,5 @@ +{ + "title": "Unity Tutorial", + "disabled": false, + "index": "Part 1 - Basic Multiplayer.md" +} \ No newline at end of file diff --git a/docs/WebSocket API Reference/_category.json b/docs/WebSocket API Reference/_category.json new file mode 100644 index 00000000..d2797306 --- /dev/null +++ b/docs/WebSocket API Reference/_category.json @@ -0,0 +1 @@ +{"title":"WebSocket API Reference","disabled":false,"index":"index.md"} \ No newline at end of file diff --git a/docs/ws/index.md b/docs/WebSocket API Reference/index.md similarity index 100% rename from docs/ws/index.md rename to docs/WebSocket API Reference/index.md diff --git a/docs/nav.js b/docs/nav.js index cb8d22f1..b62e9b7f 100644 --- a/docs/nav.js +++ b/docs/nav.js @@ -9,44 +9,44 @@ function section(title) { const nav = { items: [ section("Intro"), - page("Overview", "index", "index.md"), - page("Getting Started", "getting-started", "getting-started.md"), + page("Overview", "index", "Overview/index.md"), + page("Getting Started", "getting-started", "Getting Started/index.md"), section("Deploying"), - page("Testnet", "deploying/testnet", "deploying/testnet.md"), + page("Testnet", "deploying/testnet", "Cloud Testnet/index.md"), section("Unity Tutorial"), - page("Part 1 - Basic Multiplayer", "unity/part-1", "unity/part-1.md"), - page("Part 2 - Resources And Scheduling", "unity/part-2", "unity/part-2.md"), - page("Part 3 - BitCraft Mini", "unity/part-3", "unity/part-3.md"), + page("Part 1 - Basic Multiplayer", "unity/part-1", "Unity Tutorial/Part 1 - Basic Multiplayer.md"), + page("Part 2 - Resources And Scheduling", "unity/part-2", "Unity Tutorial/Part 2 - Resources And Scheduling.md"), + page("Part 3 - BitCraft Mini", "unity/part-3", "Unity Tutorial/Part 3 - BitCraft Mini.md"), section("Server Module Languages"), - page("Overview", "modules", "modules/index.md"), - page("Rust Quickstart", "modules/rust/quickstart", "modules/rust/quickstart.md"), - page("Rust Reference", "modules/rust", "modules/rust/index.md"), - page("C# Quickstart", "modules/c-sharp/quickstart", "modules/c-sharp/quickstart.md"), - page("C# Reference", "modules/c-sharp", "modules/c-sharp/index.md"), + page("Overview", "modules", "Server Module Languages/index.md"), + page("Rust Quickstart", "modules/rust/quickstart", "Server Module Languages/Rust/index.md"), + page("Rust Reference", "modules/rust", "Server Module Languages/Rust/ModuleReference.md"), + page("C# Quickstart", "modules/c-sharp/quickstart", "Server Module Languages/C#/index.md"), + page("C# Reference", "modules/c-sharp", "Server Module Languages/C#/ModuleReference.md"), section("Client SDK Languages"), - page("Overview", "sdks", "sdks/index.md"), - page("Typescript Quickstart", "sdks/typescript/quickstart", "sdks/typescript/quickstart.md"), - page("Typescript Reference", "sdks/typescript", "sdks/typescript/index.md"), - page("Rust Quickstart", "sdks/rust/quickstart", "sdks/rust/quickstart.md"), - page("Rust Reference", "sdks/rust", "sdks/rust/index.md"), - page("Python Quickstart", "sdks/python/quickstart", "sdks/python/quickstart.md"), - page("Python Reference", "sdks/python", "sdks/python/index.md"), - page("C# Quickstart", "sdks/c-sharp/quickstart", "sdks/c-sharp/quickstart.md"), - page("C# Reference", "sdks/c-sharp", "sdks/c-sharp/index.md"), + page("Overview", "sdks", "Client SDK Languages/index.md"), + page("Typescript Quickstart", "sdks/typescript/quickstart", "Client SDK Languages/Typescript/index.md"), + page("Typescript Reference", "sdks/typescript", "Client SDK Languages/Typescript/SDK Reference.md"), + page("Rust Quickstart", "sdks/rust/quickstart", "Client SDK Languages/Rust/index.md"), + page("Rust Reference", "sdks/rust", "Client SDK Languages/Rust/SDK Reference.md"), + page("Python Quickstart", "sdks/python/quickstart", "Client SDK Languages/Python/index.md"), + page("Python Reference", "sdks/python", "Client SDK Languages/Python/SDK Reference.md"), + page("C# Quickstart", "sdks/c-sharp/quickstart", "Client SDK Languages/C#/index.md"), + page("C# Reference", "sdks/c-sharp", "Client SDK Languages/C#/SDK Reference.md"), section("WebAssembly ABI"), - page("Module ABI Reference", "webassembly-abi", "webassembly-abi/index.md"), + page("Module ABI Reference", "webassembly-abi", "Module ABI Reference/index.md"), section("HTTP API"), - page("HTTP", "http", "http/index.md"), - page("`/identity`", "http/identity", "http/identity.md"), - page("`/database`", "http/database", "http/database.md"), - page("`/energy`", "http/energy", "http/energy.md"), + page("HTTP", "http", "HTTP API Reference/index.md"), + page("`/identity`", "http/identity", "HTTP API Reference/Identities.md"), + page("`/database`", "http/database", "HTTP API Reference/Databases.md"), + page("`/energy`", "http/energy", "HTTP API Reference/Energy.md"), section("WebSocket API Reference"), - page("WebSocket", "ws", "ws/index.md"), + page("WebSocket", "ws", "WebSocket API Reference/index.md"), section("Data Format"), - page("SATN", "satn", "satn.md"), - page("BSATN", "bsatn", "bsatn.md"), + page("SATN", "satn", "SATN Reference/index.md"), + page("BSATN", "bsatn", "SATN Reference/Binary Format.md"), section("SQL"), - page("SQL Reference", "sql", "sql/index.md"), + page("SQL Reference", "sql", "SQL Reference/index.md"), ], }; exports.default = nav; diff --git a/nav.ts b/nav.ts index 6d5a304b..b9a64ee0 100644 --- a/nav.ts +++ b/nav.ts @@ -25,53 +25,53 @@ function section(title: string): NavSection { const nav: Nav = { items: [ section("Intro"), - page("Overview", "index", "index.md"), - page("Getting Started", "getting-started", "getting-started.md"), + page("Overview", "index", "Overview/index.md"), + page("Getting Started", "getting-started", "Getting Started/index.md"), section("Deploying"), - page("Testnet", "deploying/testnet", "deploying/testnet.md"), + page("Testnet", "deploying/testnet", "Cloud Testnet/index.md"), section("Unity Tutorial"), - page("Part 1 - Basic Multiplayer", "unity/part-1", "unity/part-1.md"), - page("Part 2 - Resources And Scheduling", "unity/part-2", "unity/part-2.md"), - page("Part 3 - BitCraft Mini", "unity/part-3", "unity/part-3.md"), + page("Part 1 - Basic Multiplayer", "unity/part-1", "Unity Tutorial/Part 1 - Basic Multiplayer.md"), + page("Part 2 - Resources And Scheduling", "unity/part-2", "Unity Tutorial/Part 2 - Resources And Scheduling.md"), + page("Part 3 - BitCraft Mini", "unity/part-3", "Unity Tutorial/Part 3 - BitCraft Mini.md"), section("Server Module Languages"), - page("Overview", "modules", "modules/index.md"), - page("Rust Quickstart", "modules/rust/quickstart", "modules/rust/quickstart.md"), - page("Rust Reference", "modules/rust", "modules/rust/index.md"), - page("C# Quickstart", "modules/c-sharp/quickstart", "modules/c-sharp/quickstart.md"), - page("C# Reference", "modules/c-sharp", "modules/c-sharp/index.md"), + page("Overview", "modules", "Server Module Languages/index.md"), + page("Rust Quickstart", "modules/rust/quickstart", "Server Module Languages/Rust/index.md"), + page("Rust Reference", "modules/rust", "Server Module Languages/Rust/ModuleReference.md"), + page("C# Quickstart", "modules/c-sharp/quickstart", "Server Module Languages/C#/index.md"), + page("C# Reference", "modules/c-sharp", "Server Module Languages/C#/ModuleReference.md"), section("Client SDK Languages"), - page("Overview", "sdks", "sdks/index.md"), - page("Typescript Quickstart", "sdks/typescript/quickstart", "sdks/typescript/quickstart.md"), - page("Typescript Reference", "sdks/typescript", "sdks/typescript/index.md"), - page("Rust Quickstart", "sdks/rust/quickstart", "sdks/rust/quickstart.md"), - page("Rust Reference", "sdks/rust", "sdks/rust/index.md"), - page("Python Quickstart", "sdks/python/quickstart", "sdks/python/quickstart.md"), - page("Python Reference", "sdks/python", "sdks/python/index.md"), - page("C# Quickstart", "sdks/c-sharp/quickstart", "sdks/c-sharp/quickstart.md"), - page("C# Reference", "sdks/c-sharp", "sdks/c-sharp/index.md"), + page("Overview", "sdks", "Client SDK Languages/index.md"), + page("Typescript Quickstart", "sdks/typescript/quickstart", "Client SDK Languages/Typescript/index.md"), + page("Typescript Reference", "sdks/typescript", "Client SDK Languages/Typescript/SDK Reference.md"), + page("Rust Quickstart", "sdks/rust/quickstart", "Client SDK Languages/Rust/index.md"), + page("Rust Reference", "sdks/rust", "Client SDK Languages/Rust/SDK Reference.md"), + page("Python Quickstart", "sdks/python/quickstart", "Client SDK Languages/Python/index.md"), + page("Python Reference", "sdks/python", "Client SDK Languages/Python/SDK Reference.md"), + page("C# Quickstart", "sdks/c-sharp/quickstart", "Client SDK Languages/C#/index.md"), + page("C# Reference", "sdks/c-sharp", "Client SDK Languages/C#/SDK Reference.md"), section("WebAssembly ABI"), - page("Module ABI Reference", "webassembly-abi", "webassembly-abi/index.md"), + page("Module ABI Reference", "webassembly-abi", "Module ABI Reference/index.md"), section("HTTP API"), - page("HTTP", "http", "http/index.md"), - page("`/identity`", "http/identity", "http/identity.md"), - page("`/database`", "http/database", "http/database.md"), - page("`/energy`", "http/energy", "http/energy.md"), + page("HTTP", "http", "HTTP API Reference/index.md"), + page("`/identity`", "http/identity", "HTTP API Reference/Identities.md"), + page("`/database`", "http/database", "HTTP API Reference/Databases.md"), + page("`/energy`", "http/energy", "HTTP API Reference/Energy.md"), section("WebSocket API Reference"), - page("WebSocket", "ws", "ws/index.md"), + page("WebSocket", "ws", "WebSocket API Reference/index.md"), section("Data Format"), - page("SATN", "satn", "satn.md"), - page("BSATN", "bsatn", "bsatn.md"), + page("SATN", "satn", "SATN Reference/index.md"), + page("BSATN", "bsatn", "SATN Reference/Binary Format.md"), section("SQL"), - page("SQL Reference", "sql", "sql/index.md"), + page("SQL Reference", "sql", "SQL Reference/index.md"), ], }; From 5e79903267f23362d3b31afe3bb0036b2c62065b Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Thu, 7 Dec 2023 18:30:11 -0800 Subject: [PATCH 025/164] Update index.md --- docs/Server Module Languages/Rust/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/Server Module Languages/Rust/index.md b/docs/Server Module Languages/Rust/index.md index 6e0f1747..e0ff0f5f 100644 --- a/docs/Server Module Languages/Rust/index.md +++ b/docs/Server Module Languages/Rust/index.md @@ -234,7 +234,7 @@ spacetime publish --project-path server You can use the CLI (command line interface) to run reducers. The arguments to the reducer are passed in JSON format. ```bash -spacetime call send_message '["Hello, World!"]' +spacetime call send_message 'Hello, World!' ``` Once we've called our `send_message` reducer, we can check to make sure it ran by running the `logs` command. From 8b32302e6976d3dbac9082eb3b6f0ca7cdb59556 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Thu, 7 Dec 2023 18:34:55 -0800 Subject: [PATCH 026/164] Update index.md --- docs/Getting Started/index.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/docs/Getting Started/index.md b/docs/Getting Started/index.md index 54337d08..5a0c6041 100644 --- a/docs/Getting Started/index.md +++ b/docs/Getting Started/index.md @@ -13,12 +13,6 @@ The server listens on port `3000` by default. You can change this by using the ` SSL is not supported in standalone mode. -To set up your CLI to connect to the server, you can run the `spacetime server` command. - -```bash -spacetime server set "http://localhost:3000" -``` - ## What's Next? You are ready to start developing SpacetimeDB modules. We have a quickstart guide for each supported server-side language: From bc9a334c16efe6ae56aa6e1ecf13675908e64ff7 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Fri, 15 Dec 2023 16:22:38 -0800 Subject: [PATCH 027/164] Update index.md --- docs/Client SDK Languages/Typescript/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/Client SDK Languages/Typescript/index.md b/docs/Client SDK Languages/Typescript/index.md index ab7cfe89..0ec6b0eb 100644 --- a/docs/Client SDK Languages/Typescript/index.md +++ b/docs/Client SDK Languages/Typescript/index.md @@ -152,7 +152,7 @@ In your `quickstart-chat` directory, run: ```bash mkdir -p client/src/module_bindings -spacetime generate --lang typescript --out-dir client/src/module_bindings --project_path server +spacetime generate --lang typescript --out-dir client/src/module_bindings --project-path server ``` Take a look inside `client/src/module_bindings`. The CLI should have generated four files: From ab103824c73b79dc8f1ff0ef95d8a45b4084bf91 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Fri, 15 Dec 2023 16:24:16 -0800 Subject: [PATCH 028/164] Update index.md --- docs/Cloud Testnet/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/Cloud Testnet/index.md b/docs/Cloud Testnet/index.md index abb90fb8..ce648043 100644 --- a/docs/Cloud Testnet/index.md +++ b/docs/Cloud Testnet/index.md @@ -10,7 +10,7 @@ Currently only the `testnet` is available for SpacetimeDB cloud which is subject 1. Configure your CLI to use the SpacetimeDB Cloud. To do this, run the `spacetime server` command: ```bash -spacetime server set "https://testnet.spacetimedb.com" +spacetime server add --default "https://testnet.spacetimedb.com" testnet ``` ## Connecting your Identity to the Web Dashboard From 059d87b15d75ec5782c83018adbfc9edff43f017 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Fri, 15 Dec 2023 23:19:22 -0800 Subject: [PATCH 029/164] Revert "Revert "Reorganized the doc paths to match slugs and removed _category.json files"" (#23) This reverts commit 1fb1e0fba655e08914c5f3d4243ac840424ba640. --- docs/Client SDK Languages/C#/_category.json | 5 -- .../Python/_category.json | 5 -- docs/Client SDK Languages/Rust/_category.json | 5 -- .../Typescript/_category.json | 5 -- docs/Client SDK Languages/_category.json | 1 - docs/Cloud Testnet/_category.json | 1 - docs/Getting Started/_category.json | 1 - docs/HTTP API Reference/_category.json | 1 - docs/Module ABI Reference/_category.json | 1 - docs/Overview/_category.json | 1 - docs/SATN Reference/_category.json | 1 - docs/SQL Reference/_category.json | 1 - .../Server Module Languages/C#/_category.json | 6 -- .../Rust/_category.json | 5 -- docs/Server Module Languages/_category.json | 1 - docs/Unity Tutorial/_category.json | 5 -- docs/WebSocket API Reference/_category.json | 1 - .../Binary Format.md => bsatn.md} | 0 .../index.md => deploying/testnet.md} | 0 .../index.md => getting-started.md} | 0 .../Databases.md => http/database.md} | 0 .../Energy.md => http/energy.md} | 0 .../Identities.md => http/identity.md} | 0 docs/{HTTP API Reference => http}/index.md | 0 docs/{Overview => }/index.md | 0 .../c-sharp/index.md} | 0 .../c-sharp/quickstart.md} | 0 .../index.md | 0 .../rust/index.md} | 0 .../index.md => modules/rust/quickstart.md} | 0 docs/nav.js | 58 +++++++++---------- docs/{SATN Reference/index.md => satn.md} | 0 .../c-sharp/index.md} | 0 .../index.md => sdks/c-sharp/quickstart.md} | 0 docs/{Client SDK Languages => sdks}/index.md | 0 .../SDK Reference.md => sdks/python/index.md} | 0 .../index.md => sdks/python/quickstart.md} | 0 .../SDK Reference.md => sdks/rust/index.md} | 0 .../Rust/index.md => sdks/rust/quickstart.md} | 0 .../typescript/index.md} | 0 .../typescript/quickstart.md} | 0 docs/{SQL Reference => sql}/index.md | 0 .../part-1.md} | 0 .../part-2.md} | 0 .../part-3.md} | 0 .../index.md | 0 docs/{WebSocket API Reference => ws}/index.md | 0 nav.ts | 58 +++++++++---------- 48 files changed, 58 insertions(+), 104 deletions(-) delete mode 100644 docs/Client SDK Languages/C#/_category.json delete mode 100644 docs/Client SDK Languages/Python/_category.json delete mode 100644 docs/Client SDK Languages/Rust/_category.json delete mode 100644 docs/Client SDK Languages/Typescript/_category.json delete mode 100644 docs/Client SDK Languages/_category.json delete mode 100644 docs/Cloud Testnet/_category.json delete mode 100644 docs/Getting Started/_category.json delete mode 100644 docs/HTTP API Reference/_category.json delete mode 100644 docs/Module ABI Reference/_category.json delete mode 100644 docs/Overview/_category.json delete mode 100644 docs/SATN Reference/_category.json delete mode 100644 docs/SQL Reference/_category.json delete mode 100644 docs/Server Module Languages/C#/_category.json delete mode 100644 docs/Server Module Languages/Rust/_category.json delete mode 100644 docs/Server Module Languages/_category.json delete mode 100644 docs/Unity Tutorial/_category.json delete mode 100644 docs/WebSocket API Reference/_category.json rename docs/{SATN Reference/Binary Format.md => bsatn.md} (100%) rename docs/{Cloud Testnet/index.md => deploying/testnet.md} (100%) rename docs/{Getting Started/index.md => getting-started.md} (100%) rename docs/{HTTP API Reference/Databases.md => http/database.md} (100%) rename docs/{HTTP API Reference/Energy.md => http/energy.md} (100%) rename docs/{HTTP API Reference/Identities.md => http/identity.md} (100%) rename docs/{HTTP API Reference => http}/index.md (100%) rename docs/{Overview => }/index.md (100%) rename docs/{Server Module Languages/C#/ModuleReference.md => modules/c-sharp/index.md} (100%) rename docs/{Server Module Languages/C#/index.md => modules/c-sharp/quickstart.md} (100%) rename docs/{Server Module Languages => modules}/index.md (100%) rename docs/{Server Module Languages/Rust/ModuleReference.md => modules/rust/index.md} (100%) rename docs/{Server Module Languages/Rust/index.md => modules/rust/quickstart.md} (100%) rename docs/{SATN Reference/index.md => satn.md} (100%) rename docs/{Client SDK Languages/C#/SDK Reference.md => sdks/c-sharp/index.md} (100%) rename docs/{Client SDK Languages/C#/index.md => sdks/c-sharp/quickstart.md} (100%) rename docs/{Client SDK Languages => sdks}/index.md (100%) rename docs/{Client SDK Languages/Python/SDK Reference.md => sdks/python/index.md} (100%) rename docs/{Client SDK Languages/Python/index.md => sdks/python/quickstart.md} (100%) rename docs/{Client SDK Languages/Rust/SDK Reference.md => sdks/rust/index.md} (100%) rename docs/{Client SDK Languages/Rust/index.md => sdks/rust/quickstart.md} (100%) rename docs/{Client SDK Languages/Typescript/SDK Reference.md => sdks/typescript/index.md} (100%) rename docs/{Client SDK Languages/Typescript/index.md => sdks/typescript/quickstart.md} (100%) rename docs/{SQL Reference => sql}/index.md (100%) rename docs/{Unity Tutorial/Part 1 - Basic Multiplayer.md => unity/part-1.md} (100%) rename docs/{Unity Tutorial/Part 2 - Resources And Scheduling.md => unity/part-2.md} (100%) rename docs/{Unity Tutorial/Part 3 - BitCraft Mini.md => unity/part-3.md} (100%) rename docs/{Module ABI Reference => webassembly-abi}/index.md (100%) rename docs/{WebSocket API Reference => ws}/index.md (100%) diff --git a/docs/Client SDK Languages/C#/_category.json b/docs/Client SDK Languages/C#/_category.json deleted file mode 100644 index 60238f8e..00000000 --- a/docs/Client SDK Languages/C#/_category.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "title": "C#", - "disabled": false, - "index": "index.md" -} \ No newline at end of file diff --git a/docs/Client SDK Languages/Python/_category.json b/docs/Client SDK Languages/Python/_category.json deleted file mode 100644 index 4e08cfa1..00000000 --- a/docs/Client SDK Languages/Python/_category.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "title": "Python", - "disabled": false, - "index": "index.md" -} \ No newline at end of file diff --git a/docs/Client SDK Languages/Rust/_category.json b/docs/Client SDK Languages/Rust/_category.json deleted file mode 100644 index 6280366c..00000000 --- a/docs/Client SDK Languages/Rust/_category.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "title": "Rust", - "disabled": false, - "index": "index.md" -} \ No newline at end of file diff --git a/docs/Client SDK Languages/Typescript/_category.json b/docs/Client SDK Languages/Typescript/_category.json deleted file mode 100644 index 590d44a2..00000000 --- a/docs/Client SDK Languages/Typescript/_category.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "title": "Typescript", - "disabled": false, - "index": "index.md" -} \ No newline at end of file diff --git a/docs/Client SDK Languages/_category.json b/docs/Client SDK Languages/_category.json deleted file mode 100644 index 530c17aa..00000000 --- a/docs/Client SDK Languages/_category.json +++ /dev/null @@ -1 +0,0 @@ -{"title":"Client SDK Languages","disabled":false,"index":"index.md"} \ No newline at end of file diff --git a/docs/Cloud Testnet/_category.json b/docs/Cloud Testnet/_category.json deleted file mode 100644 index e6fa11b9..00000000 --- a/docs/Cloud Testnet/_category.json +++ /dev/null @@ -1 +0,0 @@ -{"title":"Cloud Testnet","disabled":false,"index":"index.md"} \ No newline at end of file diff --git a/docs/Getting Started/_category.json b/docs/Getting Started/_category.json deleted file mode 100644 index a68dc36c..00000000 --- a/docs/Getting Started/_category.json +++ /dev/null @@ -1 +0,0 @@ -{"title":"Getting Started","disabled":false,"index":"index.md"} \ No newline at end of file diff --git a/docs/HTTP API Reference/_category.json b/docs/HTTP API Reference/_category.json deleted file mode 100644 index c8ad821b..00000000 --- a/docs/HTTP API Reference/_category.json +++ /dev/null @@ -1 +0,0 @@ -{"title":"HTTP API Reference","disabled":false,"index":"index.md"} \ No newline at end of file diff --git a/docs/Module ABI Reference/_category.json b/docs/Module ABI Reference/_category.json deleted file mode 100644 index 7583598d..00000000 --- a/docs/Module ABI Reference/_category.json +++ /dev/null @@ -1 +0,0 @@ -{"title":"Module ABI Reference","disabled":false,"index":"index.md"} \ No newline at end of file diff --git a/docs/Overview/_category.json b/docs/Overview/_category.json deleted file mode 100644 index 35164a50..00000000 --- a/docs/Overview/_category.json +++ /dev/null @@ -1 +0,0 @@ -{"title":"Overview","disabled":false,"index":"index.md"} \ No newline at end of file diff --git a/docs/SATN Reference/_category.json b/docs/SATN Reference/_category.json deleted file mode 100644 index e26b2f05..00000000 --- a/docs/SATN Reference/_category.json +++ /dev/null @@ -1 +0,0 @@ -{"title":"SATN Reference","disabled":false,"index":"index.md"} \ No newline at end of file diff --git a/docs/SQL Reference/_category.json b/docs/SQL Reference/_category.json deleted file mode 100644 index 73d7df23..00000000 --- a/docs/SQL Reference/_category.json +++ /dev/null @@ -1 +0,0 @@ -{"title":"SQL Reference","disabled":false,"index":"index.md"} \ No newline at end of file diff --git a/docs/Server Module Languages/C#/_category.json b/docs/Server Module Languages/C#/_category.json deleted file mode 100644 index 71ae9015..00000000 --- a/docs/Server Module Languages/C#/_category.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "title": "C#", - "disabled": false, - "index": "index.md", - "tag": "Expiremental" -} \ No newline at end of file diff --git a/docs/Server Module Languages/Rust/_category.json b/docs/Server Module Languages/Rust/_category.json deleted file mode 100644 index 6280366c..00000000 --- a/docs/Server Module Languages/Rust/_category.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "title": "Rust", - "disabled": false, - "index": "index.md" -} \ No newline at end of file diff --git a/docs/Server Module Languages/_category.json b/docs/Server Module Languages/_category.json deleted file mode 100644 index 3bfa0e87..00000000 --- a/docs/Server Module Languages/_category.json +++ /dev/null @@ -1 +0,0 @@ -{"title":"Server Module Languages","disabled":false,"index":"index.md"} \ No newline at end of file diff --git a/docs/Unity Tutorial/_category.json b/docs/Unity Tutorial/_category.json deleted file mode 100644 index a3c837ad..00000000 --- a/docs/Unity Tutorial/_category.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "title": "Unity Tutorial", - "disabled": false, - "index": "Part 1 - Basic Multiplayer.md" -} \ No newline at end of file diff --git a/docs/WebSocket API Reference/_category.json b/docs/WebSocket API Reference/_category.json deleted file mode 100644 index d2797306..00000000 --- a/docs/WebSocket API Reference/_category.json +++ /dev/null @@ -1 +0,0 @@ -{"title":"WebSocket API Reference","disabled":false,"index":"index.md"} \ No newline at end of file diff --git a/docs/SATN Reference/Binary Format.md b/docs/bsatn.md similarity index 100% rename from docs/SATN Reference/Binary Format.md rename to docs/bsatn.md diff --git a/docs/Cloud Testnet/index.md b/docs/deploying/testnet.md similarity index 100% rename from docs/Cloud Testnet/index.md rename to docs/deploying/testnet.md diff --git a/docs/Getting Started/index.md b/docs/getting-started.md similarity index 100% rename from docs/Getting Started/index.md rename to docs/getting-started.md diff --git a/docs/HTTP API Reference/Databases.md b/docs/http/database.md similarity index 100% rename from docs/HTTP API Reference/Databases.md rename to docs/http/database.md diff --git a/docs/HTTP API Reference/Energy.md b/docs/http/energy.md similarity index 100% rename from docs/HTTP API Reference/Energy.md rename to docs/http/energy.md diff --git a/docs/HTTP API Reference/Identities.md b/docs/http/identity.md similarity index 100% rename from docs/HTTP API Reference/Identities.md rename to docs/http/identity.md diff --git a/docs/HTTP API Reference/index.md b/docs/http/index.md similarity index 100% rename from docs/HTTP API Reference/index.md rename to docs/http/index.md diff --git a/docs/Overview/index.md b/docs/index.md similarity index 100% rename from docs/Overview/index.md rename to docs/index.md diff --git a/docs/Server Module Languages/C#/ModuleReference.md b/docs/modules/c-sharp/index.md similarity index 100% rename from docs/Server Module Languages/C#/ModuleReference.md rename to docs/modules/c-sharp/index.md diff --git a/docs/Server Module Languages/C#/index.md b/docs/modules/c-sharp/quickstart.md similarity index 100% rename from docs/Server Module Languages/C#/index.md rename to docs/modules/c-sharp/quickstart.md diff --git a/docs/Server Module Languages/index.md b/docs/modules/index.md similarity index 100% rename from docs/Server Module Languages/index.md rename to docs/modules/index.md diff --git a/docs/Server Module Languages/Rust/ModuleReference.md b/docs/modules/rust/index.md similarity index 100% rename from docs/Server Module Languages/Rust/ModuleReference.md rename to docs/modules/rust/index.md diff --git a/docs/Server Module Languages/Rust/index.md b/docs/modules/rust/quickstart.md similarity index 100% rename from docs/Server Module Languages/Rust/index.md rename to docs/modules/rust/quickstart.md diff --git a/docs/nav.js b/docs/nav.js index b62e9b7f..cb8d22f1 100644 --- a/docs/nav.js +++ b/docs/nav.js @@ -9,44 +9,44 @@ function section(title) { const nav = { items: [ section("Intro"), - page("Overview", "index", "Overview/index.md"), - page("Getting Started", "getting-started", "Getting Started/index.md"), + page("Overview", "index", "index.md"), + page("Getting Started", "getting-started", "getting-started.md"), section("Deploying"), - page("Testnet", "deploying/testnet", "Cloud Testnet/index.md"), + page("Testnet", "deploying/testnet", "deploying/testnet.md"), section("Unity Tutorial"), - page("Part 1 - Basic Multiplayer", "unity/part-1", "Unity Tutorial/Part 1 - Basic Multiplayer.md"), - page("Part 2 - Resources And Scheduling", "unity/part-2", "Unity Tutorial/Part 2 - Resources And Scheduling.md"), - page("Part 3 - BitCraft Mini", "unity/part-3", "Unity Tutorial/Part 3 - BitCraft Mini.md"), + page("Part 1 - Basic Multiplayer", "unity/part-1", "unity/part-1.md"), + page("Part 2 - Resources And Scheduling", "unity/part-2", "unity/part-2.md"), + page("Part 3 - BitCraft Mini", "unity/part-3", "unity/part-3.md"), section("Server Module Languages"), - page("Overview", "modules", "Server Module Languages/index.md"), - page("Rust Quickstart", "modules/rust/quickstart", "Server Module Languages/Rust/index.md"), - page("Rust Reference", "modules/rust", "Server Module Languages/Rust/ModuleReference.md"), - page("C# Quickstart", "modules/c-sharp/quickstart", "Server Module Languages/C#/index.md"), - page("C# Reference", "modules/c-sharp", "Server Module Languages/C#/ModuleReference.md"), + page("Overview", "modules", "modules/index.md"), + page("Rust Quickstart", "modules/rust/quickstart", "modules/rust/quickstart.md"), + page("Rust Reference", "modules/rust", "modules/rust/index.md"), + page("C# Quickstart", "modules/c-sharp/quickstart", "modules/c-sharp/quickstart.md"), + page("C# Reference", "modules/c-sharp", "modules/c-sharp/index.md"), section("Client SDK Languages"), - page("Overview", "sdks", "Client SDK Languages/index.md"), - page("Typescript Quickstart", "sdks/typescript/quickstart", "Client SDK Languages/Typescript/index.md"), - page("Typescript Reference", "sdks/typescript", "Client SDK Languages/Typescript/SDK Reference.md"), - page("Rust Quickstart", "sdks/rust/quickstart", "Client SDK Languages/Rust/index.md"), - page("Rust Reference", "sdks/rust", "Client SDK Languages/Rust/SDK Reference.md"), - page("Python Quickstart", "sdks/python/quickstart", "Client SDK Languages/Python/index.md"), - page("Python Reference", "sdks/python", "Client SDK Languages/Python/SDK Reference.md"), - page("C# Quickstart", "sdks/c-sharp/quickstart", "Client SDK Languages/C#/index.md"), - page("C# Reference", "sdks/c-sharp", "Client SDK Languages/C#/SDK Reference.md"), + page("Overview", "sdks", "sdks/index.md"), + page("Typescript Quickstart", "sdks/typescript/quickstart", "sdks/typescript/quickstart.md"), + page("Typescript Reference", "sdks/typescript", "sdks/typescript/index.md"), + page("Rust Quickstart", "sdks/rust/quickstart", "sdks/rust/quickstart.md"), + page("Rust Reference", "sdks/rust", "sdks/rust/index.md"), + page("Python Quickstart", "sdks/python/quickstart", "sdks/python/quickstart.md"), + page("Python Reference", "sdks/python", "sdks/python/index.md"), + page("C# Quickstart", "sdks/c-sharp/quickstart", "sdks/c-sharp/quickstart.md"), + page("C# Reference", "sdks/c-sharp", "sdks/c-sharp/index.md"), section("WebAssembly ABI"), - page("Module ABI Reference", "webassembly-abi", "Module ABI Reference/index.md"), + page("Module ABI Reference", "webassembly-abi", "webassembly-abi/index.md"), section("HTTP API"), - page("HTTP", "http", "HTTP API Reference/index.md"), - page("`/identity`", "http/identity", "HTTP API Reference/Identities.md"), - page("`/database`", "http/database", "HTTP API Reference/Databases.md"), - page("`/energy`", "http/energy", "HTTP API Reference/Energy.md"), + page("HTTP", "http", "http/index.md"), + page("`/identity`", "http/identity", "http/identity.md"), + page("`/database`", "http/database", "http/database.md"), + page("`/energy`", "http/energy", "http/energy.md"), section("WebSocket API Reference"), - page("WebSocket", "ws", "WebSocket API Reference/index.md"), + page("WebSocket", "ws", "ws/index.md"), section("Data Format"), - page("SATN", "satn", "SATN Reference/index.md"), - page("BSATN", "bsatn", "SATN Reference/Binary Format.md"), + page("SATN", "satn", "satn.md"), + page("BSATN", "bsatn", "bsatn.md"), section("SQL"), - page("SQL Reference", "sql", "SQL Reference/index.md"), + page("SQL Reference", "sql", "sql/index.md"), ], }; exports.default = nav; diff --git a/docs/SATN Reference/index.md b/docs/satn.md similarity index 100% rename from docs/SATN Reference/index.md rename to docs/satn.md diff --git a/docs/Client SDK Languages/C#/SDK Reference.md b/docs/sdks/c-sharp/index.md similarity index 100% rename from docs/Client SDK Languages/C#/SDK Reference.md rename to docs/sdks/c-sharp/index.md diff --git a/docs/Client SDK Languages/C#/index.md b/docs/sdks/c-sharp/quickstart.md similarity index 100% rename from docs/Client SDK Languages/C#/index.md rename to docs/sdks/c-sharp/quickstart.md diff --git a/docs/Client SDK Languages/index.md b/docs/sdks/index.md similarity index 100% rename from docs/Client SDK Languages/index.md rename to docs/sdks/index.md diff --git a/docs/Client SDK Languages/Python/SDK Reference.md b/docs/sdks/python/index.md similarity index 100% rename from docs/Client SDK Languages/Python/SDK Reference.md rename to docs/sdks/python/index.md diff --git a/docs/Client SDK Languages/Python/index.md b/docs/sdks/python/quickstart.md similarity index 100% rename from docs/Client SDK Languages/Python/index.md rename to docs/sdks/python/quickstart.md diff --git a/docs/Client SDK Languages/Rust/SDK Reference.md b/docs/sdks/rust/index.md similarity index 100% rename from docs/Client SDK Languages/Rust/SDK Reference.md rename to docs/sdks/rust/index.md diff --git a/docs/Client SDK Languages/Rust/index.md b/docs/sdks/rust/quickstart.md similarity index 100% rename from docs/Client SDK Languages/Rust/index.md rename to docs/sdks/rust/quickstart.md diff --git a/docs/Client SDK Languages/Typescript/SDK Reference.md b/docs/sdks/typescript/index.md similarity index 100% rename from docs/Client SDK Languages/Typescript/SDK Reference.md rename to docs/sdks/typescript/index.md diff --git a/docs/Client SDK Languages/Typescript/index.md b/docs/sdks/typescript/quickstart.md similarity index 100% rename from docs/Client SDK Languages/Typescript/index.md rename to docs/sdks/typescript/quickstart.md diff --git a/docs/SQL Reference/index.md b/docs/sql/index.md similarity index 100% rename from docs/SQL Reference/index.md rename to docs/sql/index.md diff --git a/docs/Unity Tutorial/Part 1 - Basic Multiplayer.md b/docs/unity/part-1.md similarity index 100% rename from docs/Unity Tutorial/Part 1 - Basic Multiplayer.md rename to docs/unity/part-1.md diff --git a/docs/Unity Tutorial/Part 2 - Resources And Scheduling.md b/docs/unity/part-2.md similarity index 100% rename from docs/Unity Tutorial/Part 2 - Resources And Scheduling.md rename to docs/unity/part-2.md diff --git a/docs/Unity Tutorial/Part 3 - BitCraft Mini.md b/docs/unity/part-3.md similarity index 100% rename from docs/Unity Tutorial/Part 3 - BitCraft Mini.md rename to docs/unity/part-3.md diff --git a/docs/Module ABI Reference/index.md b/docs/webassembly-abi/index.md similarity index 100% rename from docs/Module ABI Reference/index.md rename to docs/webassembly-abi/index.md diff --git a/docs/WebSocket API Reference/index.md b/docs/ws/index.md similarity index 100% rename from docs/WebSocket API Reference/index.md rename to docs/ws/index.md diff --git a/nav.ts b/nav.ts index b9a64ee0..6d5a304b 100644 --- a/nav.ts +++ b/nav.ts @@ -25,53 +25,53 @@ function section(title: string): NavSection { const nav: Nav = { items: [ section("Intro"), - page("Overview", "index", "Overview/index.md"), - page("Getting Started", "getting-started", "Getting Started/index.md"), + page("Overview", "index", "index.md"), + page("Getting Started", "getting-started", "getting-started.md"), section("Deploying"), - page("Testnet", "deploying/testnet", "Cloud Testnet/index.md"), + page("Testnet", "deploying/testnet", "deploying/testnet.md"), section("Unity Tutorial"), - page("Part 1 - Basic Multiplayer", "unity/part-1", "Unity Tutorial/Part 1 - Basic Multiplayer.md"), - page("Part 2 - Resources And Scheduling", "unity/part-2", "Unity Tutorial/Part 2 - Resources And Scheduling.md"), - page("Part 3 - BitCraft Mini", "unity/part-3", "Unity Tutorial/Part 3 - BitCraft Mini.md"), + page("Part 1 - Basic Multiplayer", "unity/part-1", "unity/part-1.md"), + page("Part 2 - Resources And Scheduling", "unity/part-2", "unity/part-2.md"), + page("Part 3 - BitCraft Mini", "unity/part-3", "unity/part-3.md"), section("Server Module Languages"), - page("Overview", "modules", "Server Module Languages/index.md"), - page("Rust Quickstart", "modules/rust/quickstart", "Server Module Languages/Rust/index.md"), - page("Rust Reference", "modules/rust", "Server Module Languages/Rust/ModuleReference.md"), - page("C# Quickstart", "modules/c-sharp/quickstart", "Server Module Languages/C#/index.md"), - page("C# Reference", "modules/c-sharp", "Server Module Languages/C#/ModuleReference.md"), + page("Overview", "modules", "modules/index.md"), + page("Rust Quickstart", "modules/rust/quickstart", "modules/rust/quickstart.md"), + page("Rust Reference", "modules/rust", "modules/rust/index.md"), + page("C# Quickstart", "modules/c-sharp/quickstart", "modules/c-sharp/quickstart.md"), + page("C# Reference", "modules/c-sharp", "modules/c-sharp/index.md"), section("Client SDK Languages"), - page("Overview", "sdks", "Client SDK Languages/index.md"), - page("Typescript Quickstart", "sdks/typescript/quickstart", "Client SDK Languages/Typescript/index.md"), - page("Typescript Reference", "sdks/typescript", "Client SDK Languages/Typescript/SDK Reference.md"), - page("Rust Quickstart", "sdks/rust/quickstart", "Client SDK Languages/Rust/index.md"), - page("Rust Reference", "sdks/rust", "Client SDK Languages/Rust/SDK Reference.md"), - page("Python Quickstart", "sdks/python/quickstart", "Client SDK Languages/Python/index.md"), - page("Python Reference", "sdks/python", "Client SDK Languages/Python/SDK Reference.md"), - page("C# Quickstart", "sdks/c-sharp/quickstart", "Client SDK Languages/C#/index.md"), - page("C# Reference", "sdks/c-sharp", "Client SDK Languages/C#/SDK Reference.md"), + page("Overview", "sdks", "sdks/index.md"), + page("Typescript Quickstart", "sdks/typescript/quickstart", "sdks/typescript/quickstart.md"), + page("Typescript Reference", "sdks/typescript", "sdks/typescript/index.md"), + page("Rust Quickstart", "sdks/rust/quickstart", "sdks/rust/quickstart.md"), + page("Rust Reference", "sdks/rust", "sdks/rust/index.md"), + page("Python Quickstart", "sdks/python/quickstart", "sdks/python/quickstart.md"), + page("Python Reference", "sdks/python", "sdks/python/index.md"), + page("C# Quickstart", "sdks/c-sharp/quickstart", "sdks/c-sharp/quickstart.md"), + page("C# Reference", "sdks/c-sharp", "sdks/c-sharp/index.md"), section("WebAssembly ABI"), - page("Module ABI Reference", "webassembly-abi", "Module ABI Reference/index.md"), + page("Module ABI Reference", "webassembly-abi", "webassembly-abi/index.md"), section("HTTP API"), - page("HTTP", "http", "HTTP API Reference/index.md"), - page("`/identity`", "http/identity", "HTTP API Reference/Identities.md"), - page("`/database`", "http/database", "HTTP API Reference/Databases.md"), - page("`/energy`", "http/energy", "HTTP API Reference/Energy.md"), + page("HTTP", "http", "http/index.md"), + page("`/identity`", "http/identity", "http/identity.md"), + page("`/database`", "http/database", "http/database.md"), + page("`/energy`", "http/energy", "http/energy.md"), section("WebSocket API Reference"), - page("WebSocket", "ws", "WebSocket API Reference/index.md"), + page("WebSocket", "ws", "ws/index.md"), section("Data Format"), - page("SATN", "satn", "SATN Reference/index.md"), - page("BSATN", "bsatn", "SATN Reference/Binary Format.md"), + page("SATN", "satn", "satn.md"), + page("BSATN", "bsatn", "bsatn.md"), section("SQL"), - page("SQL Reference", "sql", "SQL Reference/index.md"), + page("SQL Reference", "sql", "sql/index.md"), ], }; From 4dd6a93a70395ef0c8efa86dcb40a888bd516a83 Mon Sep 17 00:00:00 2001 From: Piotr Sarnacki Date: Mon, 18 Dec 2023 20:07:19 +0100 Subject: [PATCH 030/164] Typescript SDK 0.8 changes (#21) * Empty push to trigger a webhook * Update TypeScript docs to 0.8 --- docs/sdks/typescript/index.md | 58 ++++++++++++++++++++++++++++-- docs/sdks/typescript/quickstart.md | 18 +++++----- 2 files changed, 64 insertions(+), 12 deletions(-) diff --git a/docs/sdks/typescript/index.md b/docs/sdks/typescript/index.md index fb7d5be6..fd7c9e91 100644 --- a/docs/sdks/typescript/index.md +++ b/docs/sdks/typescript/index.md @@ -155,6 +155,58 @@ var spacetimeDBClient = new SpacetimeDBClient( ); ``` +## Class methods + +### `SpacetimeDBClient.registerReducers` + +Registers reducer classes for use with a SpacetimeDBClient + +```ts +registerReducers(...reducerClasses: ReducerClass[]) +``` + +#### Parameters + +| Name | Type | Description | +| :----------------- | :------- | :------------------------------------------------------------------------------------------------------------------------------------------ | +| `reducerClasses` | `ReducerClass` | A list of classes to register | + +#### Example + +```ts +import SayHelloReducer from './types/say_hello_reducer'; +import AddReducer from './types/add_reducer'; + +SpacetimeDBClient.registerReducers(SayHelloReducer, AddReducer); +``` + +--- + +### `SpacetimeDBClient.registerTables` + +Registers table classes for use with a SpacetimeDBClient + +```ts +registerTables(...reducerClasses: TableClass[]) +``` + +#### Parameters + +| Name | Type | Description | +| :----------------- | :------- | :------------------------------------------------------------------------------------------------------------------------------------------ | +| `tableClasses` | `TableClass` | A list of classes to register | + +#### Example + +```ts +import User from './types/user'; +import Player from './types/player'; + +SpacetimeDBClient.registerTables(User, Player); +``` + +--- + ## Properties ### `SpacetimeDBClient` identity @@ -867,7 +919,7 @@ SayHelloReducer.call(); Register a callback to run each time the reducer is invoked. ```ts -{Reducer}.on(callback: (reducerEvent: ReducerEvent, reducerArgs: any[]) => void): void +{Reducer}.on(callback: (reducerEvent: ReducerEvent, ...reducerArgs: any[]) => void): void ``` Clients will only be notified of reducer runs if either of two criteria is met: @@ -879,12 +931,12 @@ Clients will only be notified of reducer runs if either of two criteria is met: | Name | Type | | :--------- | :---------------------------------------------------------- | -| `callback` | `(reducerEvent: ReducerEvent, reducerArgs: any[]) => void)` | +| `callback` | `(reducerEvent: ReducerEvent, ...reducerArgs: any[]) => void)` | #### Example ```ts -SayHelloReducer.on((reducerEvent, reducerArgs) => { +SayHelloReducer.on((reducerEvent, ...reducerArgs) => { console.log("SayHelloReducer called", reducerEvent, reducerArgs); }); ``` diff --git a/docs/sdks/typescript/quickstart.md b/docs/sdks/typescript/quickstart.md index 0ec6b0eb..ca8abff9 100644 --- a/docs/sdks/typescript/quickstart.md +++ b/docs/sdks/typescript/quickstart.md @@ -165,9 +165,7 @@ module_bindings └── user.ts ``` -We need to import these types into our `client/src/App.tsx`. While we are at it, we will also import the SpacetimeDBClient class from our SDK. - -> There is a known issue where if you do not use every type in your file, it will not pull them into the published build. To fix this, we are using `console.log` to force them to get pulled in. +We need to import these types into our `client/src/App.tsx`. While we are at it, we will also import the SpacetimeDBClient class from our SDK. In order to let the SDK know what tables and reducers we will be using we need to also register them. ```typescript import { SpacetimeDBClient, Identity, Address } from "@clockworklabs/spacetimedb-sdk"; @@ -176,7 +174,9 @@ import Message from "./module_bindings/message"; import User from "./module_bindings/user"; import SendMessageReducer from "./module_bindings/send_message_reducer"; import SetNameReducer from "./module_bindings/set_name_reducer"; -console.log(Message, User, SendMessageReducer, SetNameReducer); + +SpacetimeDBClient.registerReducers(SendMessageReducer, SetNameReducer); +SpacetimeDBClient.registerTables(Message, User); ``` ## Create your SpacetimeDB client @@ -385,7 +385,7 @@ User.onUpdate((oldUser, user, reducerEvent) => { We can also register callbacks to run each time a reducer is invoked. We register these callbacks using the `OnReducer` method which is automatically implemented for each reducer by `spacetime generate`. -Each reducer callback takes two arguments: +Each reducer callback takes a number of parameters: 1. `ReducerEvent` that contains information about the reducer that triggered this event. It contains several fields. The ones we care about are: @@ -393,7 +393,7 @@ Each reducer callback takes two arguments: - `status`: The `Status` of the reducer run, one of `"Committed"`, `"Failed"` or `"OutOfEnergy"`. - `message`: The error message, if any, that the reducer returned. -2. `ReducerArgs` which is an array containing the arguments with which the reducer was invoked. +2. The rest of the parameters are arguments passed to the reducer. These callbacks will be invoked in one of two cases: @@ -411,7 +411,7 @@ If the reducer status comes back as `committed`, we'll update the name in our ap To the body of `App`, add: ```typescript -SetNameReducer.on((reducerEvent, reducerArgs) => { +SetNameReducer.on((reducerEvent, newName) => { if ( local_identity.current && reducerEvent.callerIdentity.isEqual(local_identity.current) @@ -419,7 +419,7 @@ SetNameReducer.on((reducerEvent, reducerArgs) => { if (reducerEvent.status === "failed") { appendToSystemMessage(`Error setting name: ${reducerEvent.message} `); } else if (reducerEvent.status === "committed") { - setName(reducerArgs[0]); + setName(newName); } } }); @@ -432,7 +432,7 @@ We handle warnings on rejected messages the same way as rejected names, though t To the body of `App`, add: ```typescript -SendMessageReducer.on((reducerEvent, reducerArgs) => { +SendMessageReducer.on((reducerEvent, newMessage) => { if ( local_identity.current && reducerEvent.callerIdentity.isEqual(local_identity.current) From f6d1353a113dfb10c4b625bee939df8dc3994b1a Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Wed, 24 Jan 2024 12:26:38 -0800 Subject: [PATCH 031/164] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index cfe1e0af..af34b88a 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ git push -u origin a-branch-name-that-describes-my-change 6. Go to our GitHub and open a PR that references your branch in your fork on your GitHub +> NOTE! If you make a change to `nav.ts` you will have to run `npm run build` to generate a new `docs/nav.js` file. + ## License This documentation repository is licensed under Apache 2.0. See LICENSE.txt for more details. From 4a50e1c29a59df437c7b3f464d29761ea3e4f63a Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Wed, 28 Feb 2024 14:12:58 -0500 Subject: [PATCH 032/164] WebSocket API ref: remove `row_pk`. (#29) Re https://github.com/clockworklabs/SpacetimeDB/pull/840 . We're removing the `row_pk` from the WebSocket API `TableRowOperation`, as computing it has a major performance impact on the server. This commit removes references to it from the WebSocket API reference. --- docs/ws/index.md | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/docs/ws/index.md b/docs/ws/index.md index 76240163..b00bfa56 100644 --- a/docs/ws/index.md +++ b/docs/ws/index.md @@ -175,7 +175,6 @@ message TableRowOperation { INSERT = 1; } OperationType op = 1; - bytes row_pk = 2; bytes row = 3; } ``` @@ -189,9 +188,8 @@ Each `SubscriptionUpdate` contains a `TableUpdate` for each table with subscribe | `tableRowOperations` | A `TableRowOperation` for each inserted or deleted row. | | `TableRowOperation` field | Value | -| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +|---------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `op` | `INSERT` for inserted rows during a [`TransactionUpdate`](#transactionupdate) or rows resident upon applying a subscription; `DELETE` for deleted rows during a [`TransactionUpdate`](#transactionupdate). | -| `row_pk` | An opaque hash of the row computed by SpacetimeDB. Clients can use this hash to identify a previously `INSERT`ed row during a `DELETE`. | | `row` | The altered row, encoded as a BSATN `ProductValue`. | ##### Text: JSON encoding @@ -214,7 +212,6 @@ Each `SubscriptionUpdate` contains a `TableUpdate` for each table with subscribe // TableRowOperation: { "op": "insert" | "delete", - "row_pk": string, "row": array } ``` @@ -228,9 +225,8 @@ Each `SubscriptionUpdate` contains a `TableUpdate` for each table with subscribe | `table_row_operations` | A `TableRowOperation` for each inserted or deleted row. | | `TableRowOperation` field | Value | -| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +|---------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `op` | `"insert"` for inserted rows during a [`TransactionUpdate`](#transactionupdate) or rows resident upon applying a subscription; `"delete"` for deleted rows during a [`TransactionUpdate`](#transactionupdate). | -| `row_pk` | An opaque hash of the row computed by SpacetimeDB. Clients can use this hash to identify a previously inserted row during a delete. | | `row` | The altered row, encoded as a JSON array. | #### `TransactionUpdate` From ab6b6b486561adb295eb0f305372665a860a5be4 Mon Sep 17 00:00:00 2001 From: Dylan Hunt Date: Fri, 29 Mar 2024 17:16:35 +0800 Subject: [PATCH 033/164] Dylan/onboarding-upgrades (#28) * doc: Onboarding impr, fixes, consistency, cleanup refactor: Whats next cleanup, +unity, -bloat Removed redundant text while there refactor: Unity quickstart fixes, impr, prettify refactor: Unity pt1 fixes, impr, prettify fix(README): Rm "see test edits below" ref * !exists refactor(minor): General onboarding cleanup * Shorter, prettier, consistent fix(sdks/c#): Broken unitypackage url feat(sdks/c#): Add OneTimeQuery api ref * doc: Onboarding impr, fixes, consistency, cleanup * fix: Rm redundant 'module_bindings' mention * fix: Floating period, "arbitrary", "important": - PR review change requests - Additionally: hasUpdatedRecently fix and reformatting * fix: Mentioned FilterBy, used FindBy - Used FindBy since that was what the tutorial used, and also looking for a single Identity. - Note: There may be a similar rust discrepancy in the Unity pt1 tutorial. It'll work with Filter, but just simply less consistent. Holding off on that since my Rust syntax knowledge !exists. * fix(Unity-pt1): Rm copy+paste redundant comments * Duplicate comments found both above and within funcs * fix(unity): Rm unused using statement +merged info * Removed `System.Runtime.CompilerServices` * SpacetimeDB.Module seems to already include this (merged the info) * refactor(minor): Code spacing for grouping/clarity * feat: 'Standalone mode runs in foreground' memo * At general quickstart for `spacetime start` * refactor(unity-pt1): Standalone mode foreground memo * Also, removed the "speed" loss mention of C# * fix(syntaxErr): Fix err, keep FilterBy, handle null - After a verbose discussion, we will eventually swap to FindBy for single-result queries, but not in this PR. - For now, the syntax err is fixed by making the var nullable and suffixing a LINQ FirstOrDefault(). Approved by Tyler in Discord. - We never *actually* created a player in the tutorial. This creates the player. Approved by Tyler in Discord. * fix: Remote player `is null` check removal --- README.md | 2 +- docs/getting-started.md | 24 ++++++----- docs/modules/c-sharp/index.md | 3 ++ docs/modules/c-sharp/quickstart.md | 37 +++++++++++++---- docs/modules/rust/quickstart.md | 2 +- docs/sdks/c-sharp/index.md | 20 ++++++--- docs/sdks/c-sharp/quickstart.md | 66 +++++++++++++++++------------ docs/unity/part-1.md | 67 +++++++++++++++++++----------- 8 files changed, 145 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index af34b88a..0f9998b0 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ To make changes to our docs, you can open a pull request in this repository. You git clone ssh://git@github.com//spacetime-docs ``` -3. Make your edits to the docs that you want to make + test them locally (see Testing Your Edits below) +3. Make your edits to the docs that you want to make + test them locally 4. Commit your changes: ```bash diff --git a/docs/getting-started.md b/docs/getting-started.md index 5a0c6041..177a0d25 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -2,29 +2,33 @@ To develop SpacetimeDB applications locally, you will need to run the Standalone version of the server. -1. [Install](/install) the SpacetimeDB CLI (Command Line Interface). -2. Run the start command +1. [Install](/install) the SpacetimeDB CLI (Command Line Interface) +2. Run the start command: ```bash spacetime start ``` -The server listens on port `3000` by default. You can change this by using the `--listen-addr` option described below. +The server listens on port `3000` by default, customized via `--listen-addr`. -SSL is not supported in standalone mode. +💡 Standalone mode will run in the foreground. +⚠️ SSL is not supported in standalone mode. ## What's Next? -You are ready to start developing SpacetimeDB modules. We have a quickstart guide for each supported server-side language: +You are ready to start developing SpacetimeDB modules. See below for a quickstart guide for both client and server (module) languages/frameworks. + +### Server (Module) - [Rust](/docs/modules/rust/quickstart) - [C#](/docs/modules/c-sharp/quickstart) -Then you can write your client application. We have a quickstart guide for each supported client-side language: +⚡**Note:** Rust is [roughly 2x faster](https://faun.dev/c/links/faun/c-vs-rust-vs-go-a-performance-benchmarking-in-kubernetes/) than C# + +### Client - [Rust](/docs/sdks/rust/quickstart) -- [C#](/docs/sdks/c-sharp/quickstart) +- [C# (Standalone)](/docs/sdks/c-sharp/quickstart) +- [C# (Unity)](/docs/unity/part-1) - [Typescript](/docs/sdks/typescript/quickstart) -- [Python](/docs/sdks/python/quickstart) - -We also have a [step-by-step tutorial](/docs/unity/part-1) for building a multiplayer game in Unity3d. +- [Python](/docs/sdks/python/quickstart) \ No newline at end of file diff --git a/docs/modules/c-sharp/index.md b/docs/modules/c-sharp/index.md index 36a9618a..31ebd1d4 100644 --- a/docs/modules/c-sharp/index.md +++ b/docs/modules/c-sharp/index.md @@ -42,6 +42,7 @@ static partial class Module { // We can skip (or explicitly set to zero) auto-incremented fields when creating new rows. var person = new Person { Name = name, Age = age }; + // `Insert()` method is auto-generated and will insert the given row into the table. person.Insert(); // After insertion, the auto-incremented fields will be populated with their actual values. @@ -211,8 +212,10 @@ public partial struct Person // Finds a row in the table with the given value in the `Id` column and returns it, or `null` if no such row exists. public static Person? FindById(int id); + // Deletes a row in the table with the given value in the `Id` column and returns `true` if the row was found and deleted, or `false` if no such row exists. public static bool DeleteById(int id); + // Updates a row in the table with the given value in the `Id` column and returns `true` if the row was found and updated, or `false` if no such row exists. public static bool UpdateById(int oldId, Person newValue); } diff --git a/docs/modules/c-sharp/quickstart.md b/docs/modules/c-sharp/quickstart.md index fb97c316..f5f73401 100644 --- a/docs/modules/c-sharp/quickstart.md +++ b/docs/modules/c-sharp/quickstart.md @@ -16,7 +16,18 @@ If you haven't already, start by [installing SpacetimeDB](/install). This will i ## Install .NET 8 -Next we need to [install .NET 8 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/8.0) so that we can build and publish our module. .NET 8.0 is the earliest to have the `wasi-experimental` workload that we rely on. +Next we need to [install .NET 8 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/8.0) so that we can build and publish our module. + +You may already have .NET 8 and can be checked: +```bash +dotnet --list-sdks +``` + +.NET 8.0 is the earliest to have the `wasi-experimental` workload that we rely on, but requires manual activation: + +```bash +dotnet workload install wasi-experimental +``` ## Project structure @@ -35,7 +46,11 @@ spacetime init --lang csharp server ## Declare imports -`spacetime init` should have pre-populated `server/Lib.cs` with a trivial module. Clear it out, so we can write a module that's still pretty simple: a bare-bones chat server. +`spacetime init` generated a few files: + +1. Open `server/StdbModule.csproj` to generate a .sln file for intellisense/validation support. +2. Open `server/Lib.cs`, a trivial module. +3. Clear it out, so we can write a new module that's still pretty simple: a bare-bones chat server. To the top of `server/Lib.cs`, add some imports we'll be using: @@ -45,8 +60,10 @@ using SpacetimeDB.Module; using static SpacetimeDB.Runtime; ``` -- `System.Runtime.CompilerServices` allows us to use the `ModuleInitializer` attribute, which we'll use to register our `OnConnect` and `OnDisconnect` callbacks. -- `SpacetimeDB.Module` contains the special attributes we'll use to define our module. +- `System.Runtime.CompilerServices` +- `SpacetimeDB.Module` + - Contains the special attributes we'll use to define our module. + - Allows us to use the `ModuleInitializer` attribute, which we'll use to register our `OnConnect` and `OnDisconnect` callbacks. - `SpacetimeDB.Runtime` contains the raw API bindings SpacetimeDB uses to communicate with the database. We also need to create our static module class which all of the module code will live in. In `server/Lib.cs`, add: @@ -184,7 +201,7 @@ You could extend the validation in `ValidateMessage` in similar ways to `Validat In C# modules, you can register for `Connect` and `Disconnect` events by using a special `ReducerKind`. We'll use the `Connect` event to create a `User` record for the client if it doesn't yet exist, and to set its online status. -We'll use `User.FilterByIdentity` to look up a `User` row for `dbEvent.Sender`, if one exists. If we find one, we'll use `User.UpdateByIdentity` to overwrite it with a row that has `Online: true`. If not, we'll use `User.Insert` to insert a new row for our new user. All three of these methods are generated by the `[SpacetimeDB.Table]` attribute, with rows and behavior based on the row attributes. `FilterByIdentity` returns a nullable `User`, because the unique constraint from the `[SpacetimeDB.Column(ColumnAttrs.PrimaryKey)]` attribute means there will be either zero or one matching rows. `Insert` will throw an exception if the insert violates this constraint; if we want to overwrite a `User` row, we need to do so explicitly using `UpdateByIdentity`. +We'll use `User.FindByIdentity` to look up a `User` row for `dbEvent.Sender`, if one exists. If we find one, we'll use `User.UpdateByIdentity` to overwrite it with a row that has `Online: true`. If not, we'll use `User.Insert` to insert a new row for our new user. All three of these methods are generated by the `[SpacetimeDB.Table]` attribute, with rows and behavior based on the row attributes. `FindByIdentity` returns a nullable `User`, because the unique constraint from the `[SpacetimeDB.Column(ColumnAttrs.PrimaryKey)]` attribute means there will be either zero or one matching rows. `Insert` will throw an exception if the insert violates this constraint; if we want to overwrite a `User` row, we need to do so explicitly using `UpdateByIdentity`. In `server/Lib.cs`, add the definition of the connect reducer to the `Module` class: @@ -235,7 +252,7 @@ public static void OnDisconnect(DbEventArgs dbEventArgs) else { // User does not exist, log warning - Log($"Warning: No user found for disconnected client."); + Log("Warning: No user found for disconnected client."); } } ``` @@ -250,12 +267,16 @@ From the `quickstart-chat` directory, run: spacetime publish --project-path server ``` +```bash +npm i wasm-opt -g +``` + ## Call Reducers You can use the CLI (command line interface) to run reducers. The arguments to the reducer are passed in JSON format. ```bash -spacetime call send_message '["Hello, World!"]' +spacetime call send_message "Hello, World!" ``` Once we've called our `send_message` reducer, we can check to make sure it ran by running the `logs` command. @@ -288,4 +309,4 @@ spacetime sql "SELECT * FROM Message" You've just set up your first database in SpacetimeDB! The next step would be to create a client module that interacts with this module. You can use any of SpacetimDB's supported client languages to do this. Take a look at the quick start guide for your client language of choice: [Rust](/docs/languages/rust/rust-sdk-quickstart-guide), [C#](/docs/languages/csharp/csharp-sdk-quickstart-guide), [TypeScript](/docs/languages/typescript/typescript-sdk-quickstart-guide) or [Python](/docs/languages/python/python-sdk-quickstart-guide). -If you are planning to use SpacetimeDB with the Unity3d game engine, you can skip right to the [Unity Comprehensive Tutorial](/docs/unity/part-1) or check out our example game, [BitcraftMini](/docs/unity/part-3). +If you are planning to use SpacetimeDB with the Unity game engine, you can skip right to the [Unity Comprehensive Tutorial](/docs/unity/part-1) or check out our example game, [BitcraftMini](/docs/unity/part-3). diff --git a/docs/modules/rust/quickstart.md b/docs/modules/rust/quickstart.md index e0ff0f5f..e015b881 100644 --- a/docs/modules/rust/quickstart.md +++ b/docs/modules/rust/quickstart.md @@ -269,4 +269,4 @@ You can find the full code for this module [in the SpacetimeDB module examples]( You've just set up your first database in SpacetimeDB! The next step would be to create a client module that interacts with this module. You can use any of SpacetimDB's supported client languages to do this. Take a look at the quickstart guide for your client language of choice: [Rust](/docs/sdks/rust/quickstart), [C#](/docs/sdks/c-sharp/quickstart), [TypeScript](/docs/sdks/typescript/quickstart) or [Python](/docs/sdks/python/quickstart). -If you are planning to use SpacetimeDB with the Unity3d game engine, you can skip right to the [Unity Comprehensive Tutorial](/docs/unity/part-1) or check out our example game, [BitcraftMini](/docs/unity/part-3). +If you are planning to use SpacetimeDB with the Unity game engine, you can skip right to the [Unity Comprehensive Tutorial](/docs/unity/part-1) or check out our example game, [BitcraftMini](/docs/unity/part-3). diff --git a/docs/sdks/c-sharp/index.md b/docs/sdks/c-sharp/index.md index 473ca1ba..7c920cf5 100644 --- a/docs/sdks/c-sharp/index.md +++ b/docs/sdks/c-sharp/index.md @@ -17,9 +17,10 @@ The SpacetimeDB client C# for Rust contains all the tools you need to build nati - [Method `SpacetimeDBClient.Connect`](#method-spacetimedbclientconnect) - [Event `SpacetimeDBClient.onIdentityReceived`](#event-spacetimedbclientonidentityreceived) - [Event `SpacetimeDBClient.onConnect`](#event-spacetimedbclientonconnect) - - [Subscribe to queries](#subscribe-to-queries) + - [Query subscriptions & one-time actions](#subscribe-to-queries) - [Method `SpacetimeDBClient.Subscribe`](#method-spacetimedbclientsubscribe) - [Event `SpacetimeDBClient.onSubscriptionApplied`](#event-spacetimedbclientonsubscriptionapplied) + - [Method `SpacetimeDBClient.OneOffQuery`](#event-spacetimedbclientoneoffquery) - [View rows of subscribed tables](#view-rows-of-subscribed-tables) - [Class `{TABLE}`](#class-table) - [Static Method `{TABLE}.Iter`](#static-method-tableiter) @@ -64,13 +65,11 @@ dotnet add package spacetimedbsdk ### Using Unity -To install the SpacetimeDB SDK into a Unity project, download the SpacetimeDB SDK from the following link. +To install the SpacetimeDB SDK into a Unity project, [download the SpacetimeDB SDK](https://github.com/clockworklabs/com.clockworklabs.spacetimedbsdk/releases/latest), packaged as a `.unitypackage`. -https://sdk.spacetimedb.com/SpacetimeDBUnitySDK.unitypackage +In Unity navigate to the `Assets > Import Package > Custom Package` menu in the menu bar. Select your `SpacetimeDB.Unity.Comprehensive.Tutorial.unitypackage` file and leave all folders checked. -In Unity navigate to the `Assets > Import Package > Custom Package...` menu in the menu bar. Select your `SpacetimeDBUnitySDK.unitypackage` file and leave all folders checked. - -(See also the [Unity Tutorial](/docs/unity/part-1).) +(See also the [Unity Tutorial](/docs/unity/part-1)) ## Generate module bindings @@ -319,6 +318,15 @@ void Main() } ``` +### Method [`OneTimeQuery`](#method-spacetimedbclientsubscribe) + +You may not want to subscribe to a query, but instead want to run a query once and receive the results immediately via a `Task` result: + +```csharp +// Query all Messages from the sender "bob" +SpacetimeDBClient.instance.OneOffQuery("WHERE sender = \"bob\""); +``` + ## View rows of subscribed tables The SDK maintains a local view of the database called the "client cache". This cache contains whatever rows are selected via a call to [`SpacetimeDBClient.Subscribe`](#method-spacetimedbclientsubscribe). These rows are represented in the SpacetimeDB .Net SDK as instances of [`SpacetimeDB.Types.{TABLE}`](#class-table). diff --git a/docs/sdks/c-sharp/quickstart.md b/docs/sdks/c-sharp/quickstart.md index f7565019..07aa6cf6 100644 --- a/docs/sdks/c-sharp/quickstart.md +++ b/docs/sdks/c-sharp/quickstart.md @@ -1,8 +1,8 @@ # C# Client SDK Quick Start -In this guide we'll show you how to get up and running with a simple SpacetimDB app with a client written in C#. +In this guide we'll show you how to get up and running with a simple SpacetimeDB app with a client written in C#. -We'll implement a command-line client for the module created in our Rust or C# Module Quickstart guides. Make sure you follow one of these guides before you start on this one. +We'll implement a command-line client for the module created in our [Rust](../../modules/rust/quickstart.md) or [C# Module](../../modules/c-sharp/quickstart.md) Quickstart guides. Ensure you followed one of these guides before continuing. ## Project structure @@ -12,7 +12,7 @@ Enter the directory `quickstart-chat` you created in the [Rust Module Quickstart cd quickstart-chat ``` -Within it, create a new C# console application project called `client` using either Visual Studio or the .NET CLI: +Within it, create a new C# console application project called `client` using either Visual Studio, Rider or the .NET CLI: ```bash dotnet new console -o client @@ -22,7 +22,7 @@ Open the project in your IDE of choice. ## Add the NuGet package for the C# SpacetimeDB SDK -Add the `SpacetimeDB.ClientSDK` [NuGet package](https://www.nuget.org/packages/spacetimedbsdk) using Visual Studio NuGet package manager or via the .NET CLI +Add the `SpacetimeDB.ClientSDK` [NuGet package](https://www.nuget.org/packages/spacetimedbsdk) using Visual Studio or Rider _NuGet Package Manager_ or via the .NET CLI: ```bash dotnet add package SpacetimeDB.ClientSDK @@ -65,8 +65,10 @@ We will also need to create some global variables that will be explained when we ```csharp // our local client SpacetimeDB identity Identity? local_identity = null; + // declare a thread safe queue to store commands in format (command, args) ConcurrentQueue<(string,string)> input_queue = new ConcurrentQueue<(string, string)>(); + // declare a threadsafe cancel token to cancel the process loop CancellationTokenSource cancel_token = new CancellationTokenSource(); ``` @@ -75,10 +77,10 @@ CancellationTokenSource cancel_token = new CancellationTokenSource(); We'll work outside-in, first defining our `Main` function at a high level, then implementing each behavior it needs. We need `Main` to do several things: -1. Initialize the AuthToken module, which loads and stores our authentication token to/from local storage. -2. Create the SpacetimeDBClient instance. +1. Initialize the `AuthToken` module, which loads and stores our authentication token to/from local storage. +2. Create the `SpacetimeDBClient` instance. 3. Register callbacks on any events we want to handle. These will print to standard output messages received from the database and updates about users' names and online statuses. -4. Start our processing thread, which connects to the SpacetimeDB module, updates the SpacetimeDB client and processes commands that come in from the input loop running in the main thread. +4. Start our processing thread which connects to the SpacetimeDB module, updates the SpacetimeDB client and processes commands that come in from the input loop running in the main thread. 5. Start the input loop, which reads commands from standard input and sends them to the processing thread. 6. When the input loop exits, stop the processing thread and wait for it to exit. @@ -154,7 +156,7 @@ string UserNameOrIdentity(User user) => user.Name ?? user.Identity.ToString()!.S void User_OnInsert(User insertedValue, ReducerEvent? dbEvent) { - if(insertedValue.Online) + if (insertedValue.Online) { Console.WriteLine($"{UserNameOrIdentity(insertedValue)} is online"); } @@ -178,20 +180,21 @@ We'll print an appropriate message in each of these cases. ```csharp void User_OnUpdate(User oldValue, User newValue, ReducerEvent dbEvent) { - if(oldValue.Name != newValue.Name) + if (oldValue.Name != newValue.Name) { Console.WriteLine($"{UserNameOrIdentity(oldValue)} renamed to {newValue.Name}"); } - if(oldValue.Online != newValue.Online) + + if (oldValue.Online == newValue.Online) + return; + + if (newValue.Online) { - if(newValue.Online) - { - Console.WriteLine($"{UserNameOrIdentity(newValue)} connected."); - } - else - { - Console.WriteLine($"{UserNameOrIdentity(newValue)} disconnected."); - } + Console.WriteLine($"{UserNameOrIdentity(newValue)} connected."); + } + else + { + Console.WriteLine($"{UserNameOrIdentity(newValue)} disconnected."); } } ``` @@ -209,7 +212,7 @@ void PrintMessage(Message message) { var sender = User.FilterByIdentity(message.Sender); var senderName = "unknown"; - if(sender != null) + if (sender != null) { senderName = UserNameOrIdentity(sender); } @@ -219,7 +222,7 @@ void PrintMessage(Message message) void Message_OnInsert(Message insertedValue, ReducerEvent? dbEvent) { - if(dbEvent != null) + if (dbEvent != null) { PrintMessage(insertedValue); } @@ -254,7 +257,11 @@ We'll test both that our identity matches the sender and that the status is `Fai ```csharp void Reducer_OnSetNameEvent(ReducerEvent reducerEvent, string name) { - if(reducerEvent.Identity == local_identity && reducerEvent.Status == ClientApi.Event.Types.Status.Failed) + bool localIdentityFailedToChangeName = + reducerEvent.Identity == local_identity && + reducerEvent.Status == ClientApi.Event.Types.Status.Failed; + + if (localIdentityFailedToChangeName) { Console.Write($"Failed to change name to {name}"); } @@ -268,7 +275,11 @@ We handle warnings on rejected messages the same way as rejected names, though t ```csharp void Reducer_OnSendMessageEvent(ReducerEvent reducerEvent, string text) { - if (reducerEvent.Identity == local_identity && reducerEvent.Status == ClientApi.Event.Types.Status.Failed) + bool localIdentityFailedToSendMessage = + reducerEvent.Identity == local_identity && + reducerEvent.Status == ClientApi.Event.Types.Status.Failed; + + if (localIdentityFailedToSendMessage) { Console.Write($"Failed to send message {text}"); } @@ -282,7 +293,10 @@ Once we are connected, we can send our subscription to the SpacetimeDB module. S ```csharp void OnConnect() { - SpacetimeDBClient.instance.Subscribe(new List { "SELECT * FROM User", "SELECT * FROM Message" }); + SpacetimeDBClient.instance.Subscribe(new List + { + "SELECT * FROM User", "SELECT * FROM Message" + }); } ``` @@ -370,12 +384,12 @@ void InputLoop() while (true) { var input = Console.ReadLine(); - if(input == null) + if (input == null) { break; } - if(input.StartsWith("/name ")) + if (input.StartsWith("/name ")) { input_queue.Enqueue(("name", input.Substring(6))); continue; @@ -421,4 +435,4 @@ dotnet run --project client ## What's next? -Congratulations! You've built a simple chat app using SpacetimeDB. You can look at the C# SDK Reference for more information about the client SDK. If you are interested in developing in the Unity3d game engine, check out our Unity3d Comprehensive Tutorial and BitcraftMini game example. +Congratulations! You've built a simple chat app using SpacetimeDB. You can look at the C# SDK Reference for more information about the client SDK. If you are interested in developing in the Unity game engine, check out our Unity3d Comprehensive Tutorial and BitcraftMini game example. diff --git a/docs/unity/part-1.md b/docs/unity/part-1.md index 30bd3137..0e899750 100644 --- a/docs/unity/part-1.md +++ b/docs/unity/part-1.md @@ -12,14 +12,19 @@ This tutorial has been tested against UnityEngine version 2022.3.4f1. This tutor ## Prepare Project Structure -This project is separated into two sub-projects, one for the server (module) code and one for the client code. First we'll create the main directory, this directory name doesn't matter but we'll give you an example: +This project is separated into two sub-projects; + +1. Server (module) code +2. Client code + +First, we'll create a project root directory (you can choose the name): ```bash mkdir SpacetimeDBUnityTutorial cd SpacetimeDBUnityTutorial ``` -In the following sections we'll be adding a client directory and a server directory, which will contain the client files and the module (server) files respectively. We'll start by populating the client directory. +We'll start by populating the client directory. ## Setting up the Tutorial Unity Project @@ -31,9 +36,9 @@ Open Unity and create a new project by selecting "New" from the Unity Hub or goi ![UnityHub-NewProject](/images/unity-tutorial/UnityHub-NewProject.JPG) -For Project Name use `client`. For Project Location make sure that you use your `SpacetimeDBUnityTutorial` directory. This is the directory that we created in a previous step. +**⚠️ Important: Ensure `3D (URP)` is selected** to properly render the materials in the scene! -**Important: Ensure that you have selected the 3D (URP) template for this project.** If you forget to do this then Unity won't be able to properly render the materials in the scene! +For Project Name use `client`. For Project Location make sure that you use your `SpacetimeDBUnityTutorial` directory. This is the directory that we created in a previous step. ![UnityHub-3DURP](/images/unity-tutorial/UnityHub-3DURP.JPG) @@ -77,7 +82,9 @@ Now that we have everything set up, let's run the project and see it in action: ![Unity-OpenSceneMain](/images/unity-tutorial/Unity-OpenSceneMain.JPG) -NOTE: When you open the scene you may get a message saying you need to import TMP Essentials. When it appears, click the "Import TMP Essentials" button. +**NOTE:** When you open the scene you may get a message saying you need to import TMP Essentials. When it appears, click the "Import TMP Essentials" button. + +🧹 Clear any false-positive TMPro errors that may show. ![Unity Import TMP Essentials](/images/unity-tutorial/Unity-ImportTMPEssentials.JPG) @@ -105,6 +112,9 @@ At this point you should have the single player game working. In your CLI, your spacetime start ``` +💡 Standalone mode will run in the foreground. +💡 Below examples Rust language, [but you may also use C#](../modules/c-sharp/index.md). + 3. Run the following command to initialize the SpacetimeDB server project with Rust as the language: ```bash @@ -284,7 +294,6 @@ We use the `connect` and `disconnect` reducers to update the logged in state of // Called when the client connects, we update the logged_in state to true #[spacetimedb(connect)] pub fn client_connected(ctx: ReducerContext) { - // called when the client connects, we update the logged_in state to true update_player_login_state(ctx, true); } @@ -292,7 +301,6 @@ pub fn client_connected(ctx: ReducerContext) { // Called when the client disconnects, we update the logged_in state to false #[spacetimedb(disconnect)] pub fn client_disconnected(ctx: ReducerContext) { - // Called when the client disconnects, we update the logged_in state to false update_player_login_state(ctx, false); } @@ -553,8 +561,8 @@ public class RemotePlayer : MonoBehaviour canvas.worldCamera = Camera.main; // Get the username from the PlayerComponent for this object and set it in the UI - PlayerComponent playerComp = PlayerComponent.FilterByEntityId(EntityId); - Username = playerComp.Username; + // FilterByEntityId is normally nullable, but we'll assume not null for simplicity + PlayerComponent playerComp = PlayerComponent.FilterByEntityId(EntityId).First(); // Get the last location for this player and set the initial position EntityComponent entity = EntityComponent.FilterByEntityId(EntityId); @@ -612,13 +620,16 @@ private void PlayerComponent_OnInsert(PlayerComponent obj, ReducerEvent callInfo { // Spawn the player object and attach the RemotePlayer component var remotePlayer = Instantiate(PlayerPrefab); + // Lookup and apply the position for this new player var entity = EntityComponent.FilterByEntityId(obj.EntityId); var position = new Vector3(entity.Position.X, entity.Position.Y, entity.Position.Z); remotePlayer.transform.position = position; + var movementController = remotePlayer.GetComponent(); movementController.RemoteTargetPosition = position; movementController.RemoteTargetRotation = entity.Direction; + remotePlayer.AddComponent().EntityId = obj.EntityId; } } @@ -639,21 +650,26 @@ using SpacetimeDB; private float? lastUpdateTime; private void FixedUpdate() { - if ((lastUpdateTime.HasValue && Time.time - lastUpdateTime.Value > 1.0f / movementUpdateSpeed) || !SpacetimeDBClient.instance.IsConnected()) - { - return; - } - - lastUpdateTime = Time.time; - var p = PlayerMovementController.Local.GetModelPosition(); - Reducer.UpdatePlayerPosition(new StdbVector3 - { - X = p.x, - Y = p.y, - Z = p.z, - }, - PlayerMovementController.Local.GetModelRotation(), - PlayerMovementController.Local.IsMoving()); + float? deltaTime = Time.time - lastUpdateTime; + bool hasUpdatedRecently = deltaTime.HasValue && deltaTime.Value < 1.0f / movementUpdateSpeed; + bool isConnected = SpacetimeDBClient.instance.IsConnected(); + + if (hasUpdatedRecently || !isConnected) + { + return; + } + + lastUpdateTime = Time.time; + var p = PlayerMovementController.Local.GetModelPosition(); + + Reducer.UpdatePlayerPosition(new StdbVector3 + { + X = p.x, + Y = p.y, + Z = p.z, + }, + PlayerMovementController.Local.GetModelRotation(), + PlayerMovementController.Local.IsMoving()); } ``` @@ -713,13 +729,16 @@ private void OnPlayerComponentChanged(PlayerComponent obj) { // Spawn the player object and attach the RemotePlayer component var remotePlayer = Instantiate(PlayerPrefab); + // Lookup and apply the position for this new player var entity = EntityComponent.FilterByEntityId(obj.EntityId); var position = new Vector3(entity.Position.X, entity.Position.Y, entity.Position.Z); remotePlayer.transform.position = position; + var movementController = remotePlayer.GetComponent(); movementController.RemoteTargetPosition = position; movementController.RemoteTargetRotation = entity.Direction; + remotePlayer.AddComponent().EntityId = obj.EntityId; } } From 6ed040b8345e8ffa6ed97669a34b9b0b09b2a621 Mon Sep 17 00:00:00 2001 From: Dylan Hunt Date: Fri, 26 Apr 2024 03:51:37 +0800 Subject: [PATCH 034/164] Unity tutorial - C# parity (#31) * doc: Onboarding impr, fixes, consistency, cleanup refactor: Whats next cleanup, +unity, -bloat Removed redundant text while there refactor: Unity quickstart fixes, impr, prettify refactor: Unity pt1 fixes, impr, prettify fix(README): Rm "see test edits below" ref * !exists refactor(minor): General onboarding cleanup * Shorter, prettier, consistent fix(sdks/c#): Broken unitypackage url feat(sdks/c#): Add OneTimeQuery api ref * doc: Onboarding impr, fixes, consistency, cleanup * fix: Rm redundant 'module_bindings' mention * fix: Floating period, "arbitrary", "important": - PR review change requests - Additionally: hasUpdatedRecently fix and reformatting * fix: Mentioned FilterBy, used FindBy - Used FindBy since that was what the tutorial used, and also looking for a single Identity. - Note: There may be a similar rust discrepancy in the Unity pt1 tutorial. It'll work with Filter, but just simply less consistent. Holding off on that since my Rust syntax knowledge !exists. * fix(Unity-pt1): Rm copy+paste redundant comments * Duplicate comments found both above and within funcs * fix(unity): Rm unused using statement +merged info * Removed `System.Runtime.CompilerServices` * SpacetimeDB.Module seems to already include this (merged the info) * refactor(minor): Code spacing for grouping/clarity * feat: 'Standalone mode runs in foreground' memo * At general quickstart for `spacetime start` * refactor(unity-pt1): Standalone mode foreground memo * Also, removed the "speed" loss mention of C# * fix(syntaxErr): Fix err, keep FilterBy, handle null - After a verbose discussion, we will eventually swap to FindBy for single-result queries, but not in this PR. - For now, the syntax err is fixed by making the var nullable and suffixing a LINQ FirstOrDefault(). Approved by Tyler in Discord. - We never *actually* created a player in the tutorial. This creates the player. Approved by Tyler in Discord. * doc!(unity-tutorial): Add C# module parity + split - Why? - Despite being a Unity tutorial (we 100% know the user knows C#), the server example used Rust. - This creates friction when the user is already learning multiple new things: The SpacetimeDB architecture, the CLI, the client SDK and server SDK. If they previously did not know Rust, this could add some weight to the onboarding friction. - The Unity tutorial could use an overview since it's quite lengthy and progressive. - Part1 should be split, anyway - it covers way too much for a single section to handle (especially since it jumps between client and server). Splitting between basic multiplayer + advanced makes things more-manageable and less intimidating. - Before: - UNITY TUTORIAL - Part1 (Client + Rust Server) - Part2 (Resources and Scheduling) - Part3 (BitCraft Mini) - After: - UNITY TUTORIAL - BASIC MULTIPLAYER - Overview - Part1 (Setup) - Part2a (Rust Server) - Part2b (C# Server) - Part3 (Client) - UNITY TUTORIAL - ADVANCED - Part4 (Resources and Scheduling) - Part5 (BitCraft Mini) * Update docs/unity/part-2b-c-sharp.md Rust -> C# Co-authored-by: Zeke Foppa <196249+bfops@users.noreply.github.com> * Update docs/unity/part-2b-c-sharp.md - `--lang=rust` to `=csharp` Co-authored-by: Zeke Foppa <196249+bfops@users.noreply.github.com> * Update docs/unity/part-2b-c-sharp.md - Rm RustRover mention Co-authored-by: Zeke Foppa <196249+bfops@users.noreply.github.com> * Update docs/unity/part-2b-c-sharp.md - Rust -> C# Co-authored-by: Zeke Foppa <196249+bfops@users.noreply.github.com> * fix: "Next tutorial" mixups * fix: Bad troubleshooting links - Server issues shouldn't link to Client troubleshooting that has no answer * Update docs/unity/part-2b-c-sharp.md Co-authored-by: Zeke Foppa <196249+bfops@users.noreply.github.com> * Update docs/unity/part-2a-rust.md Co-authored-by: Zeke Foppa <196249+bfops@users.noreply.github.com> --------- Co-authored-by: Zeke Foppa <196249+bfops@users.noreply.github.com> --- docs/sdks/c-sharp/quickstart.md | 2 +- docs/unity/index.md | 23 + docs/unity/part-1.md | 769 +--------------------------- docs/unity/part-2a-rust.md | 312 +++++++++++ docs/unity/part-2b-c-sharp.md | 344 +++++++++++++ docs/unity/part-3.md | 487 ++++++++++++++++-- docs/unity/{part-2.md => part-4.md} | 6 +- docs/unity/part-5.md | 108 ++++ nav.ts | 16 +- 9 files changed, 1242 insertions(+), 825 deletions(-) create mode 100644 docs/unity/index.md create mode 100644 docs/unity/part-2a-rust.md create mode 100644 docs/unity/part-2b-c-sharp.md rename docs/unity/{part-2.md => part-4.md} (97%) create mode 100644 docs/unity/part-5.md diff --git a/docs/sdks/c-sharp/quickstart.md b/docs/sdks/c-sharp/quickstart.md index 07aa6cf6..92980f42 100644 --- a/docs/sdks/c-sharp/quickstart.md +++ b/docs/sdks/c-sharp/quickstart.md @@ -427,7 +427,7 @@ Finally we just need to add a call to `Main` in `Program.cs`: Main(); ``` -Now we can run the client, by hitting start in Visual Studio or running the following command in the `client` directory: +Now, we can run the client by hitting start in Visual Studio or Rider; or by running the following command in the `client` directory: ```bash dotnet run --project client diff --git a/docs/unity/index.md b/docs/unity/index.md new file mode 100644 index 00000000..2b8e6d67 --- /dev/null +++ b/docs/unity/index.md @@ -0,0 +1,23 @@ +# Unity Tutorial Overview + +Need help with the tutorial or CLI commands? [Join our Discord server](https://discord.gg/spacetimedb)! + +The objective of this progressive tutorial is to help you become acquainted with the basic features of SpacetimeDB. By the end, you should have a basic understanding of what SpacetimeDB offers for developers making multiplayer games. It assumes that you have a basic understanding of the Unity Editor, using a command line terminal and coding. + +We'll give you some CLI commands to execute. If you are using Windows, we recommend using Git Bash or PowerShell. For Mac, we recommend Terminal. + +Tested with UnityEngine `2022.3.20f1 LTS` (and may also work on newer versions). + +## Unity Tutorial - Basic Multiplayer +Get started with the core client-server setup. For part 2, you may choose your server module preference of [Rust](/docs/modules/rust) or [C#](/docs/modules/c-sharp): + +- [Part 1 - Setup](/docs/unity/part-1.md) +- [Part 2a - Server (Rust)](/docs/unity/part-2a-rust.md) +- [Part 2b - Server (C#)](/docs/unity/part-2b-csharp.md) +- [Part 3 - Client](/docs/unity/part-3.md) + +## Unity Tutorial - Advanced +By this point, you should already have a basic understanding of SpacetimeDB client, server and CLI: + +- [Part 4 - Resources & Scheduling](/docs/unity/part-4.md) +- [Part 5 - BitCraft Mini](/docs/unity/part-5.md) diff --git a/docs/unity/part-1.md b/docs/unity/part-1.md index 0e899750..b8b8c3c0 100644 --- a/docs/unity/part-1.md +++ b/docs/unity/part-1.md @@ -1,15 +1,9 @@ -# Part 1 - Basic Multiplayer +# Unity Tutorial - Basic Multiplayer - Part 1 - Setup ![UnityTutorial-HeroImage](/images/unity-tutorial/UnityTutorial-HeroImage.JPG) Need help with the tutorial? [Join our Discord server](https://discord.gg/spacetimedb)! -The objective of this tutorial is to help you become acquainted with the basic features of SpacetimeDB. By the end of this tutorial you should have a basic understanding of what SpacetimeDB offers for developers making multiplayer games. It assumes that you have a basic understanding of the Unity Editor, using a command line terminal, and coding. - -In this tutorial we'll be giving you some CLI commands to execute. If you are using Windows we recommend using Git Bash or powershell. If you're on mac we recommend you use the Terminal application. If you encouter issues with any of the commands in this guide, please reach out to us through our discord server and we would be happy to help assist you. - -This tutorial has been tested against UnityEngine version 2022.3.4f1. This tutorial may work on newer versions as well. - ## Prepare Project Structure This project is separated into two sub-projects; @@ -115,763 +109,14 @@ spacetime start 💡 Standalone mode will run in the foreground. 💡 Below examples Rust language, [but you may also use C#](../modules/c-sharp/index.md). -3. Run the following command to initialize the SpacetimeDB server project with Rust as the language: - -```bash -spacetime init --lang=rust server -``` - -This command creates a new folder named "server" within your Unity project directory and sets up the SpacetimeDB server project with Rust as the programming language. - -### Understanding Entity Component Systems +### The Entity Component Systems (ECS) -Entity Component System (ECS) is a game development architecture that separates game objects into components for better flexibility and performance. You can read more about the ECS design pattern [here](https://en.wikipedia.org/wiki/Entity_component_system). +Before we continue to creating the server module, it's important to understand the basics of the ECS. This is a game development architecture that separates game objects into components for better flexibility and performance. You can read more about the ECS design pattern [here](https://en.wikipedia.org/wiki/Entity_component_system). We chose ECS for this example project because it promotes scalability, modularity, and efficient data management, making it ideal for building multiplayer games with SpacetimeDB. -### SpacetimeDB Tables - -In this section we'll be making some edits to the file `server/src/lib.rs`. We recommend you open up this file in an IDE like VSCode or RustRover. - -**Important: Open the `server/src/lib.rs` file and delete its contents. We will be writing it from scratch here.** - -First we need to add some imports at the top of the file. - -**Copy and paste into lib.rs:** - -```rust -use spacetimedb::{spacetimedb, Identity, SpacetimeType, ReducerContext}; -use log; -``` - -Then we are going to start by adding the global `Config` table. Right now it only contains the "message of the day" but it can be extended to store other configuration variables. This also uses a couple of macros, like `#[spacetimedb(table)]` which you can learn more about in our rust module reference. Simply put, this just tells SpacetimeDB to create a table which uses this struct as the schema for the table. - -**Append to the bottom of lib.rs:** - -```rust -// We're using this table as a singleton, so there should typically only be one element where the version is 0. -#[spacetimedb(table)] -#[derive(Clone)] -pub struct Config { - #[primarykey] - pub version: u32, - pub message_of_the_day: String, -} -``` - -Next we're going to define a new `SpacetimeType` called `StdbVector3` which we're going to use to store positions. The difference between a `#[derive(SpacetimeType)]` and a `#[spacetimedb(table)]` is that tables actually store data, whereas the deriving `SpacetimeType` just allows you to create a new column of that type in a SpacetimeDB table. So therefore, `StdbVector3` is not itself a table. - -**Append to the bottom of lib.rs:** - -```rust -// This allows us to store 3D points in tables. -#[derive(SpacetimeType, Clone)] -pub struct StdbVector3 { - pub x: f32, - pub y: f32, - pub z: f32, -} -``` - -Now we're going to create a table which actually uses the `StdbVector3` that we just defined. The `EntityComponent` is associated with all entities in the world, including players. - -```rust -// This stores information related to all entities in our game. In this tutorial -// all entities must at least have an entity_id, a position, a direction and they -// must specify whether or not they are moving. -#[spacetimedb(table)] -#[derive(Clone)] -pub struct EntityComponent { - #[primarykey] - // The autoinc macro here just means every time we insert into this table - // we will receive a new row where this value will be increased by one. This - // allows us to easily get rows where `entity_id` is unique. - #[autoinc] - pub entity_id: u64, - pub position: StdbVector3, - pub direction: f32, - pub moving: bool, -} -``` - -Next we will define the `PlayerComponent` table. The `PlayerComponent` table is used to store information related to players. Each player will have a row in this table, and will also have a row in the `EntityComponent` table with a matching `entity_id`. You'll see how this works later in the `create_player` reducer. - -**Append to the bottom of lib.rs:** - -```rust -// All players have this component and it associates an entity with the user's -// Identity. It also stores their username and whether or not they're logged in. -#[derive(Clone)] -#[spacetimedb(table)] -pub struct PlayerComponent { - // An entity_id that matches an entity_id in the `EntityComponent` table. - #[primarykey] - pub entity_id: u64, - // The user's identity, which is unique to each player - #[unique] - pub owner_id: Identity, - pub username: String, - pub logged_in: bool, -} -``` - -Next we write our very first reducer, `create_player`. From the client we will call this reducer when we create a new player: - -**Append to the bottom of lib.rs:** - -```rust -// This reducer is called when the user logs in for the first time and -// enters a username -#[spacetimedb(reducer)] -pub fn create_player(ctx: ReducerContext, username: String) -> Result<(), String> { - // Get the Identity of the client who called this reducer - let owner_id = ctx.sender; - - // Make sure we don't already have a player with this identity - if PlayerComponent::filter_by_owner_id(&owner_id).is_some() { - log::info!("Player already exists"); - return Err("Player already exists".to_string()); - } - - // Create a new entity for this player and get a unique `entity_id`. - let entity_id = EntityComponent::insert(EntityComponent - { - entity_id: 0, - position: StdbVector3 { x: 0.0, y: 0.0, z: 0.0 }, - direction: 0.0, - moving: false, - }).expect("Failed to create a unique PlayerComponent.").entity_id; - - // The PlayerComponent uses the same entity_id and stores the identity of - // the owner, username, and whether or not they are logged in. - PlayerComponent::insert(PlayerComponent { - entity_id, - owner_id, - username: username.clone(), - logged_in: true, - }).expect("Failed to insert player component."); - - log::info!("Player created: {}({})", username, entity_id); - - Ok(()) -} -``` - ---- - -**SpacetimeDB Reducers** - -"Reducer" is a term coined by Clockwork Labs that refers to a function which when executed "reduces" into a list of inserts and deletes, which is then packed into a single database transaction. Reducers can be called remotely using the CLI or a client SDK or they can be scheduled to be called at some future time from another reducer call. - ---- - -SpacetimeDB gives you the ability to define custom reducers that automatically trigger when certain events occur. - -- `init` - Called the first time you publish your module and anytime you clear the database. We'll learn about publishing later. -- `connect` - Called when a user connects to the SpacetimeDB module. Their identity can be found in the `sender` value of the `ReducerContext`. -- `disconnect` - Called when a user disconnects from the SpacetimeDB module. - -Next we are going to write a custom `init` reducer that inserts the default message of the day into our `Config` table. The `Config` table only ever contains a single row with version 0, which we retrieve using `Config::filter_by_version(0)`. - -**Append to the bottom of lib.rs:** - -```rust -// Called when the module is initially published -#[spacetimedb(init)] -pub fn init() { - Config::insert(Config { - version: 0, - message_of_the_day: "Hello, World!".to_string(), - }).expect("Failed to insert config."); -} -``` - -We use the `connect` and `disconnect` reducers to update the logged in state of the player. The `update_player_login_state` helper function looks up the `PlayerComponent` row using the user's identity and if it exists, it updates the `logged_in` variable and calls the auto-generated `update` function on `PlayerComponent` to update the row. - -**Append to the bottom of lib.rs:** - -```rust -// Called when the client connects, we update the logged_in state to true -#[spacetimedb(connect)] -pub fn client_connected(ctx: ReducerContext) { - update_player_login_state(ctx, true); -} - - -// Called when the client disconnects, we update the logged_in state to false -#[spacetimedb(disconnect)] -pub fn client_disconnected(ctx: ReducerContext) { - update_player_login_state(ctx, false); -} - -// This helper function gets the PlayerComponent, sets the logged -// in variable and updates the PlayerComponent table row. -pub fn update_player_login_state(ctx: ReducerContext, logged_in: bool) { - if let Some(player) = PlayerComponent::filter_by_owner_id(&ctx.sender) { - // We clone the PlayerComponent so we can edit it and pass it back. - let mut player = player.clone(); - player.logged_in = logged_in; - PlayerComponent::update_by_entity_id(&player.entity_id.clone(), player); - } -} -``` - -Our final reducer handles player movement. In `update_player_position` we look up the `PlayerComponent` using the user's Identity. If we don't find one, we return an error because the client should not be sending moves without calling `create_player` first. - -Using the `entity_id` in the `PlayerComponent` we retrieved, we can lookup the `EntityComponent` that stores the entity's locations in the world. We update the values passed in from the client and call the auto-generated `update` function. - -**Append to the bottom of lib.rs:** - -```rust -// Updates the position of a player. This is also called when the player stops moving. -#[spacetimedb(reducer)] -pub fn update_player_position( - ctx: ReducerContext, - position: StdbVector3, - direction: f32, - moving: bool, -) -> Result<(), String> { - // First, look up the player using the sender identity, then use that - // entity_id to retrieve and update the EntityComponent - if let Some(player) = PlayerComponent::filter_by_owner_id(&ctx.sender) { - if let Some(mut entity) = EntityComponent::filter_by_entity_id(&player.entity_id) { - entity.position = position; - entity.direction = direction; - entity.moving = moving; - EntityComponent::update_by_entity_id(&player.entity_id, entity); - return Ok(()); - } - } - - // If we can not find the PlayerComponent or EntityComponent for - // this player then something went wrong. - return Err("Player not found".to_string()); -} -``` - ---- - -**Server Validation** - -In a fully developed game, the server would typically perform server-side validation on player movements to ensure they comply with game boundaries, rules, and mechanics. This validation, which we omit for simplicity in this tutorial, is essential for maintaining game integrity, preventing cheating, and ensuring a fair gaming experience. Remember to incorporate appropriate server-side validation in your game's development to ensure a secure and fair gameplay environment. - ---- - -### Publishing a Module to SpacetimeDB - -Now that we've written the code for our server module, we need to publish it to SpacetimeDB. This will create the database and call the init reducer. In your terminal or command window, run the following commands. - -```bash -cd server -spacetime publish -c unity-tutorial -``` - -If you get any errors from this command, double check that you correctly entered everything into `lib.rs`. You can also look at the Troubleshooting section at the end of this tutorial. - -## Updating our Unity Project to use SpacetimeDB - -Now we are ready to connect our bitcraft mini project to SpacetimeDB. - -### Import the SDK and Generate Module Files - -1. Add the SpacetimeDB Unity Package using the Package Manager. Open the Package Manager window by clicking on Window -> Package Manager. Click on the + button in the top left corner of the window and select "Add package from git URL". Enter the following URL and click Add. - -```bash -https://github.com/clockworklabs/com.clockworklabs.spacetimedbsdk.git -``` - -![Unity-PackageManager](/images/unity-tutorial/Unity-PackageManager.JPG) - -3. The next step is to generate the module specific client files using the SpacetimeDB CLI. The files created by this command provide an interface for retrieving values from the local client cache of the database and for registering for callbacks to events. In your terminal or command window, run the following commands. - -```bash -mkdir -p ../client/Assets/module_bindings -spacetime generate --out-dir ../client/Assets/module_bindings --lang=csharp -``` - -### Connect to Your SpacetimeDB Module - -The Unity SpacetimeDB SDK relies on there being a `NetworkManager` somewhere in the scene. Click on the GameManager object in the scene, and in the inspector, add the `NetworkManager` component. - -![Unity-AddNetworkManager](/images/unity-tutorial/Unity-AddNetworkManager.JPG) - -Next we are going to connect to our SpacetimeDB module. Open `TutorialGameManager.cs` in your editor of choice and add the following code at the top of the file: - -**Append to the top of TutorialGameManager.cs** - -```csharp -using SpacetimeDB; -using SpacetimeDB.Types; -using System.Linq; -``` - -At the top of the class definition add the following members: - -**Append to the top of TutorialGameManager class inside of TutorialGameManager.cs** - -```csharp -// These are connection variables that are exposed on the GameManager -// inspector. -[SerializeField] private string moduleAddress = "unity-tutorial"; -[SerializeField] private string hostName = "localhost:3000"; - -// This is the identity for this player that is automatically generated -// the first time you log in. We set this variable when the -// onIdentityReceived callback is triggered by the SDK after connecting -private Identity local_identity; -``` - -The first three fields will appear in your Inspector so you can update your connection details without editing the code. The `moduleAddress` should be set to the domain you used in the publish command. You should not need to change `hostName` if you are using SpacetimeDB locally. - -Now add the following code to the `Start()` function. For clarity, replace your entire `Start()` function with the function below. - -**REPLACE the Start() function in TutorialGameManager.cs** - -```csharp -// Start is called before the first frame update -void Start() -{ - instance = this; - - SpacetimeDBClient.instance.onConnect += () => - { - Debug.Log("Connected."); - - // Request all tables - SpacetimeDBClient.instance.Subscribe(new List() - { - "SELECT * FROM *", - }); - }; - - // Called when we have an error connecting to SpacetimeDB - SpacetimeDBClient.instance.onConnectError += (error, message) => - { - Debug.LogError($"Connection error: " + message); - }; - - // Called when we are disconnected from SpacetimeDB - SpacetimeDBClient.instance.onDisconnect += (closeStatus, error) => - { - Debug.Log("Disconnected."); - }; - - // Called when we receive the client identity from SpacetimeDB - SpacetimeDBClient.instance.onIdentityReceived += (token, identity, address) => { - AuthToken.SaveToken(token); - local_identity = identity; - }; - - // Called after our local cache is populated from a Subscribe call - SpacetimeDBClient.instance.onSubscriptionApplied += OnSubscriptionApplied; - - // Now that we’ve registered all our callbacks, lets connect to spacetimedb - SpacetimeDBClient.instance.Connect(AuthToken.Token, hostName, moduleAddress); -} -``` - -In our `onConnect` callback we are calling `Subscribe` and subscribing to all data in the database. You can also subscribe to specific tables using SQL syntax like `SELECT * FROM MyTable`. Our SQL documentation enumerates the operations that are accepted in our SQL syntax. - -Subscribing to tables tells SpacetimeDB what rows we want in our local client cache. We will also not get row update callbacks or event callbacks for any reducer that does not modify a row that matches at least one of our queries. This means that events can happen on the server and the client won't be notified unless they are subscribed to at least 1 row in the change. - ---- - -**Local Client Cache** - -The "local client cache" is a client-side view of the database defined by the supplied queries to the `Subscribe` function. It contains the requested data which allows efficient access without unnecessary server queries. Accessing data from the client cache is done using the auto-generated iter and filter_by functions for each table, and it ensures that update and event callbacks are limited to the subscribed rows. - ---- - -Next we write the `OnSubscriptionApplied` callback. When this event occurs for the first time, it signifies that our local client cache is fully populated. At this point, we can verify if a player entity already exists for the corresponding user. If we do not have a player entity, we need to show the `UserNameChooser` dialog so the user can enter a username. We also put the message of the day into the chat window. Finally we unsubscribe from the callback since we only need to do this once. - -**Append after the Start() function in TutorialGameManager.cs** - -```csharp -void OnSubscriptionApplied() -{ - // If we don't have any data for our player, then we are creating a - // new one. Let's show the username dialog, which will then call the - // create player reducer - var player = PlayerComponent.FilterByOwnerId(local_identity); - if (player == null) - { - // Show username selection - UIUsernameChooser.instance.Show(); - } - - // Show the Message of the Day in our Config table of the Client Cache - UIChatController.instance.OnChatMessageReceived("Message of the Day: " + Config.FilterByVersion(0).MessageOfTheDay); - - // Now that we've done this work we can unregister this callback - SpacetimeDBClient.instance.onSubscriptionApplied -= OnSubscriptionApplied; -} -``` - -### Adding the Multiplayer Functionality - -Now we have to change what happens when you press the "Continue" button in the name dialog window. Instead of calling start game like we did in the single player version, we call the `create_player` reducer on the SpacetimeDB module using the auto-generated code. Open `UIUsernameChooser.cs`. - -**Append to the top of UIUsernameChooser.cs** - -```csharp -using SpacetimeDB.Types; -``` - -Then we're doing a modification to the `ButtonPressed()` function: - -**Modify the ButtonPressed function in UIUsernameChooser.cs** - -```csharp -public void ButtonPressed() -{ - CameraController.RemoveDisabler(GetHashCode()); - _panel.SetActive(false); - - // Call the SpacetimeDB CreatePlayer reducer - Reducer.CreatePlayer(_usernameField.text); -} -``` - -We need to create a `RemotePlayer` script that we attach to remote player objects. In the same folder as `LocalPlayer.cs`, create a new C# script called `RemotePlayer`. In the start function, we will register an OnUpdate callback for the `EntityComponent` and query the local cache to get the player’s initial position. **Make sure you include a `using SpacetimeDB.Types;`** at the top of the file. - -First append this using to the top of `RemotePlayer.cs` - -**Create file RemotePlayer.cs, then replace its contents:** - -```csharp -using System.Collections; -using System.Collections.Generic; -using UnityEngine; -using SpacetimeDB.Types; -using TMPro; - -public class RemotePlayer : MonoBehaviour -{ - public ulong EntityId; - - public TMP_Text UsernameElement; - - public string Username { set { UsernameElement.text = value; } } - - void Start() - { - // Initialize overhead name - UsernameElement = GetComponentInChildren(); - var canvas = GetComponentInChildren(); - canvas.worldCamera = Camera.main; - - // Get the username from the PlayerComponent for this object and set it in the UI - // FilterByEntityId is normally nullable, but we'll assume not null for simplicity - PlayerComponent playerComp = PlayerComponent.FilterByEntityId(EntityId).First(); - - // Get the last location for this player and set the initial position - EntityComponent entity = EntityComponent.FilterByEntityId(EntityId); - transform.position = new Vector3(entity.Position.X, entity.Position.Y, entity.Position.Z); - - // Register for a callback that is called when the client gets an - // update for a row in the EntityComponent table - EntityComponent.OnUpdate += EntityComponent_OnUpdate; - } -} -``` - -We now write the `EntityComponent_OnUpdate` callback which sets the movement direction in the `MovementController` for this player. We also set the target position to the current location in the latest update. - -**Append to bottom of RemotePlayer class in RemotePlayer.cs:** - -```csharp -private void EntityComponent_OnUpdate(EntityComponent oldObj, EntityComponent obj, ReducerEvent callInfo) -{ - // If the update was made to this object - if(obj.EntityId == EntityId) - { - var movementController = GetComponent(); - - // Update target position, rotation, etc. - movementController.RemoteTargetPosition = new Vector3(obj.Position.X, obj.Position.Y, obj.Position.Z); - movementController.RemoteTargetRotation = obj.Direction; - movementController.SetMoving(obj.Moving); - } -} -``` - -Next we need to handle what happens when a `PlayerComponent` is added to our local cache. We will handle it differently based on if it’s our local player entity or a remote player. We are going to register for the `OnInsert` event for our `PlayerComponent` table. Add the following code to the `Start` function in `TutorialGameManager`. - -**Append to bottom of Start() function in TutorialGameManager.cs:** - -```csharp -PlayerComponent.OnInsert += PlayerComponent_OnInsert; -``` - -Create the `PlayerComponent_OnInsert` function which does something different depending on if it's the component for the local player or a remote player. If it's the local player, we set the local player object's initial position and call `StartGame`. If it's a remote player, we instantiate a `PlayerPrefab` with the `RemotePlayer` component. The start function of `RemotePlayer` handles initializing the player position. - -**Append to bottom of TutorialGameManager class in TutorialGameManager.cs:** - -```csharp -private void PlayerComponent_OnInsert(PlayerComponent obj, ReducerEvent callInfo) -{ - // If the identity of the PlayerComponent matches our user identity then this is the local player - if(obj.OwnerId == local_identity) - { - // Now that we have our initial position we can start the game - StartGame(); - } - else - { - // Spawn the player object and attach the RemotePlayer component - var remotePlayer = Instantiate(PlayerPrefab); - - // Lookup and apply the position for this new player - var entity = EntityComponent.FilterByEntityId(obj.EntityId); - var position = new Vector3(entity.Position.X, entity.Position.Y, entity.Position.Z); - remotePlayer.transform.position = position; - - var movementController = remotePlayer.GetComponent(); - movementController.RemoteTargetPosition = position; - movementController.RemoteTargetRotation = entity.Direction; - - remotePlayer.AddComponent().EntityId = obj.EntityId; - } -} -``` - -Next, we will add a `FixedUpdate()` function to the `LocalPlayer` class so that we can send the local player's position to SpacetimeDB. We will do this by calling the auto-generated reducer function `Reducer.UpdatePlayerPosition(...)`. When we invoke this reducer from the client, a request is sent to SpacetimeDB and the reducer `update_player_position(...)` is executed on the server and a transaction is produced. All clients connected to SpacetimeDB will start receiving the results of these transactions. - -**Append to the top of LocalPlayer.cs** - -```csharp -using SpacetimeDB.Types; -using SpacetimeDB; -``` - -**Append to the bottom of LocalPlayer class in LocalPlayer.cs** - -```csharp -private float? lastUpdateTime; -private void FixedUpdate() -{ - float? deltaTime = Time.time - lastUpdateTime; - bool hasUpdatedRecently = deltaTime.HasValue && deltaTime.Value < 1.0f / movementUpdateSpeed; - bool isConnected = SpacetimeDBClient.instance.IsConnected(); - - if (hasUpdatedRecently || !isConnected) - { - return; - } - - lastUpdateTime = Time.time; - var p = PlayerMovementController.Local.GetModelPosition(); - - Reducer.UpdatePlayerPosition(new StdbVector3 - { - X = p.x, - Y = p.y, - Z = p.z, - }, - PlayerMovementController.Local.GetModelRotation(), - PlayerMovementController.Local.IsMoving()); -} -``` - -Finally, we need to update our connection settings in the inspector for our GameManager object in the scene. Click on the GameManager in the Hierarchy tab. The the inspector tab you should now see fields for `Module Address` and `Host Name`. Set the `Module Address` to the name you used when you ran `spacetime publish`. This is likely `unity-tutorial`. If you don't remember, you can go back to your terminal and run `spacetime publish` again from the `server` folder. - -![GameManager-Inspector2](/images/unity-tutorial/GameManager-Inspector2.JPG) - -### Play the Game! - -Go to File -> Build Settings... Replace the SampleScene with the Main scene we have been working in. - -![Unity-AddOpenScenes](/images/unity-tutorial/Unity-AddOpenScenes.JPG) - -When you hit the `Build` button, it will kick off a build of the game which will use a different identity than the Unity Editor. Create your character in the build and in the Unity Editor by entering a name and clicking `Continue`. Now you can see each other in game running around the map. +### Create the Server Module -### Implement Player Logout - -So far we have not handled the `logged_in` variable of the `PlayerComponent`. This means that remote players will not despawn on your screen when they disconnect. To fix this we need to handle the `OnUpdate` event for the `PlayerComponent` table in addition to `OnInsert`. We are going to use a common function that handles any time the `PlayerComponent` changes. - -**Append to the bottom of Start() function in TutorialGameManager.cs** -```csharp -PlayerComponent.OnUpdate += PlayerComponent_OnUpdate; -``` - -We are going to add a check to determine if the player is logged for remote players. If the player is not logged in, we search for the `RemotePlayer` object with the corresponding `EntityId` and destroy it. - -Next we'll be updating some of the code in `PlayerComponent_OnInsert`. For simplicity, just replace the entire function. - -**REPLACE PlayerComponent_OnInsert in TutorialGameManager.cs** -```csharp -private void PlayerComponent_OnUpdate(PlayerComponent oldValue, PlayerComponent newValue, ReducerEvent dbEvent) -{ - OnPlayerComponentChanged(newValue); -} - -private void PlayerComponent_OnInsert(PlayerComponent obj, ReducerEvent dbEvent) -{ - OnPlayerComponentChanged(obj); -} - -private void OnPlayerComponentChanged(PlayerComponent obj) -{ - // If the identity of the PlayerComponent matches our user identity then this is the local player - if(obj.OwnerId == local_identity) - { - // Now that we have our initial position we can start the game - StartGame(); - } - else - { - // otherwise we need to look for the remote player object in the scene (if it exists) and destroy it - var existingPlayer = FindObjectsOfType().FirstOrDefault(item => item.EntityId == obj.EntityId); - if (obj.LoggedIn) - { - // Only spawn remote players who aren't already spawned - if (existingPlayer == null) - { - // Spawn the player object and attach the RemotePlayer component - var remotePlayer = Instantiate(PlayerPrefab); - - // Lookup and apply the position for this new player - var entity = EntityComponent.FilterByEntityId(obj.EntityId); - var position = new Vector3(entity.Position.X, entity.Position.Y, entity.Position.Z); - remotePlayer.transform.position = position; - - var movementController = remotePlayer.GetComponent(); - movementController.RemoteTargetPosition = position; - movementController.RemoteTargetRotation = entity.Direction; - - remotePlayer.AddComponent().EntityId = obj.EntityId; - } - } - else - { - if (existingPlayer != null) - { - Destroy(existingPlayer.gameObject); - } - } - } -} -``` - -Now you when you play the game you should see remote players disappear when they log out. - -### Finally, Add Chat Support - -The project has a chat window but so far all it's used for is the message of the day. We are going to add the ability for players to send chat messages to each other. - -First lets add a new `ChatMessage` table to the SpacetimeDB module. Add the following code to ``lib.rs``. - -**Append to the bottom of server/src/lib.rs:** - -```rust -#[spacetimedb(table)] -pub struct ChatMessage { - // The primary key for this table will be auto-incremented - #[primarykey] - #[autoinc] - pub message_id: u64, - - // The entity id of the player that sent the message - pub sender_id: u64, - // Message contents - pub text: String, -} -``` - -Now we need to add a reducer to handle inserting new chat messages. - -**Append to the bottom of server/src/lib.rs:** - -```rust -// Adds a chat entry to the ChatMessage table -#[spacetimedb(reducer)] -pub fn send_chat_message(ctx: ReducerContext, text: String) -> Result<(), String> { - if let Some(player) = PlayerComponent::filter_by_owner_id(&ctx.sender) { - // Now that we have the player we can insert the chat message using the player entity id. - ChatMessage::insert(ChatMessage { - // this column auto-increments so we can set it to 0 - message_id: 0, - sender_id: player.entity_id, - text, - }) - .unwrap(); - - return Ok(()); - } - - Err("Player not found".into()) -} -``` - -Before updating the client, let's generate the client files and update publish our module. - -**Execute commands in the server/ directory** -```bash -spacetime generate --out-dir ../client/Assets/module_bindings --lang=csharp -spacetime publish -c unity-tutorial -``` - -On the client, let's add code to send the message when the chat button or enter is pressed. Update the `OnChatButtonPress` function in `UIChatController.cs`. - -**Append to the top of UIChatController.cs:** -```csharp -using SpacetimeDB.Types; -``` - -**REPLACE the OnChatButtonPress function in UIChatController.cs:** - -```csharp -public void OnChatButtonPress() -{ - Reducer.SendChatMessage(_chatInput.text); - _chatInput.text = ""; -} -``` - -Now we need to add a reducer to handle inserting new chat messages. First register for the ChatMessage reducer in the `Start()` function using the auto-generated function: - -**Append to the bottom of the Start() function in TutorialGameManager.cs:** -```csharp -Reducer.OnSendChatMessageEvent += OnSendChatMessageEvent; -``` - -Now we write the `OnSendChatMessageEvent` function. We can find the `PlayerComponent` for the player who sent the message using the `Identity` of the sender. Then we get the `Username` and prepend it to the message before sending it to the chat window. - -**Append after the Start() function in TutorialGameManager.cs** -```csharp -private void OnSendChatMessageEvent(ReducerEvent dbEvent, string message) -{ - var player = PlayerComponent.FilterByOwnerId(dbEvent.Identity); - if (player != null) - { - UIChatController.instance.OnChatMessageReceived(player.Username + ": " + message); - } -} -``` - -Now when you run the game you should be able to send chat messages to other players. Be sure to make a new Unity client build and run it in a separate window so you can test chat between two clients. - -## Conclusion - -This concludes the first part of the tutorial. We've learned about the basics of SpacetimeDB and how to use it to create a multiplayer game. In the next part of the tutorial we will add resource nodes to the game and learn about scheduled reducers. - ---- - -### Troubleshooting - -- If you get an error when running the generate command, make sure you have an empty subfolder in your Unity project Assets folder called `module_bindings` - -- If you get this exception when running the project: - -``` -NullReferenceException: Object reference not set to an instance of an object -TutorialGameManager.Start () (at Assets/_Project/Game/TutorialGameManager.cs:26) -``` - -Check to see if your GameManager object in the Scene has the NetworkManager component attached. - -- If you get an error in your Unity console when starting the game, double check your connection settings in the Inspector for the `GameManager` object in the scene. - -``` -Connection error: Unable to connect to the remote server -``` +From here, the tutorial continues with your favorite server module language of choice: + - [Rust](part-2a-rust.md) + - [C#](part-2b-csharp.md) diff --git a/docs/unity/part-2a-rust.md b/docs/unity/part-2a-rust.md new file mode 100644 index 00000000..9b12de47 --- /dev/null +++ b/docs/unity/part-2a-rust.md @@ -0,0 +1,312 @@ +# Unity Tutorial - Basic Multiplayer - Part 2a - Server Module (Rust) + +Need help with the tutorial? [Join our Discord server](https://discord.gg/spacetimedb)! + +This progressive tutorial is continued from the [Part 1 Tutorial](/docs/unity/part-1.md) + +## Create a Server Module + +Run the following command to initialize the SpacetimeDB server module project with Rust as the language: + +```bash +spacetime init --lang=rust server +``` + +This command creates a new folder named "server" within your Unity project directory and sets up the SpacetimeDB server project with Rust as the programming language. + +### SpacetimeDB Tables + +In this section we'll be making some edits to the file `server/src/lib.rs`. We recommend you open up this file in an IDE like VSCode or RustRover. + +**Important: Open the `server/src/lib.rs` file and delete its contents. We will be writing it from scratch here.** + +First we need to add some imports at the top of the file. + +**Copy and paste into lib.rs:** + +```rust +use spacetimedb::{spacetimedb, Identity, SpacetimeType, ReducerContext}; +use log; +``` + +Then we are going to start by adding the global `Config` table. Right now it only contains the "message of the day" but it can be extended to store other configuration variables. This also uses a couple of macros, like `#[spacetimedb(table)]` which you can learn more about in our [Rust module reference](/docs/modules/rust). Simply put, this just tells SpacetimeDB to create a table which uses this struct as the schema for the table. + +**Append to the bottom of lib.rs:** + +```rust +// We're using this table as a singleton, so there should typically only be one element where the version is 0. +#[spacetimedb(table)] +#[derive(Clone)] +pub struct Config { + #[primarykey] + pub version: u32, + pub message_of_the_day: String, +} +``` + +Next, we're going to define a new `SpacetimeType` called `StdbVector3` which we're going to use to store positions. The difference between a `#[derive(SpacetimeType)]` and a `#[spacetimedb(table)]` is that tables actually store data, whereas the deriving `SpacetimeType` just allows you to create a new column of that type in a SpacetimeDB table. Therefore, `StdbVector3` is not, itself, a table. + +**Append to the bottom of lib.rs:** + +```rust +// This allows us to store 3D points in tables. +#[derive(SpacetimeType, Clone)] +pub struct StdbVector3 { + pub x: f32, + pub y: f32, + pub z: f32, +} +``` + +Now we're going to create a table which actually uses the `StdbVector3` that we just defined. The `EntityComponent` is associated with all entities in the world, including players. + +```rust +// This stores information related to all entities in our game. In this tutorial +// all entities must at least have an entity_id, a position, a direction and they +// must specify whether or not they are moving. +#[spacetimedb(table)] +#[derive(Clone)] +pub struct EntityComponent { + #[primarykey] + // The autoinc macro here just means every time we insert into this table + // we will receive a new row where this value will be increased by one. This + // allows us to easily get rows where `entity_id` is unique. + #[autoinc] + pub entity_id: u64, + pub position: StdbVector3, + pub direction: f32, + pub moving: bool, +} +``` + +Next, we will define the `PlayerComponent` table. The `PlayerComponent` table is used to store information related to players. Each player will have a row in this table, and will also have a row in the `EntityComponent` table with a matching `entity_id`. You'll see how this works later in the `create_player` reducer. + +**Append to the bottom of lib.rs:** + +```rust +// All players have this component and it associates an entity with the user's +// Identity. It also stores their username and whether or not they're logged in. +#[derive(Clone)] +#[spacetimedb(table)] +pub struct PlayerComponent { + // An entity_id that matches an entity_id in the `EntityComponent` table. + #[primarykey] + pub entity_id: u64, + + // The user's identity, which is unique to each player + #[unique] + pub owner_id: Identity, + pub username: String, + pub logged_in: bool, +} +``` + +Next, we write our very first reducer, `create_player`. From the client we will call this reducer when we create a new player: + +**Append to the bottom of lib.rs:** + +```rust +// This reducer is called when the user logs in for the first time and +// enters a username +#[spacetimedb(reducer)] +pub fn create_player(ctx: ReducerContext, username: String) -> Result<(), String> { + // Get the Identity of the client who called this reducer + let owner_id = ctx.sender; + + // Make sure we don't already have a player with this identity + if PlayerComponent::filter_by_owner_id(&owner_id).is_some() { + log::info!("Player already exists"); + return Err("Player already exists".to_string()); + } + + // Create a new entity for this player and get a unique `entity_id`. + let entity_id = EntityComponent::insert(EntityComponent + { + entity_id: 0, + position: StdbVector3 { x: 0.0, y: 0.0, z: 0.0 }, + direction: 0.0, + moving: false, + }).expect("Failed to create a unique PlayerComponent.").entity_id; + + // The PlayerComponent uses the same entity_id and stores the identity of + // the owner, username, and whether or not they are logged in. + PlayerComponent::insert(PlayerComponent { + entity_id, + owner_id, + username: username.clone(), + logged_in: true, + }).expect("Failed to insert player component."); + + log::info!("Player created: {}({})", username, entity_id); + + Ok(()) +} +``` + +--- + +**SpacetimeDB Reducers** + +"Reducer" is a term coined by Clockwork Labs that refers to a function which when executed "reduces" into a list of inserts and deletes, which is then packed into a single database transaction. Reducers can be called remotely using the CLI, client SDK or can be scheduled to be called at some future time from another reducer call. + +--- + +SpacetimeDB gives you the ability to define custom reducers that automatically trigger when certain events occur. + +- `init` - Called the first time you publish your module and anytime you clear the database. We'll learn about publishing later. +- `connect` - Called when a user connects to the SpacetimeDB module. Their identity can be found in the `sender` value of the `ReducerContext`. +- `disconnect` - Called when a user disconnects from the SpacetimeDB module. + +Next, we are going to write a custom `Init` reducer that inserts the default message of the day into our `Config` table. The `Config` table only ever contains a single row with version 0, which we retrieve using `Config.FilterByVersion(0)`. + +**Append to the bottom of lib.rs:** + +```rust +// Called when the module is initially published +#[spacetimedb(init)] +pub fn init() { + Config::insert(Config { + version: 0, + message_of_the_day: "Hello, World!".to_string(), + }).expect("Failed to insert config."); +} +``` + +We use the `connect` and `disconnect` reducers to update the logged in state of the player. The `update_player_login_state` helper function looks up the `PlayerComponent` row using the user's identity and if it exists, it updates the `logged_in` variable and calls the auto-generated `update` function on `PlayerComponent` to update the row. + +**Append to the bottom of lib.rs:** + +```rust +// Called when the client connects, we update the logged_in state to true +#[spacetimedb(connect)] +pub fn client_connected(ctx: ReducerContext) { + update_player_login_state(ctx, true); +} +``` +```rust +// Called when the client disconnects, we update the logged_in state to false +#[spacetimedb(disconnect)] +pub fn client_disconnected(ctx: ReducerContext) { + update_player_login_state(ctx, false); +} +``` +```rust +// This helper function gets the PlayerComponent, sets the logged +// in variable and updates the PlayerComponent table row. +pub fn update_player_login_state(ctx: ReducerContext, logged_in: bool) { + if let Some(player) = PlayerComponent::filter_by_owner_id(&ctx.sender) { + // We clone the PlayerComponent so we can edit it and pass it back. + let mut player = player.clone(); + player.logged_in = logged_in; + PlayerComponent::update_by_entity_id(&player.entity_id.clone(), player); + } +} +``` + +Our final reducer handles player movement. In `update_player_position` we look up the `PlayerComponent` using the user's Identity. If we don't find one, we return an error because the client should not be sending moves without calling `create_player` first. + +Using the `entity_id` in the `PlayerComponent` we retrieved, we can lookup the `EntityComponent` that stores the entity's locations in the world. We update the values passed in from the client and call the auto-generated `update` function. + +**Append to the bottom of lib.rs:** + +```rust +// Updates the position of a player. This is also called when the player stops moving. +#[spacetimedb(reducer)] +pub fn update_player_position( + ctx: ReducerContext, + position: StdbVector3, + direction: f32, + moving: bool, +) -> Result<(), String> { + // First, look up the player using the sender identity, then use that + // entity_id to retrieve and update the EntityComponent + if let Some(player) = PlayerComponent::filter_by_owner_id(&ctx.sender) { + if let Some(mut entity) = EntityComponent::filter_by_entity_id(&player.entity_id) { + entity.position = position; + entity.direction = direction; + entity.moving = moving; + EntityComponent::update_by_entity_id(&player.entity_id, entity); + return Ok(()); + } + } + + // If we can not find the PlayerComponent or EntityComponent for + // this player then something went wrong. + return Err("Player not found".to_string()); +} +``` + +--- + +**Server Validation** + +In a fully developed game, the server would typically perform server-side validation on player movements to ensure they comply with game boundaries, rules, and mechanics. This validation, which we omit for simplicity in this tutorial, is essential for maintaining game integrity, preventing cheating, and ensuring a fair gaming experience. Remember to incorporate appropriate server-side validation in your game's development to ensure a secure and fair gameplay environment. + +--- + +### Publishing a Module to SpacetimeDB + +Now that we've written the code for our server module and reached a clean checkpoint, we need to publish it to SpacetimeDB. This will create the database and call the init reducer. In your terminal or command window, run the following commands. + +```bash +cd server +spacetime publish -c unity-tutorial +``` + +### Finally, Add Chat Support + +The client project has a chat window, but so far, all it's used for is the message of the day. We are going to add the ability for players to send chat messages to each other. + +First lets add a new `ChatMessage` table to the SpacetimeDB module. Add the following code to ``lib.rs``. + +**Append to the bottom of server/src/lib.rs:** + +```rust +#[spacetimedb(table)] +pub struct ChatMessage { + // The primary key for this table will be auto-incremented + #[primarykey] + #[autoinc] + pub message_id: u64, + + // The entity id of the player that sent the message + pub sender_id: u64, + // Message contents + pub text: String, +} +``` + +Now we need to add a reducer to handle inserting new chat messages. + +**Append to the bottom of server/src/lib.rs:** + +```rust +// Adds a chat entry to the ChatMessage table +#[spacetimedb(reducer)] +pub fn send_chat_message(ctx: ReducerContext, text: String) -> Result<(), String> { + if let Some(player) = PlayerComponent::filter_by_owner_id(&ctx.sender) { + // Now that we have the player we can insert the chat message using the player entity id. + ChatMessage::insert(ChatMessage { + // this column auto-increments so we can set it to 0 + message_id: 0, + sender_id: player.entity_id, + text, + }) + .unwrap(); + + return Ok(()); + } + + Err("Player not found".into()) +} +``` + +## Wrapping Up + +Now that we added chat support, let's publish the latest module version to SpacetimeDB, assuming we're still in the `server` dir: + +```bash +spacetime publish -c unity-tutorial +``` + +From here, the [next tutorial](/docs/unity/part-3.md) continues with a Client (Unity) focus. diff --git a/docs/unity/part-2b-c-sharp.md b/docs/unity/part-2b-c-sharp.md new file mode 100644 index 00000000..f324a36d --- /dev/null +++ b/docs/unity/part-2b-c-sharp.md @@ -0,0 +1,344 @@ +# Unity Tutorial - Basic Multiplayer - Part 2a - Server Module (C#) + +Need help with the tutorial? [Join our Discord server](https://discord.gg/spacetimedb)! + +This progressive tutorial is continued from the [Part 1 Tutorial](/docs/unity/part-1.md) + +## Create a Server Module + +Run the following command to initialize the SpacetimeDB server module project with C# as the language: + +```bash +spacetime init --lang=csharp server +``` + +This command creates a new folder named "server" within your Unity project directory and sets up the SpacetimeDB server project with C# as the programming language. + +### SpacetimeDB Tables + +In this section we'll be making some edits to the file `server/src/lib.cs`. We recommend you open up this file in an IDE like VSCode. + +**Important: Open the `server/src/lib.cs` file and delete its contents. We will be writing it from scratch here.** + +First we need to add some imports at the top of the file. + +**Copy and paste into lib.cs:** + +```csharp +// using SpacetimeDB; // Uncomment to omit `SpacetimeDB` attribute prefixes +using SpacetimeDB.Module; +using static SpacetimeDB.Runtime; +``` + +Then we are going to start by adding the global `Config` table. Right now it only contains the "message of the day" but it can be extended to store other configuration variables. This also uses a couple of macros, like `#[spacetimedb(table)]` which you can learn more about in our [C# module reference](/docs/modules/c-sharp). Simply put, this just tells SpacetimeDB to create a table which uses this struct as the schema for the table. + +**Append to the bottom of lib.cs:** + +```csharp +/// We're using this table as a singleton, +/// so there should typically only be one element where the version is 0. +[SpacetimeDB.Table] +public partial class Config +{ + [SpacetimeDB.Column(ColumnAttrs.PrimaryKey)] + public Identity Version; + public string? MessageOfTheDay; +} +``` + +Next, we're going to define a new `SpacetimeType` called `StdbVector3` which we're going to use to store positions. The difference between a `[SpacetimeDB.Type]` and a `[SpacetimeDB.Table]` is that tables actually store data, whereas the deriving `SpacetimeType` just allows you to create a new column of that type in a SpacetimeDB table. Therefore, `StdbVector3` is not, itself, a table. + +**Append to the bottom of lib.cs:** + +```csharp +/// This allows us to store 3D points in tables. +[SpacetimeDB.Type] +public partial class StdbVector3 +{ + public float X; + public float Y; + public float Z; +} +``` + +Now we're going to create a table which actually uses the `StdbVector3` that we just defined. The `EntityComponent` is associated with all entities in the world, including players. + +```csharp +/// This stores information related to all entities in our game. In this tutorial +/// all entities must at least have an entity_id, a position, a direction and they +/// must specify whether or not they are moving. +[SpacetimeDB.Table] +public partial class EntityComponent +{ + [SpacetimeDB.Column(ColumnAttrs.PrimaryKeyAuto)] + public ulong EntityId; + public StdbVector3 Position; + public float Direction; + public bool Moving; +} +``` + +Next, we will define the `PlayerComponent` table. The `PlayerComponent` table is used to store information related to players. Each player will have a row in this table, and will also have a row in the `EntityComponent` table with a matching `EntityId`. You'll see how this works later in the `CreatePlayer` reducer. + +**Append to the bottom of lib.cs:** + +```csharp +/// All players have this component and it associates an entity with the user's +/// Identity. It also stores their username and whether or not they're logged in. +[SpacetimeDB.Table] +public partial class PlayerComponent +{ + // An EntityId that matches an EntityId in the `EntityComponent` table. + [SpacetimeDB.Column(ColumnAttrs.PrimaryKey)] + public ulong EntityId; + + // The user's identity, which is unique to each player + [SpacetimeDB.Column(ColumnAttrs.Unique)] + public Identity Identity; + public string? Username; + public bool LoggedIn; +} +``` + +Next, we write our very first reducer, `CreatePlayer`. From the client we will call this reducer when we create a new player: + +**Append to the bottom of lib.cs:** + +```csharp +/// This reducer is called when the user logs in for the first time and +/// enters a username. +[SpacetimeDB.Reducer] +public static void CreatePlayer(DbEventArgs dbEvent, string username) +{ + // Get the Identity of the client who called this reducer + Identity sender = dbEvent.Sender; + + // Make sure we don't already have a player with this identity + PlayerComponent? user = PlayerComponent.FindByIdentity(sender); + if (user is null) + { + throw new ArgumentException("Player already exists"); + } + + // Create a new entity for this player + try + { + new EntityComponent + { + // EntityId = 0, // 0 is the same as leaving null to get a new, unique Id + Position = new StdbVector3 { X = 0, Y = 0, Z = 0 }, + Direction = 0, + Moving = false, + }.Insert(); + } + catch + { + Log("Error: Failed to create a unique PlayerComponent", LogLevel.Error); + Throw; + } + + // The PlayerComponent uses the same entity_id and stores the identity of + // the owner, username, and whether or not they are logged in. + try + { + new PlayerComponent + { + // EntityId = 0, // 0 is the same as leaving null to get a new, unique Id + Identity = dbEvent.Sender, + Username = username, + LoggedIn = true, + }.Insert(); + } + catch + { + Log("Error: Failed to insert PlayerComponent", LogLevel.Error); + throw; + } + Log($"Player created: {username}"); +} +``` + +--- + +**SpacetimeDB Reducers** + +"Reducer" is a term coined by Clockwork Labs that refers to a function which when executed "reduces" into a list of inserts and deletes, which is then packed into a single database transaction. Reducers can be called remotely using the CLI, client SDK or can be scheduled to be called at some future time from another reducer call. + +--- + +SpacetimeDB gives you the ability to define custom reducers that automatically trigger when certain events occur. + +- `Init` - Called the first time you publish your module and anytime you clear the database. We'll learn about publishing later. +- `Connect` - Called when a user connects to the SpacetimeDB module. Their identity can be found in the `Sender` value of the `ReducerContext`. +- `Disconnect` - Called when a user disconnects from the SpacetimeDB module. + +Next, we are going to write a custom `Init` reducer that inserts the default message of the day into our `Config` table. The `Config` table only ever contains a single row with version 0, which we retrieve using `Config.FilterByVersion(0)`. + +**Append to the bottom of lib.cs:** + +```csharp +/// Called when the module is initially published +[SpacetimeDB.Reducer(ReducerKind.Init)] +public static void OnInit() +{ + try + { + new Config + { + Version = 0, + MessageOfTheDay = "Hello, World!", + }.Insert(); + } + catch + { + Log("Error: Failed to insert Config", LogLevel.Error); + throw; + } +} +``` + +We use the `Connect` and `Disconnect` reducers to update the logged in state of the player. The `UpdatePlayerLoginState` helper function we are about to define looks up the `PlayerComponent` row using the user's identity and if it exists, it updates the `LoggedIn` variable and calls the auto-generated `Update` function on `PlayerComponent` to update the row. + +**Append to the bottom of lib.cs:** + +```csharp +/// Called when the client connects, we update the LoggedIn state to true +[SpacetimeDB.Reducer(ReducerKind.Init)] +public static void ClientConnected(DbEventArgs dbEvent) => + UpdatePlayerLoginState(dbEvent, loggedIn:true); +``` +```csharp +/// Called when the client disconnects, we update the logged_in state to false +[SpacetimeDB.Reducer(ReducerKind.Disconnect)] +public static void ClientDisonnected(DbEventArgs dbEvent) => + UpdatePlayerLoginState(dbEvent, loggedIn:false); +``` +```csharp +/// This helper function gets the PlayerComponent, sets the LoggedIn +/// variable and updates the PlayerComponent table row. +private static void UpdatePlayerLoginState(DbEventArgs dbEvent, bool loggedIn) +{ + PlayerComponent? player = PlayerComponent.FindByIdentity(dbEvent.Sender); + if (player is null) + { + throw new ArgumentException("Player not found"); + } + + player.LoggedIn = loggedIn; + PlayerComponent.UpdateByIdentity(dbEvent.Sender, player); +} +``` + +Our final reducer handles player movement. In `UpdatePlayerPosition` we look up the `PlayerComponent` using the user's Identity. If we don't find one, we return an error because the client should not be sending moves without calling `CreatePlayer` first. + +Using the `EntityId` in the `PlayerComponent` we retrieved, we can lookup the `EntityComponent` that stores the entity's locations in the world. We update the values passed in from the client and call the auto-generated `Update` function. + +**Append to the bottom of lib.cs:** + +```csharp +/// Updates the position of a player. This is also called when the player stops moving. +[SpacetimeDB.Reducer] +private static void UpdatePlayerPosition( + DbEventArgs dbEvent, + StdbVector3 position, + float direction, + bool moving) +{ + // First, look up the player using the sender identity + PlayerComponent? player = PlayerComponent.FindByIdentity(dbEvent.Sender); + if (player is null) + { + throw new ArgumentException("Player not found"); + } + // Use the Player's EntityId to retrieve and update the EntityComponent + ulong playerEntityId = player.EntityId; + EntityComponent? entity = EntityComponent.FindByEntityId(playerEntityId); + if (entity is null) + { + throw new ArgumentException($"Player Entity '{playerEntityId}' not found"); + } + + entity.Position = position; + entity.Direction = direction; + entity.Moving = moving; + EntityComponent.UpdateByEntityId(playerEntityId, entity); +} +``` + +--- + +**Server Validation** + +In a fully developed game, the server would typically perform server-side validation on player movements to ensure they comply with game boundaries, rules, and mechanics. This validation, which we omit for simplicity in this tutorial, is essential for maintaining game integrity, preventing cheating, and ensuring a fair gaming experience. Remember to incorporate appropriate server-side validation in your game's development to ensure a secure and fair gameplay environment. + +--- + +### Publishing a Module to SpacetimeDB + +Now that we've written the code for our server module and reached a clean checkpoint, we need to publish it to SpacetimeDB. This will create the database and call the init reducer. In your terminal or command window, run the following commands. + +```bash +cd server +spacetime publish -c unity-tutorial +``` + +### Finally, Add Chat Support + +The client project has a chat window, but so far, all it's used for is the message of the day. We are going to add the ability for players to send chat messages to each other. + +First lets add a new `ChatMessage` table to the SpacetimeDB module. Add the following code to ``lib.cs``. + +**Append to the bottom of server/src/lib.cs:** + +```csharp +[SpacetimeDB.Table] +public partial class ChatMessage +{ + // The primary key for this table will be auto-incremented + [SpacetimeDB.Column(ColumnAttrs.PrimaryKeyAuto)] + + // The entity id of the player that sent the message + public ulong SenderId; + + // Message contents + public string? Text; +} +``` + +Now we need to add a reducer to handle inserting new chat messages. + +**Append to the bottom of server/src/lib.cs:** + +```csharp +/// Adds a chat entry to the ChatMessage table +[SpacetimeDB.Reducer] +public static void SendChatMessage(DbEventArgs dbEvent, string text) +{ + // Get the player's entity id + PlayerComponent? player = PlayerComponent.FindByIdentity(dbEvent.Sender); + if (player is null) + { + throw new ArgumentException("Player not found"); + } + + + // Insert the chat message + new ChatMessage + { + SenderId = player.EntityId, + Text = text, + }.Insert(); +} +``` + +## Wrapping Up + +💡View the [entire lib.cs file](https://gist.github.com/dylanh724/68067b4e843ea6e99fbd297fe1a87c49) + +Now that we added chat support, let's publish the latest module version to SpacetimeDB, assuming we're still in the `server` dir: + +```bash +spacetime publish -c unity-tutorial +``` + +From here, the [next tutorial](/docs/unity/part-3.md) continues with a Client (Unity) focus. \ No newline at end of file diff --git a/docs/unity/part-3.md b/docs/unity/part-3.md index b49b5a5d..c80000e1 100644 --- a/docs/unity/part-3.md +++ b/docs/unity/part-3.md @@ -1,104 +1,479 @@ -# Part 3 - BitCraft Mini +# Unity Tutorial - Basic Multiplayer - Part 3 - Client -**Oct 14th, 2023: This tutorial has not yet been updated for the recent 0.7.0 release, it will be updated asap!** +Need help with the tutorial? [Join our Discord server](https://discord.gg/spacetimedb)! -BitCraft Mini is a game that we developed which extends the code you've already developed in this tutorial. It is inspired by our game [BitCraft](https://bitcraftonline.com) and illustrates how you could build a more complex game from just the components we've discussed. Right now you can walk around, mine ore, and manage your inventory. +This progressive tutorial is continued from one of the Part 2 tutorials: +- [Rust Server Module](/docs/unity/part-2a-rust.md) +- [C# Server Module](/docs/unity/part-2b-c-sharp.md) -## 1. Download +## Updating our Unity Project Client to use SpacetimeDB -You can git-clone BitCraftMini from here: +Now we are ready to connect our _BitCraft Mini_ project to SpacetimeDB. -```plaintext -git clone ssh://git@github.com/clockworklabs/BitCraftMini +### Import the SDK and Generate Module Files + +1. Add the SpacetimeDB Unity Package using the Package Manager. Open the Package Manager window by clicking on Window -> Package Manager. Click on the + button in the top left corner of the window and select "Add package from git URL". Enter the following URL and click Add. + +```bash +https://github.com/clockworklabs/com.clockworklabs.spacetimedbsdk.git ``` -Once you have downloaded BitCraftMini, you will need to compile the spacetime module. +![Unity-PackageManager](/images/unity-tutorial/Unity-PackageManager.JPG) -## 2. Compile the Spacetime Module +3. The next step is to generate the module specific client files using the SpacetimeDB CLI. The files created by this command provide an interface for retrieving values from the local client cache of the database and for registering for callbacks to events. In your terminal or command window, run the following commands. -In order to compile the BitCraftMini module, you will need to install cargo. You can install cargo from here: +```bash +mkdir -p ../client/Assets/module_bindings +spacetime generate --out-dir ../client/Assets/module_bindings --lang=csharp +``` -> https://www.rust-lang.org/tools/install +### Connect to Your SpacetimeDB Module -Once you have cargo installed, you can compile and publish the module with these commands: +The Unity SpacetimeDB SDK relies on there being a `NetworkManager` somewhere in the scene. Click on the GameManager object in the scene, and in the inspector, add the `NetworkManager` component. -```bash -cd BitCraftMini/Server -spacetime publish +![Unity-AddNetworkManager](/images/unity-tutorial/Unity-AddNetworkManager.JPG) + +Next we are going to connect to our SpacetimeDB module. Open `TutorialGameManager.cs` in your editor of choice and add the following code at the top of the file: + +**Append to the top of TutorialGameManager.cs** + +```csharp +using SpacetimeDB; +using SpacetimeDB.Types; +using System.Linq; ``` -`spacetime publish` will output an address where your module has been deployed to. You will want to copy/save this address because you will need it in step 3. Here is an example of what it should look like: +At the top of the class definition add the following members: -```plaintext -$ spacetime publish -info: component 'rust-std' for target 'wasm32-unknown-unknown' is up to date - Finished release [optimized] target(s) in 0.03s -Publish finished successfully. -Created new database with address: c91c17ecdcea8a05302be2bad9dd59b3 +**Append to the top of TutorialGameManager class inside of TutorialGameManager.cs** + +```csharp +// These are connection variables that are exposed on the GameManager +// inspector. +[SerializeField] private string moduleAddress = "unity-tutorial"; +[SerializeField] private string hostName = "localhost:3000"; + +// This is the identity for this player that is automatically generated +// the first time you log in. We set this variable when the +// onIdentityReceived callback is triggered by the SDK after connecting +private Identity local_identity; ``` -Optionally, you can specify a name when you publish the module: +The first three fields will appear in your Inspector so you can update your connection details without editing the code. The `moduleAddress` should be set to the domain you used in the publish command. You should not need to change `hostName` if you are using SpacetimeDB locally. + +Now add the following code to the `Start()` function. For clarity, replace your entire `Start()` function with the function below. + +**REPLACE the Start() function in TutorialGameManager.cs** + +```csharp +// Start is called before the first frame update +void Start() +{ + instance = this; + + SpacetimeDBClient.instance.onConnect += () => + { + Debug.Log("Connected."); + + // Request all tables + SpacetimeDBClient.instance.Subscribe(new List() + { + "SELECT * FROM *", + }); + }; + + // Called when we have an error connecting to SpacetimeDB + SpacetimeDBClient.instance.onConnectError += (error, message) => + { + Debug.LogError($"Connection error: " + message); + }; + + // Called when we are disconnected from SpacetimeDB + SpacetimeDBClient.instance.onDisconnect += (closeStatus, error) => + { + Debug.Log("Disconnected."); + }; + + // Called when we receive the client identity from SpacetimeDB + SpacetimeDBClient.instance.onIdentityReceived += (token, identity, address) => { + AuthToken.SaveToken(token); + local_identity = identity; + }; + + // Called after our local cache is populated from a Subscribe call + SpacetimeDBClient.instance.onSubscriptionApplied += OnSubscriptionApplied; + + // Now that we’ve registered all our callbacks, lets connect to spacetimedb + SpacetimeDBClient.instance.Connect(AuthToken.Token, hostName, moduleAddress); +} +``` -```bash -spacetime publish "unique-module-name" +In our `onConnect` callback we are calling `Subscribe` and subscribing to all data in the database. You can also subscribe to specific tables using SQL syntax like `SELECT * FROM MyTable`. Our SQL documentation enumerates the operations that are accepted in our SQL syntax. + +Subscribing to tables tells SpacetimeDB what rows we want in our local client cache. We will also not get row update callbacks or event callbacks for any reducer that does not modify a row that matches at least one of our queries. This means that events can happen on the server and the client won't be notified unless they are subscribed to at least 1 row in the change. + +--- + +**Local Client Cache** + +The "local client cache" is a client-side view of the database defined by the supplied queries to the `Subscribe` function. It contains the requested data which allows efficient access without unnecessary server queries. Accessing data from the client cache is done using the auto-generated iter and filter_by functions for each table, and it ensures that update and event callbacks are limited to the subscribed rows. + +--- + +Next we write the `OnSubscriptionApplied` callback. When this event occurs for the first time, it signifies that our local client cache is fully populated. At this point, we can verify if a player entity already exists for the corresponding user. If we do not have a player entity, we need to show the `UserNameChooser` dialog so the user can enter a username. We also put the message of the day into the chat window. Finally we unsubscribe from the callback since we only need to do this once. + +**Append after the Start() function in TutorialGameManager.cs** + +```csharp +void OnSubscriptionApplied() +{ + // If we don't have any data for our player, then we are creating a + // new one. Let's show the username dialog, which will then call the + // create player reducer + var player = PlayerComponent.FilterByOwnerId(local_identity); + if (player == null) + { + // Show username selection + UIUsernameChooser.instance.Show(); + } + + // Show the Message of the Day in our Config table of the Client Cache + UIChatController.instance.OnChatMessageReceived("Message of the Day: " + Config.FilterByVersion(0).MessageOfTheDay); + + // Now that we've done this work we can unregister this callback + SpacetimeDBClient.instance.onSubscriptionApplied -= OnSubscriptionApplied; +} ``` -Currently, all the named modules exist in the same namespace so if you get a message saying that database is not owned by you, it means that someone else has already published a module with that name. You can either choose a different name or you can use the address instead. If you specify a name when you publish, you can use that name in place of the autogenerated address in both the CLI and in the Unity client. +### Adding the Multiplayer Functionality -In the BitCraftMini module we have a function called `initialize()`. This function should be called immediately after publishing the module to spacetimedb. This function is in charge of generating some initial settings that are required for the server to operate. You can call this function like so: +Now we have to change what happens when you press the "Continue" button in the name dialog window. Instead of calling start game like we did in the single player version, we call the `create_player` reducer on the SpacetimeDB module using the auto-generated code. Open `UIUsernameChooser.cs`. -```bash -spacetime call "" "initialize" "[]" +**Append to the top of UIUsernameChooser.cs** + +```csharp +using SpacetimeDB.Types; ``` -Here we are telling spacetime to invoke the `initialize()` function on our module "bitcraftmini". If the function had some arguments, we would json encode them and put them into the "[]". Since `initialize()` requires no parameters, we just leave it empty. +Then we're doing a modification to the `ButtonPressed()` function: -After you have called `initialize()` on the spacetime module you shouldgenerate the client files: +**Modify the ButtonPressed function in UIUsernameChooser.cs** -```bash -spacetime generate --out-dir ../Client/Assets/_Project/autogen --lang=cs +```csharp +public void ButtonPressed() +{ + CameraController.RemoveDisabler(GetHashCode()); + _panel.SetActive(false); + + // Call the SpacetimeDB CreatePlayer reducer + Reducer.CreatePlayer(_usernameField.text); +} +``` + +We need to create a `RemotePlayer` script that we attach to remote player objects. In the same folder as `LocalPlayer.cs`, create a new C# script called `RemotePlayer`. In the start function, we will register an OnUpdate callback for the `EntityComponent` and query the local cache to get the player’s initial position. **Make sure you include a `using SpacetimeDB.Types;`** at the top of the file. + +First append this using to the top of `RemotePlayer.cs` + +**Create file RemotePlayer.cs, then replace its contents:** + +```csharp +using System.Collections; +using System.Collections.Generic; +using UnityEngine; +using SpacetimeDB.Types; +using TMPro; + +public class RemotePlayer : MonoBehaviour +{ + public ulong EntityId; + + public TMP_Text UsernameElement; + + public string Username { set { UsernameElement.text = value; } } + + void Start() + { + // Initialize overhead name + UsernameElement = GetComponentInChildren(); + var canvas = GetComponentInChildren(); + canvas.worldCamera = Camera.main; + + // Get the username from the PlayerComponent for this object and set it in the UI + PlayerComponent? playerComp = PlayerComponent.FilterByEntityId(EntityId).FirstOrDefault(); + if (playerComp is null) + { + string inputUsername = UsernameElement.Text; + Debug.Log($"PlayerComponent not found - Creating a new player ({inputUsername})"); + Reducer.CreatePlayer(inputUsername); + + // Try again, optimistically assuming success for simplicity + PlayerComponent? playerComp = PlayerComponent.FilterByEntityId(EntityId).FirstOrDefault(); + } + + Username = playerComp.Username; + + // Get the last location for this player and set the initial position + EntityComponent entity = EntityComponent.FilterByEntityId(EntityId); + transform.position = new Vector3(entity.Position.X, entity.Position.Y, entity.Position.Z); + + // Register for a callback that is called when the client gets an + // update for a row in the EntityComponent table + EntityComponent.OnUpdate += EntityComponent_OnUpdate; + } +} +``` + +We now write the `EntityComponent_OnUpdate` callback which sets the movement direction in the `MovementController` for this player. We also set the target position to the current location in the latest update. + +**Append to bottom of RemotePlayer class in RemotePlayer.cs:** + +```csharp +private void EntityComponent_OnUpdate(EntityComponent oldObj, EntityComponent obj, ReducerEvent callInfo) +{ + // If the update was made to this object + if(obj.EntityId == EntityId) + { + var movementController = GetComponent(); + + // Update target position, rotation, etc. + movementController.RemoteTargetPosition = new Vector3(obj.Position.X, obj.Position.Y, obj.Position.Z); + movementController.RemoteTargetRotation = obj.Direction; + movementController.SetMoving(obj.Moving); + } +} +``` + +Next we need to handle what happens when a `PlayerComponent` is added to our local cache. We will handle it differently based on if it’s our local player entity or a remote player. We are going to register for the `OnInsert` event for our `PlayerComponent` table. Add the following code to the `Start` function in `TutorialGameManager`. + +**Append to bottom of Start() function in TutorialGameManager.cs:** + +```csharp +PlayerComponent.OnInsert += PlayerComponent_OnInsert; +``` + +Create the `PlayerComponent_OnInsert` function which does something different depending on if it's the component for the local player or a remote player. If it's the local player, we set the local player object's initial position and call `StartGame`. If it's a remote player, we instantiate a `PlayerPrefab` with the `RemotePlayer` component. The start function of `RemotePlayer` handles initializing the player position. + +**Append to bottom of TutorialGameManager class in TutorialGameManager.cs:** + +```csharp +private void PlayerComponent_OnInsert(PlayerComponent obj, ReducerEvent callInfo) +{ + // If the identity of the PlayerComponent matches our user identity then this is the local player + if(obj.OwnerId == local_identity) + { + // Now that we have our initial position we can start the game + StartGame(); + } + else + { + // Spawn the player object and attach the RemotePlayer component + var remotePlayer = Instantiate(PlayerPrefab); + + // Lookup and apply the position for this new player + var entity = EntityComponent.FilterByEntityId(obj.EntityId); + var position = new Vector3(entity.Position.X, entity.Position.Y, entity.Position.Z); + remotePlayer.transform.position = position; + + var movementController = remotePlayer.GetComponent(); + movementController.RemoteTargetPosition = position; + movementController.RemoteTargetRotation = entity.Direction; + + remotePlayer.AddComponent().EntityId = obj.EntityId; + } +} ``` -Here is some sample output: +Next, we will add a `FixedUpdate()` function to the `LocalPlayer` class so that we can send the local player's position to SpacetimeDB. We will do this by calling the auto-generated reducer function `Reducer.UpdatePlayerPosition(...)`. When we invoke this reducer from the client, a request is sent to SpacetimeDB and the reducer `update_player_position(...)` (Rust) or `UpdatePlayerPosition(...)` (C#) is executed on the server and a transaction is produced. All clients connected to SpacetimeDB will start receiving the results of these transactions. -```plaintext -$ spacetime generate --out-dir ../Client/Assets/_Project/autogen --lang cs -info: component 'rust-std' for target 'wasm32-unknown-unknown' is up to date - Finished release [optimized] target(s) in 0.03s -compilation took 234.613518ms -Generate finished successfully. +**Append to the top of LocalPlayer.cs** + +```csharp +using SpacetimeDB.Types; +using SpacetimeDB; ``` -If you've gotten this message then everything should be working properly so far. +**Append to the bottom of LocalPlayer class in LocalPlayer.cs** + +```csharp +private float? lastUpdateTime; +private void FixedUpdate() +{ + float? deltaTime = Time.time - lastUpdateTime; + bool hasUpdatedRecently = deltaTime.HasValue && deltaTime.Value < 1.0f / movementUpdateSpeed; + bool isConnected = SpacetimeDBClient.instance.IsConnected(); + + if (hasUpdatedRecently || !isConnected) + { + return; + } + + lastUpdateTime = Time.time; + var p = PlayerMovementController.Local.GetModelPosition(); + + Reducer.UpdatePlayerPosition(new StdbVector3 + { + X = p.x, + Y = p.y, + Z = p.z, + }, + PlayerMovementController.Local.GetModelRotation(), + PlayerMovementController.Local.IsMoving()); +} +``` -## 3. Replace address in BitCraftMiniGameManager +Finally, we need to update our connection settings in the inspector for our GameManager object in the scene. Click on the GameManager in the Hierarchy tab. The the inspector tab you should now see fields for `Module Address` and `Host Name`. Set the `Module Address` to the name you used when you ran `spacetime publish`. This is likely `unity-tutorial`. If you don't remember, you can go back to your terminal and run `spacetime publish` again from the `server` folder. -The following settings are exposed in the `BitCraftMiniGameManager` inspector: Module Address, Host Name, and SSL Enabled. +![GameManager-Inspector2](/images/unity-tutorial/GameManager-Inspector2.JPG) -Open the Main scene in Unity and click on the `GameManager` object in the heirarchy. The inspector window will look like this: +### Play the Game! -![GameManager-Inspector](/images/unity-tutorial/GameManager-Inspector.JPG) +Go to File -> Build Settings... Replace the SampleScene with the Main scene we have been working in. -Update the module address with the address you got from the `spacetime publish` command. If you are using SpacetimeDB Cloud `testnet`, the host name should be `testnet.spacetimedb.com` and SSL Enabled should be checked. If you are running SpacetimeDB Standalone locally, the host name should be `localhost:3000` and SSL Enabled should be unchecked. For instructions on how to deploy to these environments, see the [Deployment Section](/docs/deploying/testnet) +![Unity-AddOpenScenes](/images/unity-tutorial/Unity-AddOpenScenes.JPG) -## 4. Play Mode +When you hit the `Build` button, it will kick off a build of the game which will use a different identity than the Unity Editor. Create your character in the build and in the Unity Editor by entering a name and clicking `Continue`. Now you can see each other in game running around the map. -You should now be able to enter play mode and walk around! You can mine some rocks, cut down some trees and if you connect more clients you can trade with other players. +### Implement Player Logout -## 5. Editing the Module +So far we have not handled the `logged_in` variable of the `PlayerComponent`. This means that remote players will not despawn on your screen when they disconnect. To fix this we need to handle the `OnUpdate` event for the `PlayerComponent` table in addition to `OnInsert`. We are going to use a common function that handles any time the `PlayerComponent` changes. -If you want to make further updates to the module, make sure to use this publish command instead: +**Append to the bottom of Start() function in TutorialGameManager.cs** +```csharp +PlayerComponent.OnUpdate += PlayerComponent_OnUpdate; +``` -```bash -spacetime publish +We are going to add a check to determine if the player is logged for remote players. If the player is not logged in, we search for the `RemotePlayer` object with the corresponding `EntityId` and destroy it. + +Next we'll be updating some of the code in `PlayerComponent_OnInsert`. For simplicity, just replace the entire function. + +**REPLACE PlayerComponent_OnInsert in TutorialGameManager.cs** +```csharp +private void PlayerComponent_OnUpdate(PlayerComponent oldValue, PlayerComponent newValue, ReducerEvent dbEvent) +{ + OnPlayerComponentChanged(newValue); +} + +private void PlayerComponent_OnInsert(PlayerComponent obj, ReducerEvent dbEvent) +{ + OnPlayerComponentChanged(obj); +} + +private void OnPlayerComponentChanged(PlayerComponent obj) +{ + // If the identity of the PlayerComponent matches our user identity then this is the local player + if(obj.OwnerId == local_identity) + { + // Now that we have our initial position we can start the game + StartGame(); + } + else + { + // otherwise we need to look for the remote player object in the scene (if it exists) and destroy it + var existingPlayer = FindObjectsOfType().FirstOrDefault(item => item.EntityId == obj.EntityId); + if (obj.LoggedIn) + { + // Only spawn remote players who aren't already spawned + if (existingPlayer == null) + { + // Spawn the player object and attach the RemotePlayer component + var remotePlayer = Instantiate(PlayerPrefab); + + // Lookup and apply the position for this new player + var entity = EntityComponent.FilterByEntityId(obj.EntityId); + var position = new Vector3(entity.Position.X, entity.Position.Y, entity.Position.Z); + remotePlayer.transform.position = position; + + var movementController = remotePlayer.GetComponent(); + movementController.RemoteTargetPosition = position; + movementController.RemoteTargetRotation = entity.Direction; + + remotePlayer.AddComponent().EntityId = obj.EntityId; + } + } + else + { + if (existingPlayer != null) + { + Destroy(existingPlayer.gameObject); + } + } + } +} ``` -Where `` is your own address. If you do this instead then you won't have to change the address inside of `BitCraftMiniGameManager.cs` +Now you when you play the game you should see remote players disappear when they log out. -When you change the server module you should also regenerate the client files as well: +Before updating the client, let's generate the client files and update publish our module. +**Execute commands in the server/ directory** ```bash -spacetime generate --out-dir ../Client/Assets/_Project/autogen --lang=cs +spacetime generate --out-dir ../client/Assets/module_bindings --lang=csharp +spacetime publish -c unity-tutorial +``` + +On the client, let's add code to send the message when the chat button or enter is pressed. Update the `OnChatButtonPress` function in `UIChatController.cs`. + +**Append to the top of UIChatController.cs:** +```csharp +using SpacetimeDB.Types; +``` + +**REPLACE the OnChatButtonPress function in UIChatController.cs:** + +```csharp +public void OnChatButtonPress() +{ + Reducer.SendChatMessage(_chatInput.text); + _chatInput.text = ""; +} +``` + +Now we need to add a reducer to handle inserting new chat messages. First register for the ChatMessage reducer in the `Start()` function using the auto-generated function: + +**Append to the bottom of the Start() function in TutorialGameManager.cs:** +```csharp +Reducer.OnSendChatMessageEvent += OnSendChatMessageEvent; ``` -You may want to consider putting these 2 commands into a simple shell script to make the process a bit cleaner. +Now we write the `OnSendChatMessageEvent` function. We can find the `PlayerComponent` for the player who sent the message using the `Identity` of the sender. Then we get the `Username` and prepend it to the message before sending it to the chat window. + +**Append after the Start() function in TutorialGameManager.cs** +```csharp +private void OnSendChatMessageEvent(ReducerEvent dbEvent, string message) +{ + var player = PlayerComponent.FilterByOwnerId(dbEvent.Identity); + if (player != null) + { + UIChatController.instance.OnChatMessageReceived(player.Username + ": " + message); + } +} +``` + +Now when you run the game you should be able to send chat messages to other players. Be sure to make a new Unity client build and run it in a separate window so you can test chat between two clients. + +## Conclusion + +This concludes the SpacetimeDB basic multiplayer tutorial, where we learned how to create a multiplayer game. In the next Unity tutorial, we will add resource nodes to the game and learn about _scheduled_ reducers: + +From here, the tutorial continues with more-advanced topics: The [next tutorial](/docs/unity/part-4.md) introduces Resources & Scheduling. + +--- + +### Troubleshooting + +- If you get an error when running the generate command, make sure you have an empty subfolder in your Unity project Assets folder called `module_bindings` + +- If you get this exception when running the project: + +``` +NullReferenceException: Object reference not set to an instance of an object +TutorialGameManager.Start () (at Assets/_Project/Game/TutorialGameManager.cs:26) +``` + +Check to see if your GameManager object in the Scene has the NetworkManager component attached. + +- If you get an error in your Unity console when starting the game, double check your connection settings in the Inspector for the `GameManager` object in the scene. + +``` +Connection error: Unable to connect to the remote server +``` diff --git a/docs/unity/part-2.md b/docs/unity/part-4.md similarity index 97% rename from docs/unity/part-2.md rename to docs/unity/part-4.md index 537edd44..a87f27a2 100644 --- a/docs/unity/part-2.md +++ b/docs/unity/part-4.md @@ -1,4 +1,8 @@ -# Part 2 - Resources and Scheduling +# Unity Tutorial - Advanced - Part 4 - Resources and Scheduling + +Need help with the tutorial? [Join our Discord server](https://discord.gg/spacetimedb)! + +This progressive tutorial is continued from the [Part 3](/docs/unity/part-3.md) Tutorial. **Oct 14th, 2023: This tutorial has not yet been updated for the recent 0.7.0 release, it will be updated asap!** diff --git a/docs/unity/part-5.md b/docs/unity/part-5.md new file mode 100644 index 00000000..6ebce1c0 --- /dev/null +++ b/docs/unity/part-5.md @@ -0,0 +1,108 @@ +# Unity Tutorial - Advanced - Part 5 - BitCraft Mini + +Need help with the tutorial? [Join our Discord server](https://discord.gg/spacetimedb)! + +This progressive tutorial is continued from the [Part 4](/docs/unity/part-3.md) Tutorial. + +**Oct 14th, 2023: This tutorial has not yet been updated for the recent 0.7.0 release, it will be updated asap!** + +BitCraft Mini is a game that we developed which extends the code you've already developed in this tutorial. It is inspired by our game [BitCraft](https://bitcraftonline.com) and illustrates how you could build a more complex game from just the components we've discussed. Right now you can walk around, mine ore, and manage your inventory. + +## 1. Download + +You can git-clone BitCraftMini from here: + +```plaintext +git clone ssh://git@github.com/clockworklabs/BitCraftMini +``` + +Once you have downloaded BitCraftMini, you will need to compile the spacetime module. + +## 2. Compile the Spacetime Module + +In order to compile the BitCraftMini module, you will need to install cargo. You can install cargo from here: + +> https://www.rust-lang.org/tools/install + +Once you have cargo installed, you can compile and publish the module with these commands: + +```bash +cd BitCraftMini/Server +spacetime publish +``` + +`spacetime publish` will output an address where your module has been deployed to. You will want to copy/save this address because you will need it in step 3. Here is an example of what it should look like: + +```plaintext +$ spacetime publish +info: component 'rust-std' for target 'wasm32-unknown-unknown' is up to date + Finished release [optimized] target(s) in 0.03s +Publish finished successfully. +Created new database with address: c91c17ecdcea8a05302be2bad9dd59b3 +``` + +Optionally, you can specify a name when you publish the module: + +```bash +spacetime publish "unique-module-name" +``` + +Currently, all the named modules exist in the same namespace so if you get a message saying that database is not owned by you, it means that someone else has already published a module with that name. You can either choose a different name or you can use the address instead. If you specify a name when you publish, you can use that name in place of the autogenerated address in both the CLI and in the Unity client. + +In the BitCraftMini module we have a function called `initialize()`. This function should be called immediately after publishing the module to spacetimedb. This function is in charge of generating some initial settings that are required for the server to operate. You can call this function like so: + +```bash +spacetime call "" "initialize" "[]" +``` + +Here we are telling spacetime to invoke the `initialize()` function on our module "bitcraftmini". If the function had some arguments, we would json encode them and put them into the "[]". Since `initialize()` requires no parameters, we just leave it empty. + +After you have called `initialize()` on the spacetime module you shouldgenerate the client files: + +```bash +spacetime generate --out-dir ../Client/Assets/_Project/autogen --lang=cs +``` + +Here is some sample output: + +```plaintext +$ spacetime generate --out-dir ../Client/Assets/_Project/autogen --lang cs +info: component 'rust-std' for target 'wasm32-unknown-unknown' is up to date + Finished release [optimized] target(s) in 0.03s +compilation took 234.613518ms +Generate finished successfully. +``` + +If you've gotten this message then everything should be working properly so far. + +## 3. Replace address in BitCraftMiniGameManager + +The following settings are exposed in the `BitCraftMiniGameManager` inspector: Module Address, Host Name, and SSL Enabled. + +Open the Main scene in Unity and click on the `GameManager` object in the heirarchy. The inspector window will look like this: + +![GameManager-Inspector](/images/unity-tutorial/GameManager-Inspector.JPG) + +Update the module address with the address you got from the `spacetime publish` command. If you are using SpacetimeDB Cloud `testnet`, the host name should be `testnet.spacetimedb.com` and SSL Enabled should be checked. If you are running SpacetimeDB Standalone locally, the host name should be `localhost:3000` and SSL Enabled should be unchecked. For instructions on how to deploy to these environments, see the [Deployment Section](/docs/deploying/testnet) + +## 4. Play Mode + +You should now be able to enter play mode and walk around! You can mine some rocks, cut down some trees and if you connect more clients you can trade with other players. + +## 5. Editing the Module + +If you want to make further updates to the module, make sure to use this publish command instead: + +```bash +spacetime publish +``` + +Where `` is your own address. If you do this instead then you won't have to change the address inside of `BitCraftMiniGameManager.cs` + +When you change the server module you should also regenerate the client files as well: + +```bash +spacetime generate --out-dir ../Client/Assets/_Project/autogen --lang=cs +``` + +You may want to consider putting these 2 commands into a simple shell script to make the process a bit cleaner. diff --git a/nav.ts b/nav.ts index 6d5a304b..8f463ad7 100644 --- a/nav.ts +++ b/nav.ts @@ -25,16 +25,22 @@ function section(title: string): NavSection { const nav: Nav = { items: [ section("Intro"), - page("Overview", "index", "index.md"), + page("Overview", "index", "index.md"), // TODO(BREAKING): For consistency & clarity, 'index' slug should be renamed 'intro'? page("Getting Started", "getting-started", "getting-started.md"), section("Deploying"), page("Testnet", "deploying/testnet", "deploying/testnet.md"), - section("Unity Tutorial"), - page("Part 1 - Basic Multiplayer", "unity/part-1", "unity/part-1.md"), - page("Part 2 - Resources And Scheduling", "unity/part-2", "unity/part-2.md"), - page("Part 3 - BitCraft Mini", "unity/part-3", "unity/part-3.md"), + section("Unity Tutorial - Basic Multiplayer"), + page("Overview", "unity-tutorial", "unity/index.md"), + page("1 - Setup", "unity/part-1", "unity/part-1.md"), + page("2a - Server (Rust)", "unity/part-2a-rust", "unity/part-2a-rust.md"), + page("2b - Server (C#)", "unity/part-2b-c-sharp", "unity/part-2a-c-sharp.md"), + page("3 - Client", "unity/part-3", "unity/part-3.md"), + + section("Unity Tutorial - Advanced"), + page("4 - Resources And Scheduling", "unity/part-4", "unity/part-4.md"), + page("5 - BitCraft Mini", "unity/part-5", "unity/part-5.md"), section("Server Module Languages"), page("Overview", "modules", "modules/index.md"), From 4537457478327f97f2ddd689500e539d783401ab Mon Sep 17 00:00:00 2001 From: Puru Vijay <47742487+PuruVJ@users.noreply.github.com> Date: Wed, 1 May 2024 22:20:11 +0530 Subject: [PATCH 035/164] fix: Docs build, pnpm, vm evaluate (#46) * Push * prettierrc * Use cjs cuz current api require's it * Prettier override for md * fix 2b-c-sharp --- .prettierrc | 19 + build.js | 29 ++ docs/nav.js | 298 ++++++++++-- nav.ts | 163 ++++--- package.json | 29 +- pnpm-lock.yaml | 1261 ++++++++++++++++++++++++++++++++++++++++++++++++ yarn.lock | 8 - 7 files changed, 1674 insertions(+), 133 deletions(-) create mode 100644 .prettierrc create mode 100644 build.js create mode 100644 pnpm-lock.yaml delete mode 100644 yarn.lock diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..81d845ca --- /dev/null +++ b/.prettierrc @@ -0,0 +1,19 @@ +{ + "tabWidth": 4, + "useTabs": false, + "semi": true, + "singleQuote": true, + "arrowParens": "avoid", + "jsxSingleQuote": false, + "trailingComma": "none", + "endOfLine": "auto", + "printWidth": 80, + "overrides": [ + { + "files": "*.md", + "options": { + "tabWidth": 2 + } + } + ] +} diff --git a/build.js b/build.js new file mode 100644 index 00000000..5f7414c0 --- /dev/null +++ b/build.js @@ -0,0 +1,29 @@ +// @ts-check +import { build } from 'tsup'; +import { createContext, Script } from 'node:vm'; +import { readFile, writeFile, rm } from 'node:fs/promises'; +import { inspect } from 'node:util'; + +await build({ entry: { out: 'nav.ts' }, clean: true, format: 'esm' }); + +// Read the file +const nav = await readFile('dist/out.js', 'utf8'); + +// Remove this line +// export { +// nav +// }; +const final = nav.replace(/export {[^}]*};/, '') + '\nnav;'; + +// Execute the code +const context = createContext(); +const script = new Script(final); +const out = script.runInContext(context); + +await writeFile( + 'docs/nav.js', + 'module.exports = ' + + inspect(out, { depth: null, compact: false, breakLength: 120 }) +); + +await rm('dist/out.js', { recursive: true }); diff --git a/docs/nav.js b/docs/nav.js index cb8d22f1..c4346d75 100644 --- a/docs/nav.js +++ b/docs/nav.js @@ -1,52 +1,246 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -function page(title, slug, path, props) { - return { type: "page", path, slug, title, ...props }; -} -function section(title) { - return { type: "section", title }; -} -const nav = { - items: [ - section("Intro"), - page("Overview", "index", "index.md"), - page("Getting Started", "getting-started", "getting-started.md"), - section("Deploying"), - page("Testnet", "deploying/testnet", "deploying/testnet.md"), - section("Unity Tutorial"), - page("Part 1 - Basic Multiplayer", "unity/part-1", "unity/part-1.md"), - page("Part 2 - Resources And Scheduling", "unity/part-2", "unity/part-2.md"), - page("Part 3 - BitCraft Mini", "unity/part-3", "unity/part-3.md"), - section("Server Module Languages"), - page("Overview", "modules", "modules/index.md"), - page("Rust Quickstart", "modules/rust/quickstart", "modules/rust/quickstart.md"), - page("Rust Reference", "modules/rust", "modules/rust/index.md"), - page("C# Quickstart", "modules/c-sharp/quickstart", "modules/c-sharp/quickstart.md"), - page("C# Reference", "modules/c-sharp", "modules/c-sharp/index.md"), - section("Client SDK Languages"), - page("Overview", "sdks", "sdks/index.md"), - page("Typescript Quickstart", "sdks/typescript/quickstart", "sdks/typescript/quickstart.md"), - page("Typescript Reference", "sdks/typescript", "sdks/typescript/index.md"), - page("Rust Quickstart", "sdks/rust/quickstart", "sdks/rust/quickstart.md"), - page("Rust Reference", "sdks/rust", "sdks/rust/index.md"), - page("Python Quickstart", "sdks/python/quickstart", "sdks/python/quickstart.md"), - page("Python Reference", "sdks/python", "sdks/python/index.md"), - page("C# Quickstart", "sdks/c-sharp/quickstart", "sdks/c-sharp/quickstart.md"), - page("C# Reference", "sdks/c-sharp", "sdks/c-sharp/index.md"), - section("WebAssembly ABI"), - page("Module ABI Reference", "webassembly-abi", "webassembly-abi/index.md"), - section("HTTP API"), - page("HTTP", "http", "http/index.md"), - page("`/identity`", "http/identity", "http/identity.md"), - page("`/database`", "http/database", "http/database.md"), - page("`/energy`", "http/energy", "http/energy.md"), - section("WebSocket API Reference"), - page("WebSocket", "ws", "ws/index.md"), - section("Data Format"), - page("SATN", "satn", "satn.md"), - page("BSATN", "bsatn", "bsatn.md"), - section("SQL"), - page("SQL Reference", "sql", "sql/index.md"), - ], -}; -exports.default = nav; +module.exports = { + items: [ + { + type: 'section', + title: 'Intro' + }, + { + type: 'page', + path: 'index.md', + slug: 'index', + title: 'Overview' + }, + { + type: 'page', + path: 'getting-started.md', + slug: 'getting-started', + title: 'Getting Started' + }, + { + type: 'section', + title: 'Deploying' + }, + { + type: 'page', + path: 'deploying/testnet.md', + slug: 'deploying/testnet', + title: 'Testnet' + }, + { + type: 'section', + title: 'Unity Tutorial - Basic Multiplayer' + }, + { + type: 'page', + path: 'unity/index.md', + slug: 'unity-tutorial', + title: 'Overview' + }, + { + type: 'page', + path: 'unity/part-1.md', + slug: 'unity/part-1', + title: '1 - Setup' + }, + { + type: 'page', + path: 'unity/part-2a-rust.md', + slug: 'unity/part-2a-rust', + title: '2a - Server (Rust)' + }, + { + type: 'page', + path: 'unity/part-2b-c-sharp.md', + slug: 'unity/part-2b-c-sharp', + title: '2b - Server (C#)' + }, + { + type: 'page', + path: 'unity/part-3.md', + slug: 'unity/part-3', + title: '3 - Client' + }, + { + type: 'section', + title: 'Unity Tutorial - Advanced' + }, + { + type: 'page', + path: 'unity/part-4.md', + slug: 'unity/part-4', + title: '4 - Resources And Scheduling' + }, + { + type: 'page', + path: 'unity/part-5.md', + slug: 'unity/part-5', + title: '5 - BitCraft Mini' + }, + { + type: 'section', + title: 'Server Module Languages' + }, + { + type: 'page', + path: 'modules/index.md', + slug: 'modules', + title: 'Overview' + }, + { + type: 'page', + path: 'modules/rust/quickstart.md', + slug: 'modules/rust/quickstart', + title: 'Rust Quickstart' + }, + { + type: 'page', + path: 'modules/rust/index.md', + slug: 'modules/rust', + title: 'Rust Reference' + }, + { + type: 'page', + path: 'modules/c-sharp/quickstart.md', + slug: 'modules/c-sharp/quickstart', + title: 'C# Quickstart' + }, + { + type: 'page', + path: 'modules/c-sharp/index.md', + slug: 'modules/c-sharp', + title: 'C# Reference' + }, + { + type: 'section', + title: 'Client SDK Languages' + }, + { + type: 'page', + path: 'sdks/index.md', + slug: 'sdks', + title: 'Overview' + }, + { + type: 'page', + path: 'sdks/typescript/quickstart.md', + slug: 'sdks/typescript/quickstart', + title: 'Typescript Quickstart' + }, + { + type: 'page', + path: 'sdks/typescript/index.md', + slug: 'sdks/typescript', + title: 'Typescript Reference' + }, + { + type: 'page', + path: 'sdks/rust/quickstart.md', + slug: 'sdks/rust/quickstart', + title: 'Rust Quickstart' + }, + { + type: 'page', + path: 'sdks/rust/index.md', + slug: 'sdks/rust', + title: 'Rust Reference' + }, + { + type: 'page', + path: 'sdks/python/quickstart.md', + slug: 'sdks/python/quickstart', + title: 'Python Quickstart' + }, + { + type: 'page', + path: 'sdks/python/index.md', + slug: 'sdks/python', + title: 'Python Reference' + }, + { + type: 'page', + path: 'sdks/c-sharp/quickstart.md', + slug: 'sdks/c-sharp/quickstart', + title: 'C# Quickstart' + }, + { + type: 'page', + path: 'sdks/c-sharp/index.md', + slug: 'sdks/c-sharp', + title: 'C# Reference' + }, + { + type: 'section', + title: 'WebAssembly ABI' + }, + { + type: 'page', + path: 'webassembly-abi/index.md', + slug: 'webassembly-abi', + title: 'Module ABI Reference' + }, + { + type: 'section', + title: 'HTTP API' + }, + { + type: 'page', + path: 'http/index.md', + slug: 'http', + title: 'HTTP' + }, + { + type: 'page', + path: 'http/identity.md', + slug: 'http/identity', + title: '`/identity`' + }, + { + type: 'page', + path: 'http/database.md', + slug: 'http/database', + title: '`/database`' + }, + { + type: 'page', + path: 'http/energy.md', + slug: 'http/energy', + title: '`/energy`' + }, + { + type: 'section', + title: 'WebSocket API Reference' + }, + { + type: 'page', + path: 'ws/index.md', + slug: 'ws', + title: 'WebSocket' + }, + { + type: 'section', + title: 'Data Format' + }, + { + type: 'page', + path: 'satn.md', + slug: 'satn', + title: 'SATN' + }, + { + type: 'page', + path: 'bsatn.md', + slug: 'bsatn', + title: 'BSATN' + }, + { + type: 'section', + title: 'SQL' + }, + { + type: 'page', + path: 'sql/index.md', + slug: 'sql', + title: 'SQL Reference' + } + ] +} \ No newline at end of file diff --git a/nav.ts b/nav.ts index 8f463ad7..b6eea77a 100644 --- a/nav.ts +++ b/nav.ts @@ -1,84 +1,129 @@ type Nav = { - items: NavItem[]; + items: NavItem[]; }; type NavItem = NavPage | NavSection; type NavPage = { - type: "page"; - path: string; - slug: string; - title: string; - disabled?: boolean; - href?: string; + type: 'page'; + path: string; + slug: string; + title: string; + disabled?: boolean; + href?: string; }; type NavSection = { - type: "section"; - title: string; + type: 'section'; + title: string; }; -function page(title: string, slug: string, path: string, props?: { disabled?: boolean; href?: string; description?: string }): NavPage { - return { type: "page", path, slug, title, ...props }; +function page( + title: string, + slug: string, + path: string, + props?: { disabled?: boolean; href?: string; description?: string } +): NavPage { + return { type: 'page', path, slug, title, ...props }; } function section(title: string): NavSection { - return { type: "section", title }; + return { type: 'section', title }; } const nav: Nav = { - items: [ - section("Intro"), - page("Overview", "index", "index.md"), // TODO(BREAKING): For consistency & clarity, 'index' slug should be renamed 'intro'? - page("Getting Started", "getting-started", "getting-started.md"), + items: [ + section('Intro'), + page('Overview', 'index', 'index.md'), // TODO(BREAKING): For consistency & clarity, 'index' slug should be renamed 'intro'? + page('Getting Started', 'getting-started', 'getting-started.md'), - section("Deploying"), - page("Testnet", "deploying/testnet", "deploying/testnet.md"), + section('Deploying'), + page('Testnet', 'deploying/testnet', 'deploying/testnet.md'), - section("Unity Tutorial - Basic Multiplayer"), - page("Overview", "unity-tutorial", "unity/index.md"), - page("1 - Setup", "unity/part-1", "unity/part-1.md"), - page("2a - Server (Rust)", "unity/part-2a-rust", "unity/part-2a-rust.md"), - page("2b - Server (C#)", "unity/part-2b-c-sharp", "unity/part-2a-c-sharp.md"), - page("3 - Client", "unity/part-3", "unity/part-3.md"), + section('Unity Tutorial - Basic Multiplayer'), + page('Overview', 'unity-tutorial', 'unity/index.md'), + page('1 - Setup', 'unity/part-1', 'unity/part-1.md'), + page( + '2a - Server (Rust)', + 'unity/part-2a-rust', + 'unity/part-2a-rust.md' + ), + page( + '2b - Server (C#)', + 'unity/part-2b-c-sharp', + 'unity/part-2b-c-sharp.md' + ), + page('3 - Client', 'unity/part-3', 'unity/part-3.md'), - section("Unity Tutorial - Advanced"), - page("4 - Resources And Scheduling", "unity/part-4", "unity/part-4.md"), - page("5 - BitCraft Mini", "unity/part-5", "unity/part-5.md"), + section('Unity Tutorial - Advanced'), + page('4 - Resources And Scheduling', 'unity/part-4', 'unity/part-4.md'), + page('5 - BitCraft Mini', 'unity/part-5', 'unity/part-5.md'), - section("Server Module Languages"), - page("Overview", "modules", "modules/index.md"), - page("Rust Quickstart", "modules/rust/quickstart", "modules/rust/quickstart.md"), - page("Rust Reference", "modules/rust", "modules/rust/index.md"), - page("C# Quickstart", "modules/c-sharp/quickstart", "modules/c-sharp/quickstart.md"), - page("C# Reference", "modules/c-sharp", "modules/c-sharp/index.md"), + section('Server Module Languages'), + page('Overview', 'modules', 'modules/index.md'), + page( + 'Rust Quickstart', + 'modules/rust/quickstart', + 'modules/rust/quickstart.md' + ), + page('Rust Reference', 'modules/rust', 'modules/rust/index.md'), + page( + 'C# Quickstart', + 'modules/c-sharp/quickstart', + 'modules/c-sharp/quickstart.md' + ), + page('C# Reference', 'modules/c-sharp', 'modules/c-sharp/index.md'), - section("Client SDK Languages"), - page("Overview", "sdks", "sdks/index.md"), - page("Typescript Quickstart", "sdks/typescript/quickstart", "sdks/typescript/quickstart.md"), - page("Typescript Reference", "sdks/typescript", "sdks/typescript/index.md"), - page("Rust Quickstart", "sdks/rust/quickstart", "sdks/rust/quickstart.md"), - page("Rust Reference", "sdks/rust", "sdks/rust/index.md"), - page("Python Quickstart", "sdks/python/quickstart", "sdks/python/quickstart.md"), - page("Python Reference", "sdks/python", "sdks/python/index.md"), - page("C# Quickstart", "sdks/c-sharp/quickstart", "sdks/c-sharp/quickstart.md"), - page("C# Reference", "sdks/c-sharp", "sdks/c-sharp/index.md"), + section('Client SDK Languages'), + page('Overview', 'sdks', 'sdks/index.md'), + page( + 'Typescript Quickstart', + 'sdks/typescript/quickstart', + 'sdks/typescript/quickstart.md' + ), + page( + 'Typescript Reference', + 'sdks/typescript', + 'sdks/typescript/index.md' + ), + page( + 'Rust Quickstart', + 'sdks/rust/quickstart', + 'sdks/rust/quickstart.md' + ), + page('Rust Reference', 'sdks/rust', 'sdks/rust/index.md'), + page( + 'Python Quickstart', + 'sdks/python/quickstart', + 'sdks/python/quickstart.md' + ), + page('Python Reference', 'sdks/python', 'sdks/python/index.md'), + page( + 'C# Quickstart', + 'sdks/c-sharp/quickstart', + 'sdks/c-sharp/quickstart.md' + ), + page('C# Reference', 'sdks/c-sharp', 'sdks/c-sharp/index.md'), - section("WebAssembly ABI"), - page("Module ABI Reference", "webassembly-abi", "webassembly-abi/index.md"), + section('WebAssembly ABI'), + page( + 'Module ABI Reference', + 'webassembly-abi', + 'webassembly-abi/index.md' + ), - section("HTTP API"), - page("HTTP", "http", "http/index.md"), - page("`/identity`", "http/identity", "http/identity.md"), - page("`/database`", "http/database", "http/database.md"), - page("`/energy`", "http/energy", "http/energy.md"), + section('HTTP API'), + page('HTTP', 'http', 'http/index.md'), + page('`/identity`', 'http/identity', 'http/identity.md'), + page('`/database`', 'http/database', 'http/database.md'), + page('`/energy`', 'http/energy', 'http/energy.md'), - section("WebSocket API Reference"), - page("WebSocket", "ws", "ws/index.md"), + section('WebSocket API Reference'), + page('WebSocket', 'ws', 'ws/index.md'), - section("Data Format"), - page("SATN", "satn", "satn.md"), - page("BSATN", "bsatn", "bsatn.md"), + section('Data Format'), + page('SATN', 'satn', 'satn.md'), + page('BSATN', 'bsatn', 'bsatn.md'), - section("SQL"), - page("SQL Reference", "sql", "sql/index.md"), - ], + section('SQL'), + page('SQL Reference', 'sql', 'sql/index.md') + ] }; -export default nav; +export { nav }; diff --git a/package.json b/package.json index a56ea4e8..0a764ee6 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,16 @@ { - "name": "spacetime-docs", - "version": "1.0.0", - "description": "This repository contains the markdown files which are used to display documentation on our [website](https://spacetimedb.com/docs).", - "main": "index.js", - "dependencies": {}, - "devDependencies": { - "typescript": "^5.3.2" - }, - "scripts": { - "build": "tsc" - }, - "author": "Clockwork Labs", - "license": "ISC" -} \ No newline at end of file + "name": "spacetime-docs", + "version": "1.0.0", + "type": "module", + "description": "This repository contains the markdown files which are used to display documentation on our [website](https://spacetimedb.com/docs).", + "main": "index.js", + "dependencies": {}, + "devDependencies": { + "tsup": "^8.0.2" + }, + "scripts": { + "build": "node build.js" + }, + "author": "Clockwork Labs", + "license": "ISC" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 00000000..bec77ca8 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1261 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + tsup: + specifier: ^8.0.2 + version: 8.0.2 + +packages: + + '@esbuild/aix-ppc64@0.19.12': + resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.19.12': + resolution: {integrity: sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.19.12': + resolution: {integrity: sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.19.12': + resolution: {integrity: sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.19.12': + resolution: {integrity: sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.19.12': + resolution: {integrity: sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.19.12': + resolution: {integrity: sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.19.12': + resolution: {integrity: sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.19.12': + resolution: {integrity: sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.19.12': + resolution: {integrity: sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.19.12': + resolution: {integrity: sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.19.12': + resolution: {integrity: sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.19.12': + resolution: {integrity: sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.19.12': + resolution: {integrity: sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.19.12': + resolution: {integrity: sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.19.12': + resolution: {integrity: sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.19.12': + resolution: {integrity: sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.19.12': + resolution: {integrity: sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.19.12': + resolution: {integrity: sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.19.12': + resolution: {integrity: sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.19.12': + resolution: {integrity: sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.19.12': + resolution: {integrity: sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.19.12': + resolution: {integrity: sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@jridgewell/gen-mapping@0.3.5': + resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} + engines: {node: '>=6.0.0'} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/set-array@1.2.1': + resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.4.15': + resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==} + + '@jridgewell/trace-mapping@0.3.25': + resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@rollup/rollup-android-arm-eabi@4.17.2': + resolution: {integrity: sha512-NM0jFxY8bB8QLkoKxIQeObCaDlJKewVlIEkuyYKm5An1tdVZ966w2+MPQ2l8LBZLjR+SgyV+nRkTIunzOYBMLQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.17.2': + resolution: {integrity: sha512-yeX/Usk7daNIVwkq2uGoq2BYJKZY1JfyLTaHO/jaiSwi/lsf8fTFoQW/n6IdAsx5tx+iotu2zCJwz8MxI6D/Bw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.17.2': + resolution: {integrity: sha512-kcMLpE6uCwls023+kknm71ug7MZOrtXo+y5p/tsg6jltpDtgQY1Eq5sGfHcQfb+lfuKwhBmEURDga9N0ol4YPw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.17.2': + resolution: {integrity: sha512-AtKwD0VEx0zWkL0ZjixEkp5tbNLzX+FCqGG1SvOu993HnSz4qDI6S4kGzubrEJAljpVkhRSlg5bzpV//E6ysTQ==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-linux-arm-gnueabihf@4.17.2': + resolution: {integrity: sha512-3reX2fUHqN7sffBNqmEyMQVj/CKhIHZd4y631duy0hZqI8Qoqf6lTtmAKvJFYa6bhU95B1D0WgzHkmTg33In0A==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.17.2': + resolution: {integrity: sha512-uSqpsp91mheRgw96xtyAGP9FW5ChctTFEoXP0r5FAzj/3ZRv3Uxjtc7taRQSaQM/q85KEKjKsZuiZM3GyUivRg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.17.2': + resolution: {integrity: sha512-EMMPHkiCRtE8Wdk3Qhtciq6BndLtstqZIroHiiGzB3C5LDJmIZcSzVtLRbwuXuUft1Cnv+9fxuDtDxz3k3EW2A==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.17.2': + resolution: {integrity: sha512-NMPylUUZ1i0z/xJUIx6VUhISZDRT+uTWpBcjdv0/zkp7b/bQDF+NfnfdzuTiB1G6HTodgoFa93hp0O1xl+/UbA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-powerpc64le-gnu@4.17.2': + resolution: {integrity: sha512-T19My13y8uYXPw/L/k0JYaX1fJKFT/PWdXiHr8mTbXWxjVF1t+8Xl31DgBBvEKclw+1b00Chg0hxE2O7bTG7GQ==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.17.2': + resolution: {integrity: sha512-BOaNfthf3X3fOWAB+IJ9kxTgPmMqPPH5f5k2DcCsRrBIbWnaJCgX2ll77dV1TdSy9SaXTR5iDXRL8n7AnoP5cg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.17.2': + resolution: {integrity: sha512-W0UP/x7bnn3xN2eYMql2T/+wpASLE5SjObXILTMPUBDB/Fg/FxC+gX4nvCfPBCbNhz51C+HcqQp2qQ4u25ok6g==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.17.2': + resolution: {integrity: sha512-Hy7pLwByUOuyaFC6mAr7m+oMC+V7qyifzs/nW2OJfC8H4hbCzOX07Ov0VFk/zP3kBsELWNFi7rJtgbKYsav9QQ==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.17.2': + resolution: {integrity: sha512-h1+yTWeYbRdAyJ/jMiVw0l6fOOm/0D1vNLui9iPuqgRGnXA0u21gAqOyB5iHjlM9MMfNOm9RHCQ7zLIzT0x11Q==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-win32-arm64-msvc@4.17.2': + resolution: {integrity: sha512-tmdtXMfKAjy5+IQsVtDiCfqbynAQE/TQRpWdVataHmhMb9DCoJxp9vLcCBjEQWMiUYxO1QprH/HbY9ragCEFLA==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.17.2': + resolution: {integrity: sha512-7II/QCSTAHuE5vdZaQEwJq2ZACkBpQDOmQsE6D6XUbnBHW8IAhm4eTufL6msLJorzrHDFv3CF8oCA/hSIRuZeQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.17.2': + resolution: {integrity: sha512-TGGO7v7qOq4CYmSBVEYpI1Y5xDuCEnbVC5Vth8mOsW0gDSzxNrVERPc790IGHsrT2dQSimgMr9Ub3Y1Jci5/8w==} + cpu: [x64] + os: [win32] + + '@types/estree@1.0.5': + resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.0.1: + resolution: {integrity: sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.1: + resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} + engines: {node: '>=12'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + brace-expansion@2.0.1: + resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + + braces@3.0.2: + resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==} + engines: {node: '>=8'} + + bundle-require@4.0.3: + resolution: {integrity: sha512-2iscZ3fcthP2vka4Y7j277YJevwmsby/FpFDwjgw34Nl7dtCpt7zz/4TexmHMzY6KZEih7En9ImlbbgUNNQGtA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + peerDependencies: + esbuild: '>=0.17' + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + cross-spawn@7.0.3: + resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} + engines: {node: '>= 8'} + + debug@4.3.4: + resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + esbuild@0.19.12: + resolution: {integrity: sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==} + engines: {node: '>=12'} + hasBin: true + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + fast-glob@3.3.2: + resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} + engines: {node: '>=8.6.0'} + + fastq@1.17.1: + resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} + + fill-range@7.0.1: + resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} + engines: {node: '>=8'} + + foreground-child@3.1.1: + resolution: {integrity: sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==} + engines: {node: '>=14'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob@10.3.12: + resolution: {integrity: sha512-TCNv8vJ+xz4QiqTpfOJA7HvYv+tNIRHKfUWw/q+v2jdgN4ebz+KY9tGx5J4rHP0o84mNP+ApH66HRX8us3Khqg==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + ignore@5.3.1: + resolution: {integrity: sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==} + engines: {node: '>= 4'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jackspeak@2.3.6: + resolution: {integrity: sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==} + engines: {node: '>=14'} + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + lilconfig@3.1.1: + resolution: {integrity: sha512-O18pf7nyvHTckunPWCV1XUNXU1piu01y2b7ATJ0ppkUkk8ocqVWBrYjJBCwHDjD/ZWcfyrA0P4gKhzWGi5EINQ==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + lodash.sortby@4.7.0: + resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} + + lru-cache@10.2.2: + resolution: {integrity: sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==} + engines: {node: 14 || >=16.14} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.5: + resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} + engines: {node: '>=8.6'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + minimatch@9.0.4: + resolution: {integrity: sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@7.0.4: + resolution: {integrity: sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@1.10.2: + resolution: {integrity: sha512-7xTavNy5RQXnsjANvVvMkEjvloOinkAjv/Z6Ildz9v2RinZ4SBKTWFOVRbaF8p0vpHnyjV/UwNDdKuUv6M5qcA==} + engines: {node: '>=16 || 14 >=14.17'} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + pirates@4.0.6: + resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} + engines: {node: '>= 6'} + + postcss-load-config@4.0.2: + resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==} + engines: {node: '>= 14'} + peerDependencies: + postcss: '>=8.0.9' + ts-node: '>=9.0.0' + peerDependenciesMeta: + postcss: + optional: true + ts-node: + optional: true + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + reusify@1.0.4: + resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rollup@4.17.2: + resolution: {integrity: sha512-/9ClTJPByC0U4zNLowV1tMBe8yMEAxewtR3cUNX5BoEpGH3dQEWpJLr6CLp0fPdYRF/fzVOgvDb1zXuakwF5kQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + source-map@0.8.0-beta.0: + resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} + engines: {node: '>= 8'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.0: + resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} + engines: {node: '>=12'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + sucrase@3.35.0: + resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tr46@1.0.1: + resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tsup@8.0.2: + resolution: {integrity: sha512-NY8xtQXdH7hDUAZwcQdY/Vzlw9johQsaqf7iwZ6g1DOUlFYQ5/AtVAjTvihhEyeRlGo4dLRVHtrRaL35M1daqQ==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7.36.0 + '@swc/core': ^1 + postcss: ^8.4.12 + typescript: '>=4.5.0' + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@swc/core': + optional: true + postcss: + optional: true + typescript: + optional: true + + webidl-conversions@4.0.2: + resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} + + whatwg-url@7.1.0: + resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + yaml@2.4.2: + resolution: {integrity: sha512-B3VqDZ+JAg1nZpaEmWtTXUlBneoGx6CPM9b0TENK6aoSu5t73dItudwdgmi6tHlIZZId4dZ9skcAQ2UbcyAeVA==} + engines: {node: '>= 14'} + hasBin: true + +snapshots: + + '@esbuild/aix-ppc64@0.19.12': + optional: true + + '@esbuild/android-arm64@0.19.12': + optional: true + + '@esbuild/android-arm@0.19.12': + optional: true + + '@esbuild/android-x64@0.19.12': + optional: true + + '@esbuild/darwin-arm64@0.19.12': + optional: true + + '@esbuild/darwin-x64@0.19.12': + optional: true + + '@esbuild/freebsd-arm64@0.19.12': + optional: true + + '@esbuild/freebsd-x64@0.19.12': + optional: true + + '@esbuild/linux-arm64@0.19.12': + optional: true + + '@esbuild/linux-arm@0.19.12': + optional: true + + '@esbuild/linux-ia32@0.19.12': + optional: true + + '@esbuild/linux-loong64@0.19.12': + optional: true + + '@esbuild/linux-mips64el@0.19.12': + optional: true + + '@esbuild/linux-ppc64@0.19.12': + optional: true + + '@esbuild/linux-riscv64@0.19.12': + optional: true + + '@esbuild/linux-s390x@0.19.12': + optional: true + + '@esbuild/linux-x64@0.19.12': + optional: true + + '@esbuild/netbsd-x64@0.19.12': + optional: true + + '@esbuild/openbsd-x64@0.19.12': + optional: true + + '@esbuild/sunos-x64@0.19.12': + optional: true + + '@esbuild/win32-arm64@0.19.12': + optional: true + + '@esbuild/win32-ia32@0.19.12': + optional: true + + '@esbuild/win32-x64@0.19.12': + optional: true + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@jridgewell/gen-mapping@0.3.5': + dependencies: + '@jridgewell/set-array': 1.2.1 + '@jridgewell/sourcemap-codec': 1.4.15 + '@jridgewell/trace-mapping': 0.3.25 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/set-array@1.2.1': {} + + '@jridgewell/sourcemap-codec@1.4.15': {} + + '@jridgewell/trace-mapping@0.3.25': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.4.15 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.17.1 + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@rollup/rollup-android-arm-eabi@4.17.2': + optional: true + + '@rollup/rollup-android-arm64@4.17.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.17.2': + optional: true + + '@rollup/rollup-darwin-x64@4.17.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.17.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.17.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.17.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.17.2': + optional: true + + '@rollup/rollup-linux-powerpc64le-gnu@4.17.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.17.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.17.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.17.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.17.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.17.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.17.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.17.2': + optional: true + + '@types/estree@1.0.5': {} + + ansi-regex@5.0.1: {} + + ansi-regex@6.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.1: {} + + any-promise@1.3.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + array-union@2.1.0: {} + + balanced-match@1.0.2: {} + + binary-extensions@2.3.0: {} + + brace-expansion@2.0.1: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.2: + dependencies: + fill-range: 7.0.1 + + bundle-require@4.0.3(esbuild@0.19.12): + dependencies: + esbuild: 0.19.12 + load-tsconfig: 0.2.5 + + cac@6.7.14: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.2 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + commander@4.1.1: {} + + cross-spawn@7.0.3: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.3.4: + dependencies: + ms: 2.1.2 + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + eastasianwidth@0.2.0: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + esbuild@0.19.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.19.12 + '@esbuild/android-arm': 0.19.12 + '@esbuild/android-arm64': 0.19.12 + '@esbuild/android-x64': 0.19.12 + '@esbuild/darwin-arm64': 0.19.12 + '@esbuild/darwin-x64': 0.19.12 + '@esbuild/freebsd-arm64': 0.19.12 + '@esbuild/freebsd-x64': 0.19.12 + '@esbuild/linux-arm': 0.19.12 + '@esbuild/linux-arm64': 0.19.12 + '@esbuild/linux-ia32': 0.19.12 + '@esbuild/linux-loong64': 0.19.12 + '@esbuild/linux-mips64el': 0.19.12 + '@esbuild/linux-ppc64': 0.19.12 + '@esbuild/linux-riscv64': 0.19.12 + '@esbuild/linux-s390x': 0.19.12 + '@esbuild/linux-x64': 0.19.12 + '@esbuild/netbsd-x64': 0.19.12 + '@esbuild/openbsd-x64': 0.19.12 + '@esbuild/sunos-x64': 0.19.12 + '@esbuild/win32-arm64': 0.19.12 + '@esbuild/win32-ia32': 0.19.12 + '@esbuild/win32-x64': 0.19.12 + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.3 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + fast-glob@3.3.2: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.5 + + fastq@1.17.1: + dependencies: + reusify: 1.0.4 + + fill-range@7.0.1: + dependencies: + to-regex-range: 5.0.1 + + foreground-child@3.1.1: + dependencies: + cross-spawn: 7.0.3 + signal-exit: 4.1.0 + + fsevents@2.3.3: + optional: true + + get-stream@6.0.1: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob@10.3.12: + dependencies: + foreground-child: 3.1.1 + jackspeak: 2.3.6 + minimatch: 9.0.4 + minipass: 7.0.4 + path-scurry: 1.10.2 + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.2 + ignore: 5.3.1 + merge2: 1.4.1 + slash: 3.0.0 + + human-signals@2.1.0: {} + + ignore@5.3.1: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-stream@2.0.1: {} + + isexe@2.0.0: {} + + jackspeak@2.3.6: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + joycon@3.1.1: {} + + lilconfig@3.1.1: {} + + lines-and-columns@1.2.4: {} + + load-tsconfig@0.2.5: {} + + lodash.sortby@4.7.0: {} + + lru-cache@10.2.2: {} + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.5: + dependencies: + braces: 3.0.2 + picomatch: 2.3.1 + + mimic-fn@2.1.0: {} + + minimatch@9.0.4: + dependencies: + brace-expansion: 2.0.1 + + minipass@7.0.4: {} + + ms@2.1.2: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + normalize-path@3.0.0: {} + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + object-assign@4.1.1: {} + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + path-key@3.1.1: {} + + path-scurry@1.10.2: + dependencies: + lru-cache: 10.2.2 + minipass: 7.0.4 + + path-type@4.0.0: {} + + picomatch@2.3.1: {} + + pirates@4.0.6: {} + + postcss-load-config@4.0.2: + dependencies: + lilconfig: 3.1.1 + yaml: 2.4.2 + + punycode@2.3.1: {} + + queue-microtask@1.2.3: {} + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + + resolve-from@5.0.0: {} + + reusify@1.0.4: {} + + rollup@4.17.2: + dependencies: + '@types/estree': 1.0.5 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.17.2 + '@rollup/rollup-android-arm64': 4.17.2 + '@rollup/rollup-darwin-arm64': 4.17.2 + '@rollup/rollup-darwin-x64': 4.17.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.17.2 + '@rollup/rollup-linux-arm-musleabihf': 4.17.2 + '@rollup/rollup-linux-arm64-gnu': 4.17.2 + '@rollup/rollup-linux-arm64-musl': 4.17.2 + '@rollup/rollup-linux-powerpc64le-gnu': 4.17.2 + '@rollup/rollup-linux-riscv64-gnu': 4.17.2 + '@rollup/rollup-linux-s390x-gnu': 4.17.2 + '@rollup/rollup-linux-x64-gnu': 4.17.2 + '@rollup/rollup-linux-x64-musl': 4.17.2 + '@rollup/rollup-win32-arm64-msvc': 4.17.2 + '@rollup/rollup-win32-ia32-msvc': 4.17.2 + '@rollup/rollup-win32-x64-msvc': 4.17.2 + fsevents: 2.3.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + slash@3.0.0: {} + + source-map@0.8.0-beta.0: + dependencies: + whatwg-url: 7.1.0 + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.0: + dependencies: + ansi-regex: 6.0.1 + + strip-final-newline@2.0.0: {} + + sucrase@3.35.0: + dependencies: + '@jridgewell/gen-mapping': 0.3.5 + commander: 4.1.1 + glob: 10.3.12 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.6 + ts-interface-checker: 0.1.13 + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tr46@1.0.1: + dependencies: + punycode: 2.3.1 + + tree-kill@1.2.2: {} + + ts-interface-checker@0.1.13: {} + + tsup@8.0.2: + dependencies: + bundle-require: 4.0.3(esbuild@0.19.12) + cac: 6.7.14 + chokidar: 3.6.0 + debug: 4.3.4 + esbuild: 0.19.12 + execa: 5.1.1 + globby: 11.1.0 + joycon: 3.1.1 + postcss-load-config: 4.0.2 + resolve-from: 5.0.0 + rollup: 4.17.2 + source-map: 0.8.0-beta.0 + sucrase: 3.35.0 + tree-kill: 1.2.2 + transitivePeerDependencies: + - supports-color + - ts-node + + webidl-conversions@4.0.2: {} + + whatwg-url@7.1.0: + dependencies: + lodash.sortby: 4.7.0 + tr46: 1.0.1 + webidl-conversions: 4.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 5.1.2 + strip-ansi: 7.1.0 + + yaml@2.4.2: {} diff --git a/yarn.lock b/yarn.lock deleted file mode 100644 index fce89544..00000000 --- a/yarn.lock +++ /dev/null @@ -1,8 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - -typescript@^5.3.2: - version "5.3.2" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.2.tgz#00d1c7c1c46928c5845c1ee8d0cc2791031d4c43" - integrity sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ== From 222b4d04de48d99748e7d1550aec7a61776b6414 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Wed, 1 May 2024 23:57:58 -0400 Subject: [PATCH 036/164] Revert "fix: Docs build, pnpm, vm evaluate (#46)" (#48) This reverts commit 4537457478327f97f2ddd689500e539d783401ab. --- .prettierrc | 19 - build.js | 29 -- docs/nav.js | 298 ++---------- nav.ts | 163 +++---- package.json | 29 +- pnpm-lock.yaml | 1261 ------------------------------------------------ yarn.lock | 8 + 7 files changed, 133 insertions(+), 1674 deletions(-) delete mode 100644 .prettierrc delete mode 100644 build.js delete mode 100644 pnpm-lock.yaml create mode 100644 yarn.lock diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 81d845ca..00000000 --- a/.prettierrc +++ /dev/null @@ -1,19 +0,0 @@ -{ - "tabWidth": 4, - "useTabs": false, - "semi": true, - "singleQuote": true, - "arrowParens": "avoid", - "jsxSingleQuote": false, - "trailingComma": "none", - "endOfLine": "auto", - "printWidth": 80, - "overrides": [ - { - "files": "*.md", - "options": { - "tabWidth": 2 - } - } - ] -} diff --git a/build.js b/build.js deleted file mode 100644 index 5f7414c0..00000000 --- a/build.js +++ /dev/null @@ -1,29 +0,0 @@ -// @ts-check -import { build } from 'tsup'; -import { createContext, Script } from 'node:vm'; -import { readFile, writeFile, rm } from 'node:fs/promises'; -import { inspect } from 'node:util'; - -await build({ entry: { out: 'nav.ts' }, clean: true, format: 'esm' }); - -// Read the file -const nav = await readFile('dist/out.js', 'utf8'); - -// Remove this line -// export { -// nav -// }; -const final = nav.replace(/export {[^}]*};/, '') + '\nnav;'; - -// Execute the code -const context = createContext(); -const script = new Script(final); -const out = script.runInContext(context); - -await writeFile( - 'docs/nav.js', - 'module.exports = ' + - inspect(out, { depth: null, compact: false, breakLength: 120 }) -); - -await rm('dist/out.js', { recursive: true }); diff --git a/docs/nav.js b/docs/nav.js index c4346d75..cb8d22f1 100644 --- a/docs/nav.js +++ b/docs/nav.js @@ -1,246 +1,52 @@ -module.exports = { - items: [ - { - type: 'section', - title: 'Intro' - }, - { - type: 'page', - path: 'index.md', - slug: 'index', - title: 'Overview' - }, - { - type: 'page', - path: 'getting-started.md', - slug: 'getting-started', - title: 'Getting Started' - }, - { - type: 'section', - title: 'Deploying' - }, - { - type: 'page', - path: 'deploying/testnet.md', - slug: 'deploying/testnet', - title: 'Testnet' - }, - { - type: 'section', - title: 'Unity Tutorial - Basic Multiplayer' - }, - { - type: 'page', - path: 'unity/index.md', - slug: 'unity-tutorial', - title: 'Overview' - }, - { - type: 'page', - path: 'unity/part-1.md', - slug: 'unity/part-1', - title: '1 - Setup' - }, - { - type: 'page', - path: 'unity/part-2a-rust.md', - slug: 'unity/part-2a-rust', - title: '2a - Server (Rust)' - }, - { - type: 'page', - path: 'unity/part-2b-c-sharp.md', - slug: 'unity/part-2b-c-sharp', - title: '2b - Server (C#)' - }, - { - type: 'page', - path: 'unity/part-3.md', - slug: 'unity/part-3', - title: '3 - Client' - }, - { - type: 'section', - title: 'Unity Tutorial - Advanced' - }, - { - type: 'page', - path: 'unity/part-4.md', - slug: 'unity/part-4', - title: '4 - Resources And Scheduling' - }, - { - type: 'page', - path: 'unity/part-5.md', - slug: 'unity/part-5', - title: '5 - BitCraft Mini' - }, - { - type: 'section', - title: 'Server Module Languages' - }, - { - type: 'page', - path: 'modules/index.md', - slug: 'modules', - title: 'Overview' - }, - { - type: 'page', - path: 'modules/rust/quickstart.md', - slug: 'modules/rust/quickstart', - title: 'Rust Quickstart' - }, - { - type: 'page', - path: 'modules/rust/index.md', - slug: 'modules/rust', - title: 'Rust Reference' - }, - { - type: 'page', - path: 'modules/c-sharp/quickstart.md', - slug: 'modules/c-sharp/quickstart', - title: 'C# Quickstart' - }, - { - type: 'page', - path: 'modules/c-sharp/index.md', - slug: 'modules/c-sharp', - title: 'C# Reference' - }, - { - type: 'section', - title: 'Client SDK Languages' - }, - { - type: 'page', - path: 'sdks/index.md', - slug: 'sdks', - title: 'Overview' - }, - { - type: 'page', - path: 'sdks/typescript/quickstart.md', - slug: 'sdks/typescript/quickstart', - title: 'Typescript Quickstart' - }, - { - type: 'page', - path: 'sdks/typescript/index.md', - slug: 'sdks/typescript', - title: 'Typescript Reference' - }, - { - type: 'page', - path: 'sdks/rust/quickstart.md', - slug: 'sdks/rust/quickstart', - title: 'Rust Quickstart' - }, - { - type: 'page', - path: 'sdks/rust/index.md', - slug: 'sdks/rust', - title: 'Rust Reference' - }, - { - type: 'page', - path: 'sdks/python/quickstart.md', - slug: 'sdks/python/quickstart', - title: 'Python Quickstart' - }, - { - type: 'page', - path: 'sdks/python/index.md', - slug: 'sdks/python', - title: 'Python Reference' - }, - { - type: 'page', - path: 'sdks/c-sharp/quickstart.md', - slug: 'sdks/c-sharp/quickstart', - title: 'C# Quickstart' - }, - { - type: 'page', - path: 'sdks/c-sharp/index.md', - slug: 'sdks/c-sharp', - title: 'C# Reference' - }, - { - type: 'section', - title: 'WebAssembly ABI' - }, - { - type: 'page', - path: 'webassembly-abi/index.md', - slug: 'webassembly-abi', - title: 'Module ABI Reference' - }, - { - type: 'section', - title: 'HTTP API' - }, - { - type: 'page', - path: 'http/index.md', - slug: 'http', - title: 'HTTP' - }, - { - type: 'page', - path: 'http/identity.md', - slug: 'http/identity', - title: '`/identity`' - }, - { - type: 'page', - path: 'http/database.md', - slug: 'http/database', - title: '`/database`' - }, - { - type: 'page', - path: 'http/energy.md', - slug: 'http/energy', - title: '`/energy`' - }, - { - type: 'section', - title: 'WebSocket API Reference' - }, - { - type: 'page', - path: 'ws/index.md', - slug: 'ws', - title: 'WebSocket' - }, - { - type: 'section', - title: 'Data Format' - }, - { - type: 'page', - path: 'satn.md', - slug: 'satn', - title: 'SATN' - }, - { - type: 'page', - path: 'bsatn.md', - slug: 'bsatn', - title: 'BSATN' - }, - { - type: 'section', - title: 'SQL' - }, - { - type: 'page', - path: 'sql/index.md', - slug: 'sql', - title: 'SQL Reference' - } - ] -} \ No newline at end of file +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +function page(title, slug, path, props) { + return { type: "page", path, slug, title, ...props }; +} +function section(title) { + return { type: "section", title }; +} +const nav = { + items: [ + section("Intro"), + page("Overview", "index", "index.md"), + page("Getting Started", "getting-started", "getting-started.md"), + section("Deploying"), + page("Testnet", "deploying/testnet", "deploying/testnet.md"), + section("Unity Tutorial"), + page("Part 1 - Basic Multiplayer", "unity/part-1", "unity/part-1.md"), + page("Part 2 - Resources And Scheduling", "unity/part-2", "unity/part-2.md"), + page("Part 3 - BitCraft Mini", "unity/part-3", "unity/part-3.md"), + section("Server Module Languages"), + page("Overview", "modules", "modules/index.md"), + page("Rust Quickstart", "modules/rust/quickstart", "modules/rust/quickstart.md"), + page("Rust Reference", "modules/rust", "modules/rust/index.md"), + page("C# Quickstart", "modules/c-sharp/quickstart", "modules/c-sharp/quickstart.md"), + page("C# Reference", "modules/c-sharp", "modules/c-sharp/index.md"), + section("Client SDK Languages"), + page("Overview", "sdks", "sdks/index.md"), + page("Typescript Quickstart", "sdks/typescript/quickstart", "sdks/typescript/quickstart.md"), + page("Typescript Reference", "sdks/typescript", "sdks/typescript/index.md"), + page("Rust Quickstart", "sdks/rust/quickstart", "sdks/rust/quickstart.md"), + page("Rust Reference", "sdks/rust", "sdks/rust/index.md"), + page("Python Quickstart", "sdks/python/quickstart", "sdks/python/quickstart.md"), + page("Python Reference", "sdks/python", "sdks/python/index.md"), + page("C# Quickstart", "sdks/c-sharp/quickstart", "sdks/c-sharp/quickstart.md"), + page("C# Reference", "sdks/c-sharp", "sdks/c-sharp/index.md"), + section("WebAssembly ABI"), + page("Module ABI Reference", "webassembly-abi", "webassembly-abi/index.md"), + section("HTTP API"), + page("HTTP", "http", "http/index.md"), + page("`/identity`", "http/identity", "http/identity.md"), + page("`/database`", "http/database", "http/database.md"), + page("`/energy`", "http/energy", "http/energy.md"), + section("WebSocket API Reference"), + page("WebSocket", "ws", "ws/index.md"), + section("Data Format"), + page("SATN", "satn", "satn.md"), + page("BSATN", "bsatn", "bsatn.md"), + section("SQL"), + page("SQL Reference", "sql", "sql/index.md"), + ], +}; +exports.default = nav; diff --git a/nav.ts b/nav.ts index b6eea77a..8f463ad7 100644 --- a/nav.ts +++ b/nav.ts @@ -1,129 +1,84 @@ type Nav = { - items: NavItem[]; + items: NavItem[]; }; type NavItem = NavPage | NavSection; type NavPage = { - type: 'page'; - path: string; - slug: string; - title: string; - disabled?: boolean; - href?: string; + type: "page"; + path: string; + slug: string; + title: string; + disabled?: boolean; + href?: string; }; type NavSection = { - type: 'section'; - title: string; + type: "section"; + title: string; }; -function page( - title: string, - slug: string, - path: string, - props?: { disabled?: boolean; href?: string; description?: string } -): NavPage { - return { type: 'page', path, slug, title, ...props }; +function page(title: string, slug: string, path: string, props?: { disabled?: boolean; href?: string; description?: string }): NavPage { + return { type: "page", path, slug, title, ...props }; } function section(title: string): NavSection { - return { type: 'section', title }; + return { type: "section", title }; } const nav: Nav = { - items: [ - section('Intro'), - page('Overview', 'index', 'index.md'), // TODO(BREAKING): For consistency & clarity, 'index' slug should be renamed 'intro'? - page('Getting Started', 'getting-started', 'getting-started.md'), + items: [ + section("Intro"), + page("Overview", "index", "index.md"), // TODO(BREAKING): For consistency & clarity, 'index' slug should be renamed 'intro'? + page("Getting Started", "getting-started", "getting-started.md"), - section('Deploying'), - page('Testnet', 'deploying/testnet', 'deploying/testnet.md'), + section("Deploying"), + page("Testnet", "deploying/testnet", "deploying/testnet.md"), - section('Unity Tutorial - Basic Multiplayer'), - page('Overview', 'unity-tutorial', 'unity/index.md'), - page('1 - Setup', 'unity/part-1', 'unity/part-1.md'), - page( - '2a - Server (Rust)', - 'unity/part-2a-rust', - 'unity/part-2a-rust.md' - ), - page( - '2b - Server (C#)', - 'unity/part-2b-c-sharp', - 'unity/part-2b-c-sharp.md' - ), - page('3 - Client', 'unity/part-3', 'unity/part-3.md'), + section("Unity Tutorial - Basic Multiplayer"), + page("Overview", "unity-tutorial", "unity/index.md"), + page("1 - Setup", "unity/part-1", "unity/part-1.md"), + page("2a - Server (Rust)", "unity/part-2a-rust", "unity/part-2a-rust.md"), + page("2b - Server (C#)", "unity/part-2b-c-sharp", "unity/part-2a-c-sharp.md"), + page("3 - Client", "unity/part-3", "unity/part-3.md"), - section('Unity Tutorial - Advanced'), - page('4 - Resources And Scheduling', 'unity/part-4', 'unity/part-4.md'), - page('5 - BitCraft Mini', 'unity/part-5', 'unity/part-5.md'), + section("Unity Tutorial - Advanced"), + page("4 - Resources And Scheduling", "unity/part-4", "unity/part-4.md"), + page("5 - BitCraft Mini", "unity/part-5", "unity/part-5.md"), - section('Server Module Languages'), - page('Overview', 'modules', 'modules/index.md'), - page( - 'Rust Quickstart', - 'modules/rust/quickstart', - 'modules/rust/quickstart.md' - ), - page('Rust Reference', 'modules/rust', 'modules/rust/index.md'), - page( - 'C# Quickstart', - 'modules/c-sharp/quickstart', - 'modules/c-sharp/quickstart.md' - ), - page('C# Reference', 'modules/c-sharp', 'modules/c-sharp/index.md'), + section("Server Module Languages"), + page("Overview", "modules", "modules/index.md"), + page("Rust Quickstart", "modules/rust/quickstart", "modules/rust/quickstart.md"), + page("Rust Reference", "modules/rust", "modules/rust/index.md"), + page("C# Quickstart", "modules/c-sharp/quickstart", "modules/c-sharp/quickstart.md"), + page("C# Reference", "modules/c-sharp", "modules/c-sharp/index.md"), - section('Client SDK Languages'), - page('Overview', 'sdks', 'sdks/index.md'), - page( - 'Typescript Quickstart', - 'sdks/typescript/quickstart', - 'sdks/typescript/quickstart.md' - ), - page( - 'Typescript Reference', - 'sdks/typescript', - 'sdks/typescript/index.md' - ), - page( - 'Rust Quickstart', - 'sdks/rust/quickstart', - 'sdks/rust/quickstart.md' - ), - page('Rust Reference', 'sdks/rust', 'sdks/rust/index.md'), - page( - 'Python Quickstart', - 'sdks/python/quickstart', - 'sdks/python/quickstart.md' - ), - page('Python Reference', 'sdks/python', 'sdks/python/index.md'), - page( - 'C# Quickstart', - 'sdks/c-sharp/quickstart', - 'sdks/c-sharp/quickstart.md' - ), - page('C# Reference', 'sdks/c-sharp', 'sdks/c-sharp/index.md'), + section("Client SDK Languages"), + page("Overview", "sdks", "sdks/index.md"), + page("Typescript Quickstart", "sdks/typescript/quickstart", "sdks/typescript/quickstart.md"), + page("Typescript Reference", "sdks/typescript", "sdks/typescript/index.md"), + page("Rust Quickstart", "sdks/rust/quickstart", "sdks/rust/quickstart.md"), + page("Rust Reference", "sdks/rust", "sdks/rust/index.md"), + page("Python Quickstart", "sdks/python/quickstart", "sdks/python/quickstart.md"), + page("Python Reference", "sdks/python", "sdks/python/index.md"), + page("C# Quickstart", "sdks/c-sharp/quickstart", "sdks/c-sharp/quickstart.md"), + page("C# Reference", "sdks/c-sharp", "sdks/c-sharp/index.md"), - section('WebAssembly ABI'), - page( - 'Module ABI Reference', - 'webassembly-abi', - 'webassembly-abi/index.md' - ), + section("WebAssembly ABI"), + page("Module ABI Reference", "webassembly-abi", "webassembly-abi/index.md"), - section('HTTP API'), - page('HTTP', 'http', 'http/index.md'), - page('`/identity`', 'http/identity', 'http/identity.md'), - page('`/database`', 'http/database', 'http/database.md'), - page('`/energy`', 'http/energy', 'http/energy.md'), + section("HTTP API"), + page("HTTP", "http", "http/index.md"), + page("`/identity`", "http/identity", "http/identity.md"), + page("`/database`", "http/database", "http/database.md"), + page("`/energy`", "http/energy", "http/energy.md"), - section('WebSocket API Reference'), - page('WebSocket', 'ws', 'ws/index.md'), + section("WebSocket API Reference"), + page("WebSocket", "ws", "ws/index.md"), - section('Data Format'), - page('SATN', 'satn', 'satn.md'), - page('BSATN', 'bsatn', 'bsatn.md'), + section("Data Format"), + page("SATN", "satn", "satn.md"), + page("BSATN", "bsatn", "bsatn.md"), - section('SQL'), - page('SQL Reference', 'sql', 'sql/index.md') - ] + section("SQL"), + page("SQL Reference", "sql", "sql/index.md"), + ], }; -export { nav }; +export default nav; diff --git a/package.json b/package.json index 0a764ee6..a56ea4e8 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,15 @@ { - "name": "spacetime-docs", - "version": "1.0.0", - "type": "module", - "description": "This repository contains the markdown files which are used to display documentation on our [website](https://spacetimedb.com/docs).", - "main": "index.js", - "dependencies": {}, - "devDependencies": { - "tsup": "^8.0.2" - }, - "scripts": { - "build": "node build.js" - }, - "author": "Clockwork Labs", - "license": "ISC" -} + "name": "spacetime-docs", + "version": "1.0.0", + "description": "This repository contains the markdown files which are used to display documentation on our [website](https://spacetimedb.com/docs).", + "main": "index.js", + "dependencies": {}, + "devDependencies": { + "typescript": "^5.3.2" + }, + "scripts": { + "build": "tsc" + }, + "author": "Clockwork Labs", + "license": "ISC" +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml deleted file mode 100644 index bec77ca8..00000000 --- a/pnpm-lock.yaml +++ /dev/null @@ -1,1261 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - devDependencies: - tsup: - specifier: ^8.0.2 - version: 8.0.2 - -packages: - - '@esbuild/aix-ppc64@0.19.12': - resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.19.12': - resolution: {integrity: sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==} - engines: {node: '>=12'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.19.12': - resolution: {integrity: sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==} - engines: {node: '>=12'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.19.12': - resolution: {integrity: sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==} - engines: {node: '>=12'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.19.12': - resolution: {integrity: sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==} - engines: {node: '>=12'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.19.12': - resolution: {integrity: sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==} - engines: {node: '>=12'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.19.12': - resolution: {integrity: sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==} - engines: {node: '>=12'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.19.12': - resolution: {integrity: sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==} - engines: {node: '>=12'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.19.12': - resolution: {integrity: sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==} - engines: {node: '>=12'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.19.12': - resolution: {integrity: sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==} - engines: {node: '>=12'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.19.12': - resolution: {integrity: sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==} - engines: {node: '>=12'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.19.12': - resolution: {integrity: sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==} - engines: {node: '>=12'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.19.12': - resolution: {integrity: sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==} - engines: {node: '>=12'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.19.12': - resolution: {integrity: sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.19.12': - resolution: {integrity: sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==} - engines: {node: '>=12'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.19.12': - resolution: {integrity: sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==} - engines: {node: '>=12'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.19.12': - resolution: {integrity: sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==} - engines: {node: '>=12'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-x64@0.19.12': - resolution: {integrity: sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==} - engines: {node: '>=12'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-x64@0.19.12': - resolution: {integrity: sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==} - engines: {node: '>=12'} - cpu: [x64] - os: [openbsd] - - '@esbuild/sunos-x64@0.19.12': - resolution: {integrity: sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==} - engines: {node: '>=12'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.19.12': - resolution: {integrity: sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==} - engines: {node: '>=12'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.19.12': - resolution: {integrity: sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==} - engines: {node: '>=12'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.19.12': - resolution: {integrity: sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==} - engines: {node: '>=12'} - cpu: [x64] - os: [win32] - - '@isaacs/cliui@8.0.2': - resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} - engines: {node: '>=12'} - - '@jridgewell/gen-mapping@0.3.5': - resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} - engines: {node: '>=6.0.0'} - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - - '@jridgewell/set-array@1.2.1': - resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.4.15': - resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==} - - '@jridgewell/trace-mapping@0.3.25': - resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} - - '@nodelib/fs.scandir@2.1.5': - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} - - '@nodelib/fs.stat@2.0.5': - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} - - '@nodelib/fs.walk@1.2.8': - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} - - '@pkgjs/parseargs@0.11.0': - resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} - engines: {node: '>=14'} - - '@rollup/rollup-android-arm-eabi@4.17.2': - resolution: {integrity: sha512-NM0jFxY8bB8QLkoKxIQeObCaDlJKewVlIEkuyYKm5An1tdVZ966w2+MPQ2l8LBZLjR+SgyV+nRkTIunzOYBMLQ==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.17.2': - resolution: {integrity: sha512-yeX/Usk7daNIVwkq2uGoq2BYJKZY1JfyLTaHO/jaiSwi/lsf8fTFoQW/n6IdAsx5tx+iotu2zCJwz8MxI6D/Bw==} - cpu: [arm64] - os: [android] - - '@rollup/rollup-darwin-arm64@4.17.2': - resolution: {integrity: sha512-kcMLpE6uCwls023+kknm71ug7MZOrtXo+y5p/tsg6jltpDtgQY1Eq5sGfHcQfb+lfuKwhBmEURDga9N0ol4YPw==} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.17.2': - resolution: {integrity: sha512-AtKwD0VEx0zWkL0ZjixEkp5tbNLzX+FCqGG1SvOu993HnSz4qDI6S4kGzubrEJAljpVkhRSlg5bzpV//E6ysTQ==} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-linux-arm-gnueabihf@4.17.2': - resolution: {integrity: sha512-3reX2fUHqN7sffBNqmEyMQVj/CKhIHZd4y631duy0hZqI8Qoqf6lTtmAKvJFYa6bhU95B1D0WgzHkmTg33In0A==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm-musleabihf@4.17.2': - resolution: {integrity: sha512-uSqpsp91mheRgw96xtyAGP9FW5ChctTFEoXP0r5FAzj/3ZRv3Uxjtc7taRQSaQM/q85KEKjKsZuiZM3GyUivRg==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm64-gnu@4.17.2': - resolution: {integrity: sha512-EMMPHkiCRtE8Wdk3Qhtciq6BndLtstqZIroHiiGzB3C5LDJmIZcSzVtLRbwuXuUft1Cnv+9fxuDtDxz3k3EW2A==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-arm64-musl@4.17.2': - resolution: {integrity: sha512-NMPylUUZ1i0z/xJUIx6VUhISZDRT+uTWpBcjdv0/zkp7b/bQDF+NfnfdzuTiB1G6HTodgoFa93hp0O1xl+/UbA==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-powerpc64le-gnu@4.17.2': - resolution: {integrity: sha512-T19My13y8uYXPw/L/k0JYaX1fJKFT/PWdXiHr8mTbXWxjVF1t+8Xl31DgBBvEKclw+1b00Chg0hxE2O7bTG7GQ==} - cpu: [ppc64] - os: [linux] - - '@rollup/rollup-linux-riscv64-gnu@4.17.2': - resolution: {integrity: sha512-BOaNfthf3X3fOWAB+IJ9kxTgPmMqPPH5f5k2DcCsRrBIbWnaJCgX2ll77dV1TdSy9SaXTR5iDXRL8n7AnoP5cg==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-s390x-gnu@4.17.2': - resolution: {integrity: sha512-W0UP/x7bnn3xN2eYMql2T/+wpASLE5SjObXILTMPUBDB/Fg/FxC+gX4nvCfPBCbNhz51C+HcqQp2qQ4u25ok6g==} - cpu: [s390x] - os: [linux] - - '@rollup/rollup-linux-x64-gnu@4.17.2': - resolution: {integrity: sha512-Hy7pLwByUOuyaFC6mAr7m+oMC+V7qyifzs/nW2OJfC8H4hbCzOX07Ov0VFk/zP3kBsELWNFi7rJtgbKYsav9QQ==} - cpu: [x64] - os: [linux] - - '@rollup/rollup-linux-x64-musl@4.17.2': - resolution: {integrity: sha512-h1+yTWeYbRdAyJ/jMiVw0l6fOOm/0D1vNLui9iPuqgRGnXA0u21gAqOyB5iHjlM9MMfNOm9RHCQ7zLIzT0x11Q==} - cpu: [x64] - os: [linux] - - '@rollup/rollup-win32-arm64-msvc@4.17.2': - resolution: {integrity: sha512-tmdtXMfKAjy5+IQsVtDiCfqbynAQE/TQRpWdVataHmhMb9DCoJxp9vLcCBjEQWMiUYxO1QprH/HbY9ragCEFLA==} - cpu: [arm64] - os: [win32] - - '@rollup/rollup-win32-ia32-msvc@4.17.2': - resolution: {integrity: sha512-7II/QCSTAHuE5vdZaQEwJq2ZACkBpQDOmQsE6D6XUbnBHW8IAhm4eTufL6msLJorzrHDFv3CF8oCA/hSIRuZeQ==} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-x64-msvc@4.17.2': - resolution: {integrity: sha512-TGGO7v7qOq4CYmSBVEYpI1Y5xDuCEnbVC5Vth8mOsW0gDSzxNrVERPc790IGHsrT2dQSimgMr9Ub3Y1Jci5/8w==} - cpu: [x64] - os: [win32] - - '@types/estree@1.0.5': - resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} - - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - - ansi-regex@6.0.1: - resolution: {integrity: sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==} - engines: {node: '>=12'} - - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - ansi-styles@6.2.1: - resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} - engines: {node: '>=12'} - - any-promise@1.3.0: - resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} - - anymatch@3.1.3: - resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} - engines: {node: '>= 8'} - - array-union@2.1.0: - resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} - engines: {node: '>=8'} - - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - - binary-extensions@2.3.0: - resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} - engines: {node: '>=8'} - - brace-expansion@2.0.1: - resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} - - braces@3.0.2: - resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==} - engines: {node: '>=8'} - - bundle-require@4.0.3: - resolution: {integrity: sha512-2iscZ3fcthP2vka4Y7j277YJevwmsby/FpFDwjgw34Nl7dtCpt7zz/4TexmHMzY6KZEih7En9ImlbbgUNNQGtA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - peerDependencies: - esbuild: '>=0.17' - - cac@6.7.14: - resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} - engines: {node: '>=8'} - - chokidar@3.6.0: - resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} - engines: {node: '>= 8.10.0'} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - - commander@4.1.1: - resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} - engines: {node: '>= 6'} - - cross-spawn@7.0.3: - resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} - engines: {node: '>= 8'} - - debug@4.3.4: - resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - dir-glob@3.0.1: - resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} - engines: {node: '>=8'} - - eastasianwidth@0.2.0: - resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - - emoji-regex@9.2.2: - resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - - esbuild@0.19.12: - resolution: {integrity: sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==} - engines: {node: '>=12'} - hasBin: true - - execa@5.1.1: - resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} - engines: {node: '>=10'} - - fast-glob@3.3.2: - resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} - engines: {node: '>=8.6.0'} - - fastq@1.17.1: - resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} - - fill-range@7.0.1: - resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} - engines: {node: '>=8'} - - foreground-child@3.1.1: - resolution: {integrity: sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==} - engines: {node: '>=14'} - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - get-stream@6.0.1: - resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} - engines: {node: '>=10'} - - glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} - - glob@10.3.12: - resolution: {integrity: sha512-TCNv8vJ+xz4QiqTpfOJA7HvYv+tNIRHKfUWw/q+v2jdgN4ebz+KY9tGx5J4rHP0o84mNP+ApH66HRX8us3Khqg==} - engines: {node: '>=16 || 14 >=14.17'} - hasBin: true - - globby@11.1.0: - resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} - engines: {node: '>=10'} - - human-signals@2.1.0: - resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} - engines: {node: '>=10.17.0'} - - ignore@5.3.1: - resolution: {integrity: sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==} - engines: {node: '>= 4'} - - is-binary-path@2.1.0: - resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} - engines: {node: '>=8'} - - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - - is-stream@2.0.1: - resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} - engines: {node: '>=8'} - - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - jackspeak@2.3.6: - resolution: {integrity: sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==} - engines: {node: '>=14'} - - joycon@3.1.1: - resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} - engines: {node: '>=10'} - - lilconfig@3.1.1: - resolution: {integrity: sha512-O18pf7nyvHTckunPWCV1XUNXU1piu01y2b7ATJ0ppkUkk8ocqVWBrYjJBCwHDjD/ZWcfyrA0P4gKhzWGi5EINQ==} - engines: {node: '>=14'} - - lines-and-columns@1.2.4: - resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - - load-tsconfig@0.2.5: - resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - lodash.sortby@4.7.0: - resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} - - lru-cache@10.2.2: - resolution: {integrity: sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==} - engines: {node: 14 || >=16.14} - - merge-stream@2.0.0: - resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} - - merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} - - micromatch@4.0.5: - resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} - engines: {node: '>=8.6'} - - mimic-fn@2.1.0: - resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} - engines: {node: '>=6'} - - minimatch@9.0.4: - resolution: {integrity: sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==} - engines: {node: '>=16 || 14 >=14.17'} - - minipass@7.0.4: - resolution: {integrity: sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==} - engines: {node: '>=16 || 14 >=14.17'} - - ms@2.1.2: - resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} - - mz@2.7.0: - resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - - normalize-path@3.0.0: - resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} - engines: {node: '>=0.10.0'} - - npm-run-path@4.0.1: - resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} - engines: {node: '>=8'} - - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} - - onetime@5.1.2: - resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} - engines: {node: '>=6'} - - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - - path-scurry@1.10.2: - resolution: {integrity: sha512-7xTavNy5RQXnsjANvVvMkEjvloOinkAjv/Z6Ildz9v2RinZ4SBKTWFOVRbaF8p0vpHnyjV/UwNDdKuUv6M5qcA==} - engines: {node: '>=16 || 14 >=14.17'} - - path-type@4.0.0: - resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} - engines: {node: '>=8'} - - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} - engines: {node: '>=8.6'} - - pirates@4.0.6: - resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} - engines: {node: '>= 6'} - - postcss-load-config@4.0.2: - resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==} - engines: {node: '>= 14'} - peerDependencies: - postcss: '>=8.0.9' - ts-node: '>=9.0.0' - peerDependenciesMeta: - postcss: - optional: true - ts-node: - optional: true - - punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} - - queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - - readdirp@3.6.0: - resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} - engines: {node: '>=8.10.0'} - - resolve-from@5.0.0: - resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} - engines: {node: '>=8'} - - reusify@1.0.4: - resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - - rollup@4.17.2: - resolution: {integrity: sha512-/9ClTJPByC0U4zNLowV1tMBe8yMEAxewtR3cUNX5BoEpGH3dQEWpJLr6CLp0fPdYRF/fzVOgvDb1zXuakwF5kQ==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - - run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - - signal-exit@3.0.7: - resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} - - signal-exit@4.1.0: - resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} - engines: {node: '>=14'} - - slash@3.0.0: - resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} - engines: {node: '>=8'} - - source-map@0.8.0-beta.0: - resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} - engines: {node: '>= 8'} - - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - - string-width@5.1.2: - resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} - engines: {node: '>=12'} - - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - - strip-ansi@7.1.0: - resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} - engines: {node: '>=12'} - - strip-final-newline@2.0.0: - resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} - engines: {node: '>=6'} - - sucrase@3.35.0: - resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==} - engines: {node: '>=16 || 14 >=14.17'} - hasBin: true - - thenify-all@1.6.0: - resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} - engines: {node: '>=0.8'} - - thenify@3.3.1: - resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} - - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - - tr46@1.0.1: - resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} - - tree-kill@1.2.2: - resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} - hasBin: true - - ts-interface-checker@0.1.13: - resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} - - tsup@8.0.2: - resolution: {integrity: sha512-NY8xtQXdH7hDUAZwcQdY/Vzlw9johQsaqf7iwZ6g1DOUlFYQ5/AtVAjTvihhEyeRlGo4dLRVHtrRaL35M1daqQ==} - engines: {node: '>=18'} - hasBin: true - peerDependencies: - '@microsoft/api-extractor': ^7.36.0 - '@swc/core': ^1 - postcss: ^8.4.12 - typescript: '>=4.5.0' - peerDependenciesMeta: - '@microsoft/api-extractor': - optional: true - '@swc/core': - optional: true - postcss: - optional: true - typescript: - optional: true - - webidl-conversions@4.0.2: - resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} - - whatwg-url@7.1.0: - resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} - - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} - - wrap-ansi@8.1.0: - resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} - engines: {node: '>=12'} - - yaml@2.4.2: - resolution: {integrity: sha512-B3VqDZ+JAg1nZpaEmWtTXUlBneoGx6CPM9b0TENK6aoSu5t73dItudwdgmi6tHlIZZId4dZ9skcAQ2UbcyAeVA==} - engines: {node: '>= 14'} - hasBin: true - -snapshots: - - '@esbuild/aix-ppc64@0.19.12': - optional: true - - '@esbuild/android-arm64@0.19.12': - optional: true - - '@esbuild/android-arm@0.19.12': - optional: true - - '@esbuild/android-x64@0.19.12': - optional: true - - '@esbuild/darwin-arm64@0.19.12': - optional: true - - '@esbuild/darwin-x64@0.19.12': - optional: true - - '@esbuild/freebsd-arm64@0.19.12': - optional: true - - '@esbuild/freebsd-x64@0.19.12': - optional: true - - '@esbuild/linux-arm64@0.19.12': - optional: true - - '@esbuild/linux-arm@0.19.12': - optional: true - - '@esbuild/linux-ia32@0.19.12': - optional: true - - '@esbuild/linux-loong64@0.19.12': - optional: true - - '@esbuild/linux-mips64el@0.19.12': - optional: true - - '@esbuild/linux-ppc64@0.19.12': - optional: true - - '@esbuild/linux-riscv64@0.19.12': - optional: true - - '@esbuild/linux-s390x@0.19.12': - optional: true - - '@esbuild/linux-x64@0.19.12': - optional: true - - '@esbuild/netbsd-x64@0.19.12': - optional: true - - '@esbuild/openbsd-x64@0.19.12': - optional: true - - '@esbuild/sunos-x64@0.19.12': - optional: true - - '@esbuild/win32-arm64@0.19.12': - optional: true - - '@esbuild/win32-ia32@0.19.12': - optional: true - - '@esbuild/win32-x64@0.19.12': - optional: true - - '@isaacs/cliui@8.0.2': - dependencies: - string-width: 5.1.2 - string-width-cjs: string-width@4.2.3 - strip-ansi: 7.1.0 - strip-ansi-cjs: strip-ansi@6.0.1 - wrap-ansi: 8.1.0 - wrap-ansi-cjs: wrap-ansi@7.0.0 - - '@jridgewell/gen-mapping@0.3.5': - dependencies: - '@jridgewell/set-array': 1.2.1 - '@jridgewell/sourcemap-codec': 1.4.15 - '@jridgewell/trace-mapping': 0.3.25 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/set-array@1.2.1': {} - - '@jridgewell/sourcemap-codec@1.4.15': {} - - '@jridgewell/trace-mapping@0.3.25': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.4.15 - - '@nodelib/fs.scandir@2.1.5': - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - - '@nodelib/fs.stat@2.0.5': {} - - '@nodelib/fs.walk@1.2.8': - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.17.1 - - '@pkgjs/parseargs@0.11.0': - optional: true - - '@rollup/rollup-android-arm-eabi@4.17.2': - optional: true - - '@rollup/rollup-android-arm64@4.17.2': - optional: true - - '@rollup/rollup-darwin-arm64@4.17.2': - optional: true - - '@rollup/rollup-darwin-x64@4.17.2': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.17.2': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.17.2': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.17.2': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.17.2': - optional: true - - '@rollup/rollup-linux-powerpc64le-gnu@4.17.2': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.17.2': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.17.2': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.17.2': - optional: true - - '@rollup/rollup-linux-x64-musl@4.17.2': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.17.2': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.17.2': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.17.2': - optional: true - - '@types/estree@1.0.5': {} - - ansi-regex@5.0.1: {} - - ansi-regex@6.0.1: {} - - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - - ansi-styles@6.2.1: {} - - any-promise@1.3.0: {} - - anymatch@3.1.3: - dependencies: - normalize-path: 3.0.0 - picomatch: 2.3.1 - - array-union@2.1.0: {} - - balanced-match@1.0.2: {} - - binary-extensions@2.3.0: {} - - brace-expansion@2.0.1: - dependencies: - balanced-match: 1.0.2 - - braces@3.0.2: - dependencies: - fill-range: 7.0.1 - - bundle-require@4.0.3(esbuild@0.19.12): - dependencies: - esbuild: 0.19.12 - load-tsconfig: 0.2.5 - - cac@6.7.14: {} - - chokidar@3.6.0: - dependencies: - anymatch: 3.1.3 - braces: 3.0.2 - glob-parent: 5.1.2 - is-binary-path: 2.1.0 - is-glob: 4.0.3 - normalize-path: 3.0.0 - readdirp: 3.6.0 - optionalDependencies: - fsevents: 2.3.3 - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - - commander@4.1.1: {} - - cross-spawn@7.0.3: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - - debug@4.3.4: - dependencies: - ms: 2.1.2 - - dir-glob@3.0.1: - dependencies: - path-type: 4.0.0 - - eastasianwidth@0.2.0: {} - - emoji-regex@8.0.0: {} - - emoji-regex@9.2.2: {} - - esbuild@0.19.12: - optionalDependencies: - '@esbuild/aix-ppc64': 0.19.12 - '@esbuild/android-arm': 0.19.12 - '@esbuild/android-arm64': 0.19.12 - '@esbuild/android-x64': 0.19.12 - '@esbuild/darwin-arm64': 0.19.12 - '@esbuild/darwin-x64': 0.19.12 - '@esbuild/freebsd-arm64': 0.19.12 - '@esbuild/freebsd-x64': 0.19.12 - '@esbuild/linux-arm': 0.19.12 - '@esbuild/linux-arm64': 0.19.12 - '@esbuild/linux-ia32': 0.19.12 - '@esbuild/linux-loong64': 0.19.12 - '@esbuild/linux-mips64el': 0.19.12 - '@esbuild/linux-ppc64': 0.19.12 - '@esbuild/linux-riscv64': 0.19.12 - '@esbuild/linux-s390x': 0.19.12 - '@esbuild/linux-x64': 0.19.12 - '@esbuild/netbsd-x64': 0.19.12 - '@esbuild/openbsd-x64': 0.19.12 - '@esbuild/sunos-x64': 0.19.12 - '@esbuild/win32-arm64': 0.19.12 - '@esbuild/win32-ia32': 0.19.12 - '@esbuild/win32-x64': 0.19.12 - - execa@5.1.1: - dependencies: - cross-spawn: 7.0.3 - get-stream: 6.0.1 - human-signals: 2.1.0 - is-stream: 2.0.1 - merge-stream: 2.0.0 - npm-run-path: 4.0.1 - onetime: 5.1.2 - signal-exit: 3.0.7 - strip-final-newline: 2.0.0 - - fast-glob@3.3.2: - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.5 - - fastq@1.17.1: - dependencies: - reusify: 1.0.4 - - fill-range@7.0.1: - dependencies: - to-regex-range: 5.0.1 - - foreground-child@3.1.1: - dependencies: - cross-spawn: 7.0.3 - signal-exit: 4.1.0 - - fsevents@2.3.3: - optional: true - - get-stream@6.0.1: {} - - glob-parent@5.1.2: - dependencies: - is-glob: 4.0.3 - - glob@10.3.12: - dependencies: - foreground-child: 3.1.1 - jackspeak: 2.3.6 - minimatch: 9.0.4 - minipass: 7.0.4 - path-scurry: 1.10.2 - - globby@11.1.0: - dependencies: - array-union: 2.1.0 - dir-glob: 3.0.1 - fast-glob: 3.3.2 - ignore: 5.3.1 - merge2: 1.4.1 - slash: 3.0.0 - - human-signals@2.1.0: {} - - ignore@5.3.1: {} - - is-binary-path@2.1.0: - dependencies: - binary-extensions: 2.3.0 - - is-extglob@2.1.1: {} - - is-fullwidth-code-point@3.0.0: {} - - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 - - is-number@7.0.0: {} - - is-stream@2.0.1: {} - - isexe@2.0.0: {} - - jackspeak@2.3.6: - dependencies: - '@isaacs/cliui': 8.0.2 - optionalDependencies: - '@pkgjs/parseargs': 0.11.0 - - joycon@3.1.1: {} - - lilconfig@3.1.1: {} - - lines-and-columns@1.2.4: {} - - load-tsconfig@0.2.5: {} - - lodash.sortby@4.7.0: {} - - lru-cache@10.2.2: {} - - merge-stream@2.0.0: {} - - merge2@1.4.1: {} - - micromatch@4.0.5: - dependencies: - braces: 3.0.2 - picomatch: 2.3.1 - - mimic-fn@2.1.0: {} - - minimatch@9.0.4: - dependencies: - brace-expansion: 2.0.1 - - minipass@7.0.4: {} - - ms@2.1.2: {} - - mz@2.7.0: - dependencies: - any-promise: 1.3.0 - object-assign: 4.1.1 - thenify-all: 1.6.0 - - normalize-path@3.0.0: {} - - npm-run-path@4.0.1: - dependencies: - path-key: 3.1.1 - - object-assign@4.1.1: {} - - onetime@5.1.2: - dependencies: - mimic-fn: 2.1.0 - - path-key@3.1.1: {} - - path-scurry@1.10.2: - dependencies: - lru-cache: 10.2.2 - minipass: 7.0.4 - - path-type@4.0.0: {} - - picomatch@2.3.1: {} - - pirates@4.0.6: {} - - postcss-load-config@4.0.2: - dependencies: - lilconfig: 3.1.1 - yaml: 2.4.2 - - punycode@2.3.1: {} - - queue-microtask@1.2.3: {} - - readdirp@3.6.0: - dependencies: - picomatch: 2.3.1 - - resolve-from@5.0.0: {} - - reusify@1.0.4: {} - - rollup@4.17.2: - dependencies: - '@types/estree': 1.0.5 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.17.2 - '@rollup/rollup-android-arm64': 4.17.2 - '@rollup/rollup-darwin-arm64': 4.17.2 - '@rollup/rollup-darwin-x64': 4.17.2 - '@rollup/rollup-linux-arm-gnueabihf': 4.17.2 - '@rollup/rollup-linux-arm-musleabihf': 4.17.2 - '@rollup/rollup-linux-arm64-gnu': 4.17.2 - '@rollup/rollup-linux-arm64-musl': 4.17.2 - '@rollup/rollup-linux-powerpc64le-gnu': 4.17.2 - '@rollup/rollup-linux-riscv64-gnu': 4.17.2 - '@rollup/rollup-linux-s390x-gnu': 4.17.2 - '@rollup/rollup-linux-x64-gnu': 4.17.2 - '@rollup/rollup-linux-x64-musl': 4.17.2 - '@rollup/rollup-win32-arm64-msvc': 4.17.2 - '@rollup/rollup-win32-ia32-msvc': 4.17.2 - '@rollup/rollup-win32-x64-msvc': 4.17.2 - fsevents: 2.3.3 - - run-parallel@1.2.0: - dependencies: - queue-microtask: 1.2.3 - - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - - signal-exit@3.0.7: {} - - signal-exit@4.1.0: {} - - slash@3.0.0: {} - - source-map@0.8.0-beta.0: - dependencies: - whatwg-url: 7.1.0 - - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - - string-width@5.1.2: - dependencies: - eastasianwidth: 0.2.0 - emoji-regex: 9.2.2 - strip-ansi: 7.1.0 - - strip-ansi@6.0.1: - dependencies: - ansi-regex: 5.0.1 - - strip-ansi@7.1.0: - dependencies: - ansi-regex: 6.0.1 - - strip-final-newline@2.0.0: {} - - sucrase@3.35.0: - dependencies: - '@jridgewell/gen-mapping': 0.3.5 - commander: 4.1.1 - glob: 10.3.12 - lines-and-columns: 1.2.4 - mz: 2.7.0 - pirates: 4.0.6 - ts-interface-checker: 0.1.13 - - thenify-all@1.6.0: - dependencies: - thenify: 3.3.1 - - thenify@3.3.1: - dependencies: - any-promise: 1.3.0 - - to-regex-range@5.0.1: - dependencies: - is-number: 7.0.0 - - tr46@1.0.1: - dependencies: - punycode: 2.3.1 - - tree-kill@1.2.2: {} - - ts-interface-checker@0.1.13: {} - - tsup@8.0.2: - dependencies: - bundle-require: 4.0.3(esbuild@0.19.12) - cac: 6.7.14 - chokidar: 3.6.0 - debug: 4.3.4 - esbuild: 0.19.12 - execa: 5.1.1 - globby: 11.1.0 - joycon: 3.1.1 - postcss-load-config: 4.0.2 - resolve-from: 5.0.0 - rollup: 4.17.2 - source-map: 0.8.0-beta.0 - sucrase: 3.35.0 - tree-kill: 1.2.2 - transitivePeerDependencies: - - supports-color - - ts-node - - webidl-conversions@4.0.2: {} - - whatwg-url@7.1.0: - dependencies: - lodash.sortby: 4.7.0 - tr46: 1.0.1 - webidl-conversions: 4.0.2 - - which@2.0.2: - dependencies: - isexe: 2.0.0 - - wrap-ansi@7.0.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - - wrap-ansi@8.1.0: - dependencies: - ansi-styles: 6.2.1 - string-width: 5.1.2 - strip-ansi: 7.1.0 - - yaml@2.4.2: {} diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 00000000..fce89544 --- /dev/null +++ b/yarn.lock @@ -0,0 +1,8 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +typescript@^5.3.2: + version "5.3.2" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.2.tgz#00d1c7c1c46928c5845c1ee8d0cc2791031d4c43" + integrity sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ== From 93cb9d6b2fe8a6be6d636174b1d5821852ff22c8 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Thu, 2 May 2024 10:02:46 -0400 Subject: [PATCH 037/164] fix: Docs build, pnpm, vm evaluate (#46) (#50) * Push * prettierrc * Use cjs cuz current api require's it * Prettier override for md * fix 2b-c-sharp Hopefully fixed the break introduced by pnpm Fix to nav.js generation Now just using tsc to build the file type = commonjs Co-authored-by: Puru Vijay <47742487+PuruVJ@users.noreply.github.com> --- .prettierrc | 19 ++++++ docs/nav.js | 92 +++++++++++++++------------- nav.ts | 163 +++++++++++++++++++++++++++++++------------------ package.json | 27 ++++---- pnpm-lock.yaml | 18 ++++++ yarn.lock | 8 --- 6 files changed, 203 insertions(+), 124 deletions(-) create mode 100644 .prettierrc create mode 100644 pnpm-lock.yaml delete mode 100644 yarn.lock diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..81d845ca --- /dev/null +++ b/.prettierrc @@ -0,0 +1,19 @@ +{ + "tabWidth": 4, + "useTabs": false, + "semi": true, + "singleQuote": true, + "arrowParens": "avoid", + "jsxSingleQuote": false, + "trailingComma": "none", + "endOfLine": "auto", + "printWidth": 80, + "overrides": [ + { + "files": "*.md", + "options": { + "tabWidth": 2 + } + } + ] +} diff --git a/docs/nav.js b/docs/nav.js index cb8d22f1..ec6d9d66 100644 --- a/docs/nav.js +++ b/docs/nav.js @@ -1,52 +1,58 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); +exports.nav = void 0; function page(title, slug, path, props) { - return { type: "page", path, slug, title, ...props }; + return { type: 'page', path, slug, title, ...props }; } function section(title) { - return { type: "section", title }; + return { type: 'section', title }; } const nav = { items: [ - section("Intro"), - page("Overview", "index", "index.md"), - page("Getting Started", "getting-started", "getting-started.md"), - section("Deploying"), - page("Testnet", "deploying/testnet", "deploying/testnet.md"), - section("Unity Tutorial"), - page("Part 1 - Basic Multiplayer", "unity/part-1", "unity/part-1.md"), - page("Part 2 - Resources And Scheduling", "unity/part-2", "unity/part-2.md"), - page("Part 3 - BitCraft Mini", "unity/part-3", "unity/part-3.md"), - section("Server Module Languages"), - page("Overview", "modules", "modules/index.md"), - page("Rust Quickstart", "modules/rust/quickstart", "modules/rust/quickstart.md"), - page("Rust Reference", "modules/rust", "modules/rust/index.md"), - page("C# Quickstart", "modules/c-sharp/quickstart", "modules/c-sharp/quickstart.md"), - page("C# Reference", "modules/c-sharp", "modules/c-sharp/index.md"), - section("Client SDK Languages"), - page("Overview", "sdks", "sdks/index.md"), - page("Typescript Quickstart", "sdks/typescript/quickstart", "sdks/typescript/quickstart.md"), - page("Typescript Reference", "sdks/typescript", "sdks/typescript/index.md"), - page("Rust Quickstart", "sdks/rust/quickstart", "sdks/rust/quickstart.md"), - page("Rust Reference", "sdks/rust", "sdks/rust/index.md"), - page("Python Quickstart", "sdks/python/quickstart", "sdks/python/quickstart.md"), - page("Python Reference", "sdks/python", "sdks/python/index.md"), - page("C# Quickstart", "sdks/c-sharp/quickstart", "sdks/c-sharp/quickstart.md"), - page("C# Reference", "sdks/c-sharp", "sdks/c-sharp/index.md"), - section("WebAssembly ABI"), - page("Module ABI Reference", "webassembly-abi", "webassembly-abi/index.md"), - section("HTTP API"), - page("HTTP", "http", "http/index.md"), - page("`/identity`", "http/identity", "http/identity.md"), - page("`/database`", "http/database", "http/database.md"), - page("`/energy`", "http/energy", "http/energy.md"), - section("WebSocket API Reference"), - page("WebSocket", "ws", "ws/index.md"), - section("Data Format"), - page("SATN", "satn", "satn.md"), - page("BSATN", "bsatn", "bsatn.md"), - section("SQL"), - page("SQL Reference", "sql", "sql/index.md"), - ], + section('Intro'), + page('Overview', 'index', 'index.md'), // TODO(BREAKING): For consistency & clarity, 'index' slug should be renamed 'intro'? + page('Getting Started', 'getting-started', 'getting-started.md'), + section('Deploying'), + page('Testnet', 'deploying/testnet', 'deploying/testnet.md'), + section('Unity Tutorial - Basic Multiplayer'), + page('Overview', 'unity-tutorial', 'unity/index.md'), + page('1 - Setup', 'unity/part-1', 'unity/part-1.md'), + page('2a - Server (Rust)', 'unity/part-2a-rust', 'unity/part-2a-rust.md'), + page('2b - Server (C#)', 'unity/part-2b-c-sharp', 'unity/part-2b-c-sharp.md'), + page('3 - Client', 'unity/part-3', 'unity/part-3.md'), + section('Unity Tutorial - Advanced'), + page('4 - Resources And Scheduling', 'unity/part-4', 'unity/part-4.md'), + page('5 - BitCraft Mini', 'unity/part-5', 'unity/part-5.md'), + section('Server Module Languages'), + page('Overview', 'modules', 'modules/index.md'), + page('Rust Quickstart', 'modules/rust/quickstart', 'modules/rust/quickstart.md'), + page('Rust Reference', 'modules/rust', 'modules/rust/index.md'), + page('C# Quickstart', 'modules/c-sharp/quickstart', 'modules/c-sharp/quickstart.md'), + page('C# Reference', 'modules/c-sharp', 'modules/c-sharp/index.md'), + section('Client SDK Languages'), + page('Overview', 'sdks', 'sdks/index.md'), + page('Typescript Quickstart', 'sdks/typescript/quickstart', 'sdks/typescript/quickstart.md'), + page('Typescript Reference', 'sdks/typescript', 'sdks/typescript/index.md'), + page('Rust Quickstart', 'sdks/rust/quickstart', 'sdks/rust/quickstart.md'), + page('Rust Reference', 'sdks/rust', 'sdks/rust/index.md'), + page('Python Quickstart', 'sdks/python/quickstart', 'sdks/python/quickstart.md'), + page('Python Reference', 'sdks/python', 'sdks/python/index.md'), + page('C# Quickstart', 'sdks/c-sharp/quickstart', 'sdks/c-sharp/quickstart.md'), + page('C# Reference', 'sdks/c-sharp', 'sdks/c-sharp/index.md'), + section('WebAssembly ABI'), + page('Module ABI Reference', 'webassembly-abi', 'webassembly-abi/index.md'), + section('HTTP API'), + page('HTTP', 'http', 'http/index.md'), + page('`/identity`', 'http/identity', 'http/identity.md'), + page('`/database`', 'http/database', 'http/database.md'), + page('`/energy`', 'http/energy', 'http/energy.md'), + section('WebSocket API Reference'), + page('WebSocket', 'ws', 'ws/index.md'), + section('Data Format'), + page('SATN', 'satn', 'satn.md'), + page('BSATN', 'bsatn', 'bsatn.md'), + section('SQL'), + page('SQL Reference', 'sql', 'sql/index.md') + ] }; -exports.default = nav; +exports.nav = nav; diff --git a/nav.ts b/nav.ts index 8f463ad7..b6eea77a 100644 --- a/nav.ts +++ b/nav.ts @@ -1,84 +1,129 @@ type Nav = { - items: NavItem[]; + items: NavItem[]; }; type NavItem = NavPage | NavSection; type NavPage = { - type: "page"; - path: string; - slug: string; - title: string; - disabled?: boolean; - href?: string; + type: 'page'; + path: string; + slug: string; + title: string; + disabled?: boolean; + href?: string; }; type NavSection = { - type: "section"; - title: string; + type: 'section'; + title: string; }; -function page(title: string, slug: string, path: string, props?: { disabled?: boolean; href?: string; description?: string }): NavPage { - return { type: "page", path, slug, title, ...props }; +function page( + title: string, + slug: string, + path: string, + props?: { disabled?: boolean; href?: string; description?: string } +): NavPage { + return { type: 'page', path, slug, title, ...props }; } function section(title: string): NavSection { - return { type: "section", title }; + return { type: 'section', title }; } const nav: Nav = { - items: [ - section("Intro"), - page("Overview", "index", "index.md"), // TODO(BREAKING): For consistency & clarity, 'index' slug should be renamed 'intro'? - page("Getting Started", "getting-started", "getting-started.md"), + items: [ + section('Intro'), + page('Overview', 'index', 'index.md'), // TODO(BREAKING): For consistency & clarity, 'index' slug should be renamed 'intro'? + page('Getting Started', 'getting-started', 'getting-started.md'), - section("Deploying"), - page("Testnet", "deploying/testnet", "deploying/testnet.md"), + section('Deploying'), + page('Testnet', 'deploying/testnet', 'deploying/testnet.md'), - section("Unity Tutorial - Basic Multiplayer"), - page("Overview", "unity-tutorial", "unity/index.md"), - page("1 - Setup", "unity/part-1", "unity/part-1.md"), - page("2a - Server (Rust)", "unity/part-2a-rust", "unity/part-2a-rust.md"), - page("2b - Server (C#)", "unity/part-2b-c-sharp", "unity/part-2a-c-sharp.md"), - page("3 - Client", "unity/part-3", "unity/part-3.md"), + section('Unity Tutorial - Basic Multiplayer'), + page('Overview', 'unity-tutorial', 'unity/index.md'), + page('1 - Setup', 'unity/part-1', 'unity/part-1.md'), + page( + '2a - Server (Rust)', + 'unity/part-2a-rust', + 'unity/part-2a-rust.md' + ), + page( + '2b - Server (C#)', + 'unity/part-2b-c-sharp', + 'unity/part-2b-c-sharp.md' + ), + page('3 - Client', 'unity/part-3', 'unity/part-3.md'), - section("Unity Tutorial - Advanced"), - page("4 - Resources And Scheduling", "unity/part-4", "unity/part-4.md"), - page("5 - BitCraft Mini", "unity/part-5", "unity/part-5.md"), + section('Unity Tutorial - Advanced'), + page('4 - Resources And Scheduling', 'unity/part-4', 'unity/part-4.md'), + page('5 - BitCraft Mini', 'unity/part-5', 'unity/part-5.md'), - section("Server Module Languages"), - page("Overview", "modules", "modules/index.md"), - page("Rust Quickstart", "modules/rust/quickstart", "modules/rust/quickstart.md"), - page("Rust Reference", "modules/rust", "modules/rust/index.md"), - page("C# Quickstart", "modules/c-sharp/quickstart", "modules/c-sharp/quickstart.md"), - page("C# Reference", "modules/c-sharp", "modules/c-sharp/index.md"), + section('Server Module Languages'), + page('Overview', 'modules', 'modules/index.md'), + page( + 'Rust Quickstart', + 'modules/rust/quickstart', + 'modules/rust/quickstart.md' + ), + page('Rust Reference', 'modules/rust', 'modules/rust/index.md'), + page( + 'C# Quickstart', + 'modules/c-sharp/quickstart', + 'modules/c-sharp/quickstart.md' + ), + page('C# Reference', 'modules/c-sharp', 'modules/c-sharp/index.md'), - section("Client SDK Languages"), - page("Overview", "sdks", "sdks/index.md"), - page("Typescript Quickstart", "sdks/typescript/quickstart", "sdks/typescript/quickstart.md"), - page("Typescript Reference", "sdks/typescript", "sdks/typescript/index.md"), - page("Rust Quickstart", "sdks/rust/quickstart", "sdks/rust/quickstart.md"), - page("Rust Reference", "sdks/rust", "sdks/rust/index.md"), - page("Python Quickstart", "sdks/python/quickstart", "sdks/python/quickstart.md"), - page("Python Reference", "sdks/python", "sdks/python/index.md"), - page("C# Quickstart", "sdks/c-sharp/quickstart", "sdks/c-sharp/quickstart.md"), - page("C# Reference", "sdks/c-sharp", "sdks/c-sharp/index.md"), + section('Client SDK Languages'), + page('Overview', 'sdks', 'sdks/index.md'), + page( + 'Typescript Quickstart', + 'sdks/typescript/quickstart', + 'sdks/typescript/quickstart.md' + ), + page( + 'Typescript Reference', + 'sdks/typescript', + 'sdks/typescript/index.md' + ), + page( + 'Rust Quickstart', + 'sdks/rust/quickstart', + 'sdks/rust/quickstart.md' + ), + page('Rust Reference', 'sdks/rust', 'sdks/rust/index.md'), + page( + 'Python Quickstart', + 'sdks/python/quickstart', + 'sdks/python/quickstart.md' + ), + page('Python Reference', 'sdks/python', 'sdks/python/index.md'), + page( + 'C# Quickstart', + 'sdks/c-sharp/quickstart', + 'sdks/c-sharp/quickstart.md' + ), + page('C# Reference', 'sdks/c-sharp', 'sdks/c-sharp/index.md'), - section("WebAssembly ABI"), - page("Module ABI Reference", "webassembly-abi", "webassembly-abi/index.md"), + section('WebAssembly ABI'), + page( + 'Module ABI Reference', + 'webassembly-abi', + 'webassembly-abi/index.md' + ), - section("HTTP API"), - page("HTTP", "http", "http/index.md"), - page("`/identity`", "http/identity", "http/identity.md"), - page("`/database`", "http/database", "http/database.md"), - page("`/energy`", "http/energy", "http/energy.md"), + section('HTTP API'), + page('HTTP', 'http', 'http/index.md'), + page('`/identity`', 'http/identity', 'http/identity.md'), + page('`/database`', 'http/database', 'http/database.md'), + page('`/energy`', 'http/energy', 'http/energy.md'), - section("WebSocket API Reference"), - page("WebSocket", "ws", "ws/index.md"), + section('WebSocket API Reference'), + page('WebSocket', 'ws', 'ws/index.md'), - section("Data Format"), - page("SATN", "satn", "satn.md"), - page("BSATN", "bsatn", "bsatn.md"), + section('Data Format'), + page('SATN', 'satn', 'satn.md'), + page('BSATN', 'bsatn', 'bsatn.md'), - section("SQL"), - page("SQL Reference", "sql", "sql/index.md"), - ], + section('SQL'), + page('SQL Reference', 'sql', 'sql/index.md') + ] }; -export default nav; +export { nav }; diff --git a/package.json b/package.json index a56ea4e8..4b23519c 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,14 @@ { - "name": "spacetime-docs", - "version": "1.0.0", - "description": "This repository contains the markdown files which are used to display documentation on our [website](https://spacetimedb.com/docs).", - "main": "index.js", - "dependencies": {}, - "devDependencies": { - "typescript": "^5.3.2" - }, - "scripts": { - "build": "tsc" - }, - "author": "Clockwork Labs", - "license": "ISC" -} \ No newline at end of file + "name": "spacetime-docs", + "version": "1.0.0", + "description": "This repository contains the markdown files which are used to display documentation on our [website](https://spacetimedb.com/docs).", + "main": "index.js", + "devDependencies": { + "typescript": "^5.4.5" + }, + "scripts": { + "build": "tsc" + }, + "author": "Clockwork Labs", + "license": "ISC" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 00000000..8cffafc8 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,18 @@ +lockfileVersion: '6.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +devDependencies: + typescript: + specifier: ^5.4.5 + version: 5.4.5 + +packages: + + /typescript@5.4.5: + resolution: {integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==} + engines: {node: '>=14.17'} + hasBin: true + dev: true diff --git a/yarn.lock b/yarn.lock deleted file mode 100644 index fce89544..00000000 --- a/yarn.lock +++ /dev/null @@ -1,8 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - -typescript@^5.3.2: - version "5.3.2" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.2.tgz#00d1c7c1c46928c5845c1ee8d0cc2791031d4c43" - integrity sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ== From 09ece428977c5eccfc3addbf5a4ae72758e96afc Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Thu, 2 May 2024 10:11:54 -0400 Subject: [PATCH 038/164] Revert "fix: Docs build, pnpm, vm evaluate (#46) (#50)" (#52) This reverts commit 93cb9d6b2fe8a6be6d636174b1d5821852ff22c8. --- .prettierrc | 19 ------ docs/nav.js | 92 +++++++++++++--------------- nav.ts | 163 ++++++++++++++++++------------------------------- package.json | 27 ++++---- pnpm-lock.yaml | 18 ------ yarn.lock | 8 +++ 6 files changed, 124 insertions(+), 203 deletions(-) delete mode 100644 .prettierrc delete mode 100644 pnpm-lock.yaml create mode 100644 yarn.lock diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 81d845ca..00000000 --- a/.prettierrc +++ /dev/null @@ -1,19 +0,0 @@ -{ - "tabWidth": 4, - "useTabs": false, - "semi": true, - "singleQuote": true, - "arrowParens": "avoid", - "jsxSingleQuote": false, - "trailingComma": "none", - "endOfLine": "auto", - "printWidth": 80, - "overrides": [ - { - "files": "*.md", - "options": { - "tabWidth": 2 - } - } - ] -} diff --git a/docs/nav.js b/docs/nav.js index ec6d9d66..cb8d22f1 100644 --- a/docs/nav.js +++ b/docs/nav.js @@ -1,58 +1,52 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -exports.nav = void 0; function page(title, slug, path, props) { - return { type: 'page', path, slug, title, ...props }; + return { type: "page", path, slug, title, ...props }; } function section(title) { - return { type: 'section', title }; + return { type: "section", title }; } const nav = { items: [ - section('Intro'), - page('Overview', 'index', 'index.md'), // TODO(BREAKING): For consistency & clarity, 'index' slug should be renamed 'intro'? - page('Getting Started', 'getting-started', 'getting-started.md'), - section('Deploying'), - page('Testnet', 'deploying/testnet', 'deploying/testnet.md'), - section('Unity Tutorial - Basic Multiplayer'), - page('Overview', 'unity-tutorial', 'unity/index.md'), - page('1 - Setup', 'unity/part-1', 'unity/part-1.md'), - page('2a - Server (Rust)', 'unity/part-2a-rust', 'unity/part-2a-rust.md'), - page('2b - Server (C#)', 'unity/part-2b-c-sharp', 'unity/part-2b-c-sharp.md'), - page('3 - Client', 'unity/part-3', 'unity/part-3.md'), - section('Unity Tutorial - Advanced'), - page('4 - Resources And Scheduling', 'unity/part-4', 'unity/part-4.md'), - page('5 - BitCraft Mini', 'unity/part-5', 'unity/part-5.md'), - section('Server Module Languages'), - page('Overview', 'modules', 'modules/index.md'), - page('Rust Quickstart', 'modules/rust/quickstart', 'modules/rust/quickstart.md'), - page('Rust Reference', 'modules/rust', 'modules/rust/index.md'), - page('C# Quickstart', 'modules/c-sharp/quickstart', 'modules/c-sharp/quickstart.md'), - page('C# Reference', 'modules/c-sharp', 'modules/c-sharp/index.md'), - section('Client SDK Languages'), - page('Overview', 'sdks', 'sdks/index.md'), - page('Typescript Quickstart', 'sdks/typescript/quickstart', 'sdks/typescript/quickstart.md'), - page('Typescript Reference', 'sdks/typescript', 'sdks/typescript/index.md'), - page('Rust Quickstart', 'sdks/rust/quickstart', 'sdks/rust/quickstart.md'), - page('Rust Reference', 'sdks/rust', 'sdks/rust/index.md'), - page('Python Quickstart', 'sdks/python/quickstart', 'sdks/python/quickstart.md'), - page('Python Reference', 'sdks/python', 'sdks/python/index.md'), - page('C# Quickstart', 'sdks/c-sharp/quickstart', 'sdks/c-sharp/quickstart.md'), - page('C# Reference', 'sdks/c-sharp', 'sdks/c-sharp/index.md'), - section('WebAssembly ABI'), - page('Module ABI Reference', 'webassembly-abi', 'webassembly-abi/index.md'), - section('HTTP API'), - page('HTTP', 'http', 'http/index.md'), - page('`/identity`', 'http/identity', 'http/identity.md'), - page('`/database`', 'http/database', 'http/database.md'), - page('`/energy`', 'http/energy', 'http/energy.md'), - section('WebSocket API Reference'), - page('WebSocket', 'ws', 'ws/index.md'), - section('Data Format'), - page('SATN', 'satn', 'satn.md'), - page('BSATN', 'bsatn', 'bsatn.md'), - section('SQL'), - page('SQL Reference', 'sql', 'sql/index.md') - ] + section("Intro"), + page("Overview", "index", "index.md"), + page("Getting Started", "getting-started", "getting-started.md"), + section("Deploying"), + page("Testnet", "deploying/testnet", "deploying/testnet.md"), + section("Unity Tutorial"), + page("Part 1 - Basic Multiplayer", "unity/part-1", "unity/part-1.md"), + page("Part 2 - Resources And Scheduling", "unity/part-2", "unity/part-2.md"), + page("Part 3 - BitCraft Mini", "unity/part-3", "unity/part-3.md"), + section("Server Module Languages"), + page("Overview", "modules", "modules/index.md"), + page("Rust Quickstart", "modules/rust/quickstart", "modules/rust/quickstart.md"), + page("Rust Reference", "modules/rust", "modules/rust/index.md"), + page("C# Quickstart", "modules/c-sharp/quickstart", "modules/c-sharp/quickstart.md"), + page("C# Reference", "modules/c-sharp", "modules/c-sharp/index.md"), + section("Client SDK Languages"), + page("Overview", "sdks", "sdks/index.md"), + page("Typescript Quickstart", "sdks/typescript/quickstart", "sdks/typescript/quickstart.md"), + page("Typescript Reference", "sdks/typescript", "sdks/typescript/index.md"), + page("Rust Quickstart", "sdks/rust/quickstart", "sdks/rust/quickstart.md"), + page("Rust Reference", "sdks/rust", "sdks/rust/index.md"), + page("Python Quickstart", "sdks/python/quickstart", "sdks/python/quickstart.md"), + page("Python Reference", "sdks/python", "sdks/python/index.md"), + page("C# Quickstart", "sdks/c-sharp/quickstart", "sdks/c-sharp/quickstart.md"), + page("C# Reference", "sdks/c-sharp", "sdks/c-sharp/index.md"), + section("WebAssembly ABI"), + page("Module ABI Reference", "webassembly-abi", "webassembly-abi/index.md"), + section("HTTP API"), + page("HTTP", "http", "http/index.md"), + page("`/identity`", "http/identity", "http/identity.md"), + page("`/database`", "http/database", "http/database.md"), + page("`/energy`", "http/energy", "http/energy.md"), + section("WebSocket API Reference"), + page("WebSocket", "ws", "ws/index.md"), + section("Data Format"), + page("SATN", "satn", "satn.md"), + page("BSATN", "bsatn", "bsatn.md"), + section("SQL"), + page("SQL Reference", "sql", "sql/index.md"), + ], }; -exports.nav = nav; +exports.default = nav; diff --git a/nav.ts b/nav.ts index b6eea77a..8f463ad7 100644 --- a/nav.ts +++ b/nav.ts @@ -1,129 +1,84 @@ type Nav = { - items: NavItem[]; + items: NavItem[]; }; type NavItem = NavPage | NavSection; type NavPage = { - type: 'page'; - path: string; - slug: string; - title: string; - disabled?: boolean; - href?: string; + type: "page"; + path: string; + slug: string; + title: string; + disabled?: boolean; + href?: string; }; type NavSection = { - type: 'section'; - title: string; + type: "section"; + title: string; }; -function page( - title: string, - slug: string, - path: string, - props?: { disabled?: boolean; href?: string; description?: string } -): NavPage { - return { type: 'page', path, slug, title, ...props }; +function page(title: string, slug: string, path: string, props?: { disabled?: boolean; href?: string; description?: string }): NavPage { + return { type: "page", path, slug, title, ...props }; } function section(title: string): NavSection { - return { type: 'section', title }; + return { type: "section", title }; } const nav: Nav = { - items: [ - section('Intro'), - page('Overview', 'index', 'index.md'), // TODO(BREAKING): For consistency & clarity, 'index' slug should be renamed 'intro'? - page('Getting Started', 'getting-started', 'getting-started.md'), + items: [ + section("Intro"), + page("Overview", "index", "index.md"), // TODO(BREAKING): For consistency & clarity, 'index' slug should be renamed 'intro'? + page("Getting Started", "getting-started", "getting-started.md"), - section('Deploying'), - page('Testnet', 'deploying/testnet', 'deploying/testnet.md'), + section("Deploying"), + page("Testnet", "deploying/testnet", "deploying/testnet.md"), - section('Unity Tutorial - Basic Multiplayer'), - page('Overview', 'unity-tutorial', 'unity/index.md'), - page('1 - Setup', 'unity/part-1', 'unity/part-1.md'), - page( - '2a - Server (Rust)', - 'unity/part-2a-rust', - 'unity/part-2a-rust.md' - ), - page( - '2b - Server (C#)', - 'unity/part-2b-c-sharp', - 'unity/part-2b-c-sharp.md' - ), - page('3 - Client', 'unity/part-3', 'unity/part-3.md'), + section("Unity Tutorial - Basic Multiplayer"), + page("Overview", "unity-tutorial", "unity/index.md"), + page("1 - Setup", "unity/part-1", "unity/part-1.md"), + page("2a - Server (Rust)", "unity/part-2a-rust", "unity/part-2a-rust.md"), + page("2b - Server (C#)", "unity/part-2b-c-sharp", "unity/part-2a-c-sharp.md"), + page("3 - Client", "unity/part-3", "unity/part-3.md"), - section('Unity Tutorial - Advanced'), - page('4 - Resources And Scheduling', 'unity/part-4', 'unity/part-4.md'), - page('5 - BitCraft Mini', 'unity/part-5', 'unity/part-5.md'), + section("Unity Tutorial - Advanced"), + page("4 - Resources And Scheduling", "unity/part-4", "unity/part-4.md"), + page("5 - BitCraft Mini", "unity/part-5", "unity/part-5.md"), - section('Server Module Languages'), - page('Overview', 'modules', 'modules/index.md'), - page( - 'Rust Quickstart', - 'modules/rust/quickstart', - 'modules/rust/quickstart.md' - ), - page('Rust Reference', 'modules/rust', 'modules/rust/index.md'), - page( - 'C# Quickstart', - 'modules/c-sharp/quickstart', - 'modules/c-sharp/quickstart.md' - ), - page('C# Reference', 'modules/c-sharp', 'modules/c-sharp/index.md'), + section("Server Module Languages"), + page("Overview", "modules", "modules/index.md"), + page("Rust Quickstart", "modules/rust/quickstart", "modules/rust/quickstart.md"), + page("Rust Reference", "modules/rust", "modules/rust/index.md"), + page("C# Quickstart", "modules/c-sharp/quickstart", "modules/c-sharp/quickstart.md"), + page("C# Reference", "modules/c-sharp", "modules/c-sharp/index.md"), - section('Client SDK Languages'), - page('Overview', 'sdks', 'sdks/index.md'), - page( - 'Typescript Quickstart', - 'sdks/typescript/quickstart', - 'sdks/typescript/quickstart.md' - ), - page( - 'Typescript Reference', - 'sdks/typescript', - 'sdks/typescript/index.md' - ), - page( - 'Rust Quickstart', - 'sdks/rust/quickstart', - 'sdks/rust/quickstart.md' - ), - page('Rust Reference', 'sdks/rust', 'sdks/rust/index.md'), - page( - 'Python Quickstart', - 'sdks/python/quickstart', - 'sdks/python/quickstart.md' - ), - page('Python Reference', 'sdks/python', 'sdks/python/index.md'), - page( - 'C# Quickstart', - 'sdks/c-sharp/quickstart', - 'sdks/c-sharp/quickstart.md' - ), - page('C# Reference', 'sdks/c-sharp', 'sdks/c-sharp/index.md'), + section("Client SDK Languages"), + page("Overview", "sdks", "sdks/index.md"), + page("Typescript Quickstart", "sdks/typescript/quickstart", "sdks/typescript/quickstart.md"), + page("Typescript Reference", "sdks/typescript", "sdks/typescript/index.md"), + page("Rust Quickstart", "sdks/rust/quickstart", "sdks/rust/quickstart.md"), + page("Rust Reference", "sdks/rust", "sdks/rust/index.md"), + page("Python Quickstart", "sdks/python/quickstart", "sdks/python/quickstart.md"), + page("Python Reference", "sdks/python", "sdks/python/index.md"), + page("C# Quickstart", "sdks/c-sharp/quickstart", "sdks/c-sharp/quickstart.md"), + page("C# Reference", "sdks/c-sharp", "sdks/c-sharp/index.md"), - section('WebAssembly ABI'), - page( - 'Module ABI Reference', - 'webassembly-abi', - 'webassembly-abi/index.md' - ), + section("WebAssembly ABI"), + page("Module ABI Reference", "webassembly-abi", "webassembly-abi/index.md"), - section('HTTP API'), - page('HTTP', 'http', 'http/index.md'), - page('`/identity`', 'http/identity', 'http/identity.md'), - page('`/database`', 'http/database', 'http/database.md'), - page('`/energy`', 'http/energy', 'http/energy.md'), + section("HTTP API"), + page("HTTP", "http", "http/index.md"), + page("`/identity`", "http/identity", "http/identity.md"), + page("`/database`", "http/database", "http/database.md"), + page("`/energy`", "http/energy", "http/energy.md"), - section('WebSocket API Reference'), - page('WebSocket', 'ws', 'ws/index.md'), + section("WebSocket API Reference"), + page("WebSocket", "ws", "ws/index.md"), - section('Data Format'), - page('SATN', 'satn', 'satn.md'), - page('BSATN', 'bsatn', 'bsatn.md'), + section("Data Format"), + page("SATN", "satn", "satn.md"), + page("BSATN", "bsatn", "bsatn.md"), - section('SQL'), - page('SQL Reference', 'sql', 'sql/index.md') - ] + section("SQL"), + page("SQL Reference", "sql", "sql/index.md"), + ], }; -export { nav }; +export default nav; diff --git a/package.json b/package.json index 4b23519c..a56ea4e8 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,15 @@ { - "name": "spacetime-docs", - "version": "1.0.0", - "description": "This repository contains the markdown files which are used to display documentation on our [website](https://spacetimedb.com/docs).", - "main": "index.js", - "devDependencies": { - "typescript": "^5.4.5" - }, - "scripts": { - "build": "tsc" - }, - "author": "Clockwork Labs", - "license": "ISC" -} + "name": "spacetime-docs", + "version": "1.0.0", + "description": "This repository contains the markdown files which are used to display documentation on our [website](https://spacetimedb.com/docs).", + "main": "index.js", + "dependencies": {}, + "devDependencies": { + "typescript": "^5.3.2" + }, + "scripts": { + "build": "tsc" + }, + "author": "Clockwork Labs", + "license": "ISC" +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml deleted file mode 100644 index 8cffafc8..00000000 --- a/pnpm-lock.yaml +++ /dev/null @@ -1,18 +0,0 @@ -lockfileVersion: '6.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -devDependencies: - typescript: - specifier: ^5.4.5 - version: 5.4.5 - -packages: - - /typescript@5.4.5: - resolution: {integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==} - engines: {node: '>=14.17'} - hasBin: true - dev: true diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 00000000..fce89544 --- /dev/null +++ b/yarn.lock @@ -0,0 +1,8 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +typescript@^5.3.2: + version "5.3.2" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.2.tgz#00d1c7c1c46928c5845c1ee8d0cc2791031d4c43" + integrity sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ== From d1902a7e2afc47b5df00863c290e3c592f59f9df Mon Sep 17 00:00:00 2001 From: Puru Vijay <47742487+PuruVJ@users.noreply.github.com> Date: Thu, 2 May 2024 20:51:12 +0530 Subject: [PATCH 039/164] fix: Unity tutorial slugs (#51) --- docs/nav.js | 15 ++++++++++----- nav.ts | 2 +- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/nav.js b/docs/nav.js index cb8d22f1..4413888e 100644 --- a/docs/nav.js +++ b/docs/nav.js @@ -9,14 +9,19 @@ function section(title) { const nav = { items: [ section("Intro"), - page("Overview", "index", "index.md"), + page("Overview", "index", "index.md"), // TODO(BREAKING): For consistency & clarity, 'index' slug should be renamed 'intro'? page("Getting Started", "getting-started", "getting-started.md"), section("Deploying"), page("Testnet", "deploying/testnet", "deploying/testnet.md"), - section("Unity Tutorial"), - page("Part 1 - Basic Multiplayer", "unity/part-1", "unity/part-1.md"), - page("Part 2 - Resources And Scheduling", "unity/part-2", "unity/part-2.md"), - page("Part 3 - BitCraft Mini", "unity/part-3", "unity/part-3.md"), + section("Unity Tutorial - Basic Multiplayer"), + page("Overview", "unity-tutorial", "unity/index.md"), + page("1 - Setup", "unity/part-1", "unity/part-1.md"), + page("2a - Server (Rust)", "unity/part-2a-rust", "unity/part-2a-rust.md"), + page("2b - Server (C#)", "unity/part-2b-c-sharp", "unity/part-2b-c-sharp.md"), + page("3 - Client", "unity/part-3", "unity/part-3.md"), + section("Unity Tutorial - Advanced"), + page("4 - Resources And Scheduling", "unity/part-4", "unity/part-4.md"), + page("5 - BitCraft Mini", "unity/part-5", "unity/part-5.md"), section("Server Module Languages"), page("Overview", "modules", "modules/index.md"), page("Rust Quickstart", "modules/rust/quickstart", "modules/rust/quickstart.md"), diff --git a/nav.ts b/nav.ts index 8f463ad7..26a83f4c 100644 --- a/nav.ts +++ b/nav.ts @@ -35,7 +35,7 @@ const nav: Nav = { page("Overview", "unity-tutorial", "unity/index.md"), page("1 - Setup", "unity/part-1", "unity/part-1.md"), page("2a - Server (Rust)", "unity/part-2a-rust", "unity/part-2a-rust.md"), - page("2b - Server (C#)", "unity/part-2b-c-sharp", "unity/part-2a-c-sharp.md"), + page("2b - Server (C#)", "unity/part-2b-c-sharp", "unity/part-2b-c-sharp.md"), page("3 - Client", "unity/part-3", "unity/part-3.md"), section("Unity Tutorial - Advanced"), From 801ab78d65e397ba35633b20130dcce8ffa3ed83 Mon Sep 17 00:00:00 2001 From: Puru Vijay <47742487+PuruVJ@users.noreply.github.com> Date: Thu, 23 May 2024 05:19:14 +0530 Subject: [PATCH 040/164] fix: Broken docs links (#53) --- docs/sdks/c-sharp/quickstart.md | 24 ++-- docs/sdks/typescript/index.md | 191 +++++++++++++------------------- docs/unity/index.md | 14 ++- docs/unity/part-1.md | 9 +- docs/unity/part-2a-rust.md | 20 ++-- docs/unity/part-2b-c-sharp.md | 18 +-- docs/unity/part-3.md | 35 +++--- docs/unity/part-4.md | 2 +- docs/unity/part-5.md | 2 +- 9 files changed, 148 insertions(+), 167 deletions(-) diff --git a/docs/sdks/c-sharp/quickstart.md b/docs/sdks/c-sharp/quickstart.md index 92980f42..28f3c2e1 100644 --- a/docs/sdks/c-sharp/quickstart.md +++ b/docs/sdks/c-sharp/quickstart.md @@ -2,7 +2,7 @@ In this guide we'll show you how to get up and running with a simple SpacetimeDB app with a client written in C#. -We'll implement a command-line client for the module created in our [Rust](../../modules/rust/quickstart.md) or [C# Module](../../modules/c-sharp/quickstart.md) Quickstart guides. Ensure you followed one of these guides before continuing. +We'll implement a command-line client for the module created in our [Rust](../../modules/rust/quickstart) or [C# Module](../../modules/c-sharp/quickstart) Quickstart guides. Ensure you followed one of these guides before continuing. ## Project structure @@ -184,10 +184,10 @@ void User_OnUpdate(User oldValue, User newValue, ReducerEvent dbEvent) { Console.WriteLine($"{UserNameOrIdentity(oldValue)} renamed to {newValue.Name}"); } - + if (oldValue.Online == newValue.Online) return; - + if (newValue.Online) { Console.WriteLine($"{UserNameOrIdentity(newValue)} connected."); @@ -257,10 +257,10 @@ We'll test both that our identity matches the sender and that the status is `Fai ```csharp void Reducer_OnSetNameEvent(ReducerEvent reducerEvent, string name) { - bool localIdentityFailedToChangeName = - reducerEvent.Identity == local_identity && + bool localIdentityFailedToChangeName = + reducerEvent.Identity == local_identity && reducerEvent.Status == ClientApi.Event.Types.Status.Failed; - + if (localIdentityFailedToChangeName) { Console.Write($"Failed to change name to {name}"); @@ -275,8 +275,8 @@ We handle warnings on rejected messages the same way as rejected names, though t ```csharp void Reducer_OnSendMessageEvent(ReducerEvent reducerEvent, string text) { - bool localIdentityFailedToSendMessage = - reducerEvent.Identity == local_identity && + bool localIdentityFailedToSendMessage = + reducerEvent.Identity == local_identity && reducerEvent.Status == ClientApi.Event.Types.Status.Failed; if (localIdentityFailedToSendMessage) @@ -293,9 +293,9 @@ Once we are connected, we can send our subscription to the SpacetimeDB module. S ```csharp void OnConnect() { - SpacetimeDBClient.instance.Subscribe(new List - { - "SELECT * FROM User", "SELECT * FROM Message" + SpacetimeDBClient.instance.Subscribe(new List + { + "SELECT * FROM User", "SELECT * FROM Message" }); } ``` @@ -349,7 +349,7 @@ Since the input loop will be blocking, we'll run our processing code in a separa ```csharp const string HOST = "http://localhost:3000"; const string DBNAME = "module"; - + void ProcessThread() { SpacetimeDBClient.instance.Connect(AuthToken.Token, HOST, DBNAME); diff --git a/docs/sdks/typescript/index.md b/docs/sdks/typescript/index.md index fd7c9e91..166c1575 100644 --- a/docs/sdks/typescript/index.md +++ b/docs/sdks/typescript/index.md @@ -10,11 +10,11 @@ First, create a new client project, and add the following to your `tsconfig.json ```json { - "compilerOptions": { - //You can use any target higher than this one - //https://www.typescriptlang.org/tsconfig#target - "target": "es2015" - } + "compilerOptions": { + //You can use any target higher than this one + //https://www.typescriptlang.org/tsconfig#target + "target": "es2015" + } } ``` @@ -77,11 +77,11 @@ quickstart-chat Import the `module_bindings` in your client's _main_ file: ```typescript -import { SpacetimeDBClient, Identity } from "@clockworklabs/spacetimedb-sdk"; +import { SpacetimeDBClient, Identity } from '@clockworklabs/spacetimedb-sdk'; -import Person from "./module_bindings/person"; -import AddReducer from "./module_bindings/add_reducer"; -import SayHelloReducer from "./module_bindings/say_hello_reducer"; +import Person from './module_bindings/person'; +import AddReducer from './module_bindings/add_reducer'; +import SayHelloReducer from './module_bindings/say_hello_reducer'; console.log(Person, AddReducer, SayHelloReducer); ``` @@ -92,7 +92,7 @@ console.log(Person, AddReducer, SayHelloReducer); ### Classes | Class | Description | -|-------------------------------------------------|------------------------------------------------------------------------------| +| ----------------------------------------------- | ---------------------------------------------------------------------------- | | [`SpacetimeDBClient`](#class-spacetimedbclient) | The database client connection to a SpacetimeDB server. | | [`Identity`](#class-identity) | The user's public identity. | | [`Address`](#class-address) | An opaque identifier for differentiating connections by the same `Identity`. | @@ -142,17 +142,12 @@ new SpacetimeDBClient(host: string, name_or_address: string, auth_token?: string #### Example ```ts -const host = "ws://localhost:3000"; -const name_or_address = "database_name"; +const host = 'ws://localhost:3000'; +const name_or_address = 'database_name'; const auth_token = undefined; -const protocol = "binary"; +const protocol = 'binary'; -var spacetimeDBClient = new SpacetimeDBClient( - host, - name_or_address, - auth_token, - protocol -); +var spacetimeDBClient = new SpacetimeDBClient(host, name_or_address, auth_token, protocol); ``` ## Class methods @@ -167,9 +162,9 @@ registerReducers(...reducerClasses: ReducerClass[]) #### Parameters -| Name | Type | Description | -| :----------------- | :------- | :------------------------------------------------------------------------------------------------------------------------------------------ | -| `reducerClasses` | `ReducerClass` | A list of classes to register | +| Name | Type | Description | +| :--------------- | :------------- | :---------------------------- | +| `reducerClasses` | `ReducerClass` | A list of classes to register | #### Example @@ -192,9 +187,9 @@ registerTables(...reducerClasses: TableClass[]) #### Parameters -| Name | Type | Description | -| :----------------- | :------- | :------------------------------------------------------------------------------------------------------------------------------------------ | -| `tableClasses` | `TableClass` | A list of classes to register | +| Name | Type | Description | +| :------------- | :----------- | :---------------------------- | +| `tableClasses` | `TableClass` | A list of classes to register | #### Example @@ -239,10 +234,10 @@ token: string | undefined #### Parameters -| Name | Type | Description | -| :------------ | :----------------------------------------------------- | :------------------------------ | -| `reducerName` | `string` | The name of the reducer to call | -| `serializer` | [`Serializer`](../interfaces/serializer.Serializer.md) | - | +| Name | Type | Description | +| :------------ | :----------- | :------------------------------ | +| `reducerName` | `string` | The name of the reducer to call | +| `serializer` | `Serializer` | - | --- @@ -269,15 +264,11 @@ connect(host: string?, name_or_address: string?, auth_token: string?): Promise `void` | #### Example ```ts spacetimeDBClient.onConnect((token, identity, address) => { - console.log("Connected to SpacetimeDB"); - console.log("Token", token); - console.log("Identity", identity); - console.log("Address", address); + console.log('Connected to SpacetimeDB'); + console.log('Token', token); + console.log('Identity', identity); + console.log('Address', address); }); ``` @@ -382,7 +370,7 @@ onError(callback: (...args: any[]) => void): void ```ts spacetimeDBClient.onError((...args: any[]) => { - console.error("ERROR", args); + console.error('ERROR', args); }); ``` @@ -475,13 +463,13 @@ An opaque identifier for a client connection to a database, intended to differen Defined in [spacetimedb-sdk.address](https://github.com/clockworklabs/spacetimedb-typescript-sdk/blob/main/src/address.ts): -| Constructors | Description | -| ----------------------------------------------- | -------------------------------------------- | +| Constructors | Description | +| --------------------------------------------- | ------------------------------------------- | | [`Address.constructor`](#address-constructor) | Creates a new `Address`. | -| Methods | | -| [`Address.isEqual`](#address-isequal) | Compare two identities for equality. | +| Methods | | +| [`Address.isEqual`](#address-isequal) | Compare two identities for equality. | | [`Address.toHexString`](#address-tohexstring) | Print the address as a hexadecimal string. | -| Static methods | | +| Static methods | | | [`Address.fromString`](#address-fromstring) | Parse an Address from a hexadecimal string. | ## Constructors @@ -510,15 +498,15 @@ isEqual(other: Address): boolean #### Parameters -| Name | Type | -| :------ | :---------------------------- | +| Name | Type | +| :------ | :-------------------------- | | `other` | [`Address`](#class-address) | #### Returns `boolean` -___ +--- ### `Address` toHexString @@ -532,7 +520,7 @@ toHexString(): string `string` -___ +--- ### `Address` fromString @@ -607,17 +595,14 @@ Return all the subscribed rows in the table. #### Example ```ts -var spacetimeDBClient = new SpacetimeDBClient( - "ws://localhost:3000", - "database_name" -); +var spacetimeDBClient = new SpacetimeDBClient('ws://localhost:3000', 'database_name'); spacetimeDBClient.onConnect((token, identity, address) => { - spacetimeDBClient.subscribe(["SELECT * FROM Person"]); + spacetimeDBClient.subscribe(['SELECT * FROM Person']); - setTimeout(() => { - console.log(Person.all()); // Prints all the `Person` rows in the database. - }, 5000); + setTimeout(() => { + console.log(Person.all()); // Prints all the `Person` rows in the database. + }, 5000); }); ``` @@ -638,17 +623,14 @@ Return the number of subscribed rows in the table, or 0 if there is no active co #### Example ```ts -var spacetimeDBClient = new SpacetimeDBClient( - "ws://localhost:3000", - "database_name" -); +var spacetimeDBClient = new SpacetimeDBClient('ws://localhost:3000', 'database_name'); spacetimeDBClient.onConnect((token, identity, address) => { - spacetimeDBClient.subscribe(["SELECT * FROM Person"]); + spacetimeDBClient.subscribe(['SELECT * FROM Person']); - setTimeout(() => { - console.log(Person.count()); - }, 5000); + setTimeout(() => { + console.log(Person.count()); + }, 5000); }); ``` @@ -677,17 +659,14 @@ These methods are named `filterBy{COLUMN}`, where `{COLUMN}` is the column name #### Example ```ts -var spacetimeDBClient = new SpacetimeDBClient( - "ws://localhost:3000", - "database_name" -); +var spacetimeDBClient = new SpacetimeDBClient('ws://localhost:3000', 'database_name'); spacetimeDBClient.onConnect((token, identity, address) => { - spacetimeDBClient.subscribe(["SELECT * FROM Person"]); + spacetimeDBClient.subscribe(['SELECT * FROM Person']); - setTimeout(() => { - console.log(Person.filterByName("John")); // prints all the `Person` rows named John. - }, 5000); + setTimeout(() => { + console.log(Person.filterByName('John')); // prints all the `Person` rows named John. + }, 5000); }); ``` @@ -746,20 +725,17 @@ Register an `onInsert` callback for when a subscribed row is newly inserted into #### Example ```ts -var spacetimeDBClient = new SpacetimeDBClient( - "ws://localhost:3000", - "database_name" -); +var spacetimeDBClient = new SpacetimeDBClient('ws://localhost:3000', 'database_name'); spacetimeDBClient.onConnect((token, identity, address) => { - spacetimeDBClient.subscribe(["SELECT * FROM Person"]); + spacetimeDBClient.subscribe(['SELECT * FROM Person']); }); Person.onInsert((person, reducerEvent) => { - if (reducerEvent) { - console.log("New person inserted by reducer", reducerEvent, person); - } else { - console.log("New person received during subscription update", person); - } + if (reducerEvent) { + console.log('New person inserted by reducer', reducerEvent, person); + } else { + console.log('New person received during subscription update', person); + } }); ``` @@ -800,16 +776,13 @@ Register an `onUpdate` callback to run when an existing row is modified by prima #### Example ```ts -var spacetimeDBClient = new SpacetimeDBClient( - "ws://localhost:3000", - "database_name" -); +var spacetimeDBClient = new SpacetimeDBClient('ws://localhost:3000', 'database_name'); spacetimeDBClient.onConnect((token, identity, address) => { - spacetimeDBClient.subscribe(["SELECT * FROM Person"]); + spacetimeDBClient.subscribe(['SELECT * FROM Person']); }); Person.onUpdate((oldPerson, newPerson, reducerEvent) => { - console.log("Person updated by reducer", reducerEvent, oldPerson, newPerson); + console.log('Person updated by reducer', reducerEvent, oldPerson, newPerson); }); ``` @@ -848,23 +821,17 @@ Register an `onDelete` callback for when a subscribed row is removed from the da #### Example ```ts -var spacetimeDBClient = new SpacetimeDBClient( - "ws://localhost:3000", - "database_name" -); +var spacetimeDBClient = new SpacetimeDBClient('ws://localhost:3000', 'database_name'); spacetimeDBClient.onConnect((token, identity, address) => { - spacetimeDBClient.subscribe(["SELECT * FROM Person"]); + spacetimeDBClient.subscribe(['SELECT * FROM Person']); }); Person.onDelete((person, reducerEvent) => { - if (reducerEvent) { - console.log("Person deleted by reducer", reducerEvent, person); - } else { - console.log( - "Person no longer subscribed during subscription update", - person - ); - } + if (reducerEvent) { + console.log('Person deleted by reducer', reducerEvent, person); + } else { + console.log('Person no longer subscribed during subscription update', person); + } }); ``` @@ -929,14 +896,14 @@ Clients will only be notified of reducer runs if either of two criteria is met: #### Parameters -| Name | Type | -| :--------- | :---------------------------------------------------------- | +| Name | Type | +| :--------- | :------------------------------------------------------------- | | `callback` | `(reducerEvent: ReducerEvent, ...reducerArgs: any[]) => void)` | #### Example ```ts SayHelloReducer.on((reducerEvent, ...reducerArgs) => { - console.log("SayHelloReducer called", reducerEvent, reducerArgs); + console.log('SayHelloReducer called', reducerEvent, reducerArgs); }); ``` diff --git a/docs/unity/index.md b/docs/unity/index.md index 2b8e6d67..76970748 100644 --- a/docs/unity/index.md +++ b/docs/unity/index.md @@ -9,15 +9,17 @@ We'll give you some CLI commands to execute. If you are using Windows, we recomm Tested with UnityEngine `2022.3.20f1 LTS` (and may also work on newer versions). ## Unity Tutorial - Basic Multiplayer + Get started with the core client-server setup. For part 2, you may choose your server module preference of [Rust](/docs/modules/rust) or [C#](/docs/modules/c-sharp): -- [Part 1 - Setup](/docs/unity/part-1.md) -- [Part 2a - Server (Rust)](/docs/unity/part-2a-rust.md) -- [Part 2b - Server (C#)](/docs/unity/part-2b-csharp.md) -- [Part 3 - Client](/docs/unity/part-3.md) +- [Part 1 - Setup](/docs/unity/part-1) +- [Part 2a - Server (Rust)](/docs/unity/part-2a-rust) +- [Part 2b - Server (C#)](/docs/unity/part-2b-csharp) +- [Part 3 - Client](/docs/unity/part-3) ## Unity Tutorial - Advanced + By this point, you should already have a basic understanding of SpacetimeDB client, server and CLI: -- [Part 4 - Resources & Scheduling](/docs/unity/part-4.md) -- [Part 5 - BitCraft Mini](/docs/unity/part-5.md) +- [Part 4 - Resources & Scheduling](/docs/unity/part-4) +- [Part 5 - BitCraft Mini](/docs/unity/part-5) diff --git a/docs/unity/part-1.md b/docs/unity/part-1.md index b8b8c3c0..0db2f5aa 100644 --- a/docs/unity/part-1.md +++ b/docs/unity/part-1.md @@ -8,7 +8,7 @@ Need help with the tutorial? [Join our Discord server](https://discord.gg/spacet This project is separated into two sub-projects; -1. Server (module) code +1. Server (module) code 2. Client code First, we'll create a project root directory (you can choose the name): @@ -107,7 +107,7 @@ spacetime start ``` 💡 Standalone mode will run in the foreground. -💡 Below examples Rust language, [but you may also use C#](../modules/c-sharp/index.md). +💡 Below examples Rust language, [but you may also use C#](../modules/c-sharp). ### The Entity Component Systems (ECS) @@ -118,5 +118,6 @@ We chose ECS for this example project because it promotes scalability, modularit ### Create the Server Module From here, the tutorial continues with your favorite server module language of choice: - - [Rust](part-2a-rust.md) - - [C#](part-2b-csharp.md) + +- [Rust](part-2a-rust) +- [C#](part-2b-csharp) diff --git a/docs/unity/part-2a-rust.md b/docs/unity/part-2a-rust.md index 9b12de47..fd9361f2 100644 --- a/docs/unity/part-2a-rust.md +++ b/docs/unity/part-2a-rust.md @@ -2,7 +2,7 @@ Need help with the tutorial? [Join our Discord server](https://discord.gg/spacetimedb)! -This progressive tutorial is continued from the [Part 1 Tutorial](/docs/unity/part-1.md) +This progressive tutorial is continued from the [Part 1 Tutorial](/docs/unity/part-1) ## Create a Server Module @@ -84,7 +84,7 @@ Next, we will define the `PlayerComponent` table. The `PlayerComponent` table is **Append to the bottom of lib.rs:** ```rust -// All players have this component and it associates an entity with the user's +// All players have this component and it associates an entity with the user's // Identity. It also stores their username and whether or not they're logged in. #[derive(Clone)] #[spacetimedb(table)] @@ -92,7 +92,7 @@ pub struct PlayerComponent { // An entity_id that matches an entity_id in the `EntityComponent` table. #[primarykey] pub entity_id: u64, - + // The user's identity, which is unique to each player #[unique] pub owner_id: Identity, @@ -120,9 +120,9 @@ pub fn create_player(ctx: ReducerContext, username: String) -> Result<(), String } // Create a new entity for this player and get a unique `entity_id`. - let entity_id = EntityComponent::insert(EntityComponent - { - entity_id: 0, + let entity_id = EntityComponent::insert(EntityComponent + { + entity_id: 0, position: StdbVector3 { x: 0.0, y: 0.0, z: 0.0 }, direction: 0.0, moving: false, @@ -183,6 +183,7 @@ pub fn client_connected(ctx: ReducerContext) { update_player_login_state(ctx, true); } ``` + ```rust // Called when the client disconnects, we update the logged_in state to false #[spacetimedb(disconnect)] @@ -190,6 +191,7 @@ pub fn client_disconnected(ctx: ReducerContext) { update_player_login_state(ctx, false); } ``` + ```rust // This helper function gets the PlayerComponent, sets the logged // in variable and updates the PlayerComponent table row. @@ -230,7 +232,7 @@ pub fn update_player_position( } } - // If we can not find the PlayerComponent or EntityComponent for + // If we can not find the PlayerComponent or EntityComponent for // this player then something went wrong. return Err("Player not found".to_string()); } @@ -257,7 +259,7 @@ spacetime publish -c unity-tutorial The client project has a chat window, but so far, all it's used for is the message of the day. We are going to add the ability for players to send chat messages to each other. -First lets add a new `ChatMessage` table to the SpacetimeDB module. Add the following code to ``lib.rs``. +First lets add a new `ChatMessage` table to the SpacetimeDB module. Add the following code to `lib.rs`. **Append to the bottom of server/src/lib.rs:** @@ -309,4 +311,4 @@ Now that we added chat support, let's publish the latest module version to Space spacetime publish -c unity-tutorial ``` -From here, the [next tutorial](/docs/unity/part-3.md) continues with a Client (Unity) focus. +From here, the [next tutorial](/docs/unity/part-3) continues with a Client (Unity) focus. diff --git a/docs/unity/part-2b-c-sharp.md b/docs/unity/part-2b-c-sharp.md index f324a36d..ee6c0028 100644 --- a/docs/unity/part-2b-c-sharp.md +++ b/docs/unity/part-2b-c-sharp.md @@ -2,7 +2,7 @@ Need help with the tutorial? [Join our Discord server](https://discord.gg/spacetimedb)! -This progressive tutorial is continued from the [Part 1 Tutorial](/docs/unity/part-1.md) +This progressive tutorial is continued from the [Part 1 Tutorial](/docs/unity/part-1) ## Create a Server Module @@ -91,7 +91,7 @@ public partial class PlayerComponent // An EntityId that matches an EntityId in the `EntityComponent` table. [SpacetimeDB.Column(ColumnAttrs.PrimaryKey)] public ulong EntityId; - + // The user's identity, which is unique to each player [SpacetimeDB.Column(ColumnAttrs.Unique)] public Identity Identity; @@ -136,7 +136,7 @@ public static void CreatePlayer(DbEventArgs dbEvent, string username) Log("Error: Failed to create a unique PlayerComponent", LogLevel.Error); Throw; } - + // The PlayerComponent uses the same entity_id and stores the identity of // the owner, username, and whether or not they are logged in. try @@ -207,12 +207,14 @@ We use the `Connect` and `Disconnect` reducers to update the logged in state of public static void ClientConnected(DbEventArgs dbEvent) => UpdatePlayerLoginState(dbEvent, loggedIn:true); ``` + ```csharp /// Called when the client disconnects, we update the logged_in state to false [SpacetimeDB.Reducer(ReducerKind.Disconnect)] public static void ClientDisonnected(DbEventArgs dbEvent) => UpdatePlayerLoginState(dbEvent, loggedIn:false); ``` + ```csharp /// This helper function gets the PlayerComponent, sets the LoggedIn /// variable and updates the PlayerComponent table row. @@ -257,7 +259,7 @@ private static void UpdatePlayerPosition( { throw new ArgumentException($"Player Entity '{playerEntityId}' not found"); } - + entity.Position = position; entity.Direction = direction; entity.Moving = moving; @@ -286,7 +288,7 @@ spacetime publish -c unity-tutorial The client project has a chat window, but so far, all it's used for is the message of the day. We are going to add the ability for players to send chat messages to each other. -First lets add a new `ChatMessage` table to the SpacetimeDB module. Add the following code to ``lib.cs``. +First lets add a new `ChatMessage` table to the SpacetimeDB module. Add the following code to `lib.cs`. **Append to the bottom of server/src/lib.cs:** @@ -296,10 +298,10 @@ public partial class ChatMessage { // The primary key for this table will be auto-incremented [SpacetimeDB.Column(ColumnAttrs.PrimaryKeyAuto)] - + // The entity id of the player that sent the message public ulong SenderId; - + // Message contents public string? Text; } @@ -341,4 +343,4 @@ Now that we added chat support, let's publish the latest module version to Space spacetime publish -c unity-tutorial ``` -From here, the [next tutorial](/docs/unity/part-3.md) continues with a Client (Unity) focus. \ No newline at end of file +From here, the [next tutorial](/docs/unity/part-3) continues with a Client (Unity) focus. diff --git a/docs/unity/part-3.md b/docs/unity/part-3.md index c80000e1..d1db4dbb 100644 --- a/docs/unity/part-3.md +++ b/docs/unity/part-3.md @@ -2,9 +2,10 @@ Need help with the tutorial? [Join our Discord server](https://discord.gg/spacetimedb)! -This progressive tutorial is continued from one of the Part 2 tutorials: -- [Rust Server Module](/docs/unity/part-2a-rust.md) -- [C# Server Module](/docs/unity/part-2b-c-sharp.md) +This progressive tutorial is continued from one of the Part 2 tutorials: + +- [Rust Server Module](/docs/unity/part-2a-rust) +- [C# Server Module](/docs/unity/part-2b-c-sharp) ## Updating our Unity Project Client to use SpacetimeDB @@ -161,7 +162,7 @@ Then we're doing a modification to the `ButtonPressed()` function: ```csharp public void ButtonPressed() -{ +{ CameraController.RemoveDisabler(GetHashCode()); _panel.SetActive(false); @@ -205,11 +206,11 @@ public class RemotePlayer : MonoBehaviour string inputUsername = UsernameElement.Text; Debug.Log($"PlayerComponent not found - Creating a new player ({inputUsername})"); Reducer.CreatePlayer(inputUsername); - + // Try again, optimistically assuming success for simplicity PlayerComponent? playerComp = PlayerComponent.FilterByEntityId(EntityId).FirstOrDefault(); } - + Username = playerComp.Username; // Get the last location for this player and set the initial position @@ -268,16 +269,16 @@ private void PlayerComponent_OnInsert(PlayerComponent obj, ReducerEvent callInfo { // Spawn the player object and attach the RemotePlayer component var remotePlayer = Instantiate(PlayerPrefab); - + // Lookup and apply the position for this new player var entity = EntityComponent.FilterByEntityId(obj.EntityId); var position = new Vector3(entity.Position.X, entity.Position.Y, entity.Position.Z); remotePlayer.transform.position = position; - + var movementController = remotePlayer.GetComponent(); movementController.RemoteTargetPosition = position; movementController.RemoteTargetRotation = entity.Direction; - + remotePlayer.AddComponent().EntityId = obj.EntityId; } } @@ -309,7 +310,7 @@ private void FixedUpdate() lastUpdateTime = Time.time; var p = PlayerMovementController.Local.GetModelPosition(); - + Reducer.UpdatePlayerPosition(new StdbVector3 { X = p.x, @@ -338,6 +339,7 @@ When you hit the `Build` button, it will kick off a build of the game which will So far we have not handled the `logged_in` variable of the `PlayerComponent`. This means that remote players will not despawn on your screen when they disconnect. To fix this we need to handle the `OnUpdate` event for the `PlayerComponent` table in addition to `OnInsert`. We are going to use a common function that handles any time the `PlayerComponent` changes. **Append to the bottom of Start() function in TutorialGameManager.cs** + ```csharp PlayerComponent.OnUpdate += PlayerComponent_OnUpdate; ``` @@ -347,6 +349,7 @@ We are going to add a check to determine if the player is logged for remote play Next we'll be updating some of the code in `PlayerComponent_OnInsert`. For simplicity, just replace the entire function. **REPLACE PlayerComponent_OnInsert in TutorialGameManager.cs** + ```csharp private void PlayerComponent_OnUpdate(PlayerComponent oldValue, PlayerComponent newValue, ReducerEvent dbEvent) { @@ -377,16 +380,16 @@ private void OnPlayerComponentChanged(PlayerComponent obj) { // Spawn the player object and attach the RemotePlayer component var remotePlayer = Instantiate(PlayerPrefab); - + // Lookup and apply the position for this new player var entity = EntityComponent.FilterByEntityId(obj.EntityId); var position = new Vector3(entity.Position.X, entity.Position.Y, entity.Position.Z); remotePlayer.transform.position = position; - + var movementController = remotePlayer.GetComponent(); movementController.RemoteTargetPosition = position; movementController.RemoteTargetRotation = entity.Direction; - + remotePlayer.AddComponent().EntityId = obj.EntityId; } } @@ -406,6 +409,7 @@ Now you when you play the game you should see remote players disappear when they Before updating the client, let's generate the client files and update publish our module. **Execute commands in the server/ directory** + ```bash spacetime generate --out-dir ../client/Assets/module_bindings --lang=csharp spacetime publish -c unity-tutorial @@ -414,6 +418,7 @@ spacetime publish -c unity-tutorial On the client, let's add code to send the message when the chat button or enter is pressed. Update the `OnChatButtonPress` function in `UIChatController.cs`. **Append to the top of UIChatController.cs:** + ```csharp using SpacetimeDB.Types; ``` @@ -431,6 +436,7 @@ public void OnChatButtonPress() Now we need to add a reducer to handle inserting new chat messages. First register for the ChatMessage reducer in the `Start()` function using the auto-generated function: **Append to the bottom of the Start() function in TutorialGameManager.cs:** + ```csharp Reducer.OnSendChatMessageEvent += OnSendChatMessageEvent; ``` @@ -438,6 +444,7 @@ Reducer.OnSendChatMessageEvent += OnSendChatMessageEvent; Now we write the `OnSendChatMessageEvent` function. We can find the `PlayerComponent` for the player who sent the message using the `Identity` of the sender. Then we get the `Username` and prepend it to the message before sending it to the chat window. **Append after the Start() function in TutorialGameManager.cs** + ```csharp private void OnSendChatMessageEvent(ReducerEvent dbEvent, string message) { @@ -455,7 +462,7 @@ Now when you run the game you should be able to send chat messages to other play This concludes the SpacetimeDB basic multiplayer tutorial, where we learned how to create a multiplayer game. In the next Unity tutorial, we will add resource nodes to the game and learn about _scheduled_ reducers: -From here, the tutorial continues with more-advanced topics: The [next tutorial](/docs/unity/part-4.md) introduces Resources & Scheduling. +From here, the tutorial continues with more-advanced topics: The [next tutorial](/docs/unity/part-4) introduces Resources & Scheduling. --- diff --git a/docs/unity/part-4.md b/docs/unity/part-4.md index a87f27a2..b8af1018 100644 --- a/docs/unity/part-4.md +++ b/docs/unity/part-4.md @@ -2,7 +2,7 @@ Need help with the tutorial? [Join our Discord server](https://discord.gg/spacetimedb)! -This progressive tutorial is continued from the [Part 3](/docs/unity/part-3.md) Tutorial. +This progressive tutorial is continued from the [Part 3](/docs/unity/part-3) Tutorial. **Oct 14th, 2023: This tutorial has not yet been updated for the recent 0.7.0 release, it will be updated asap!** diff --git a/docs/unity/part-5.md b/docs/unity/part-5.md index 6ebce1c0..2c59c73b 100644 --- a/docs/unity/part-5.md +++ b/docs/unity/part-5.md @@ -2,7 +2,7 @@ Need help with the tutorial? [Join our Discord server](https://discord.gg/spacetimedb)! -This progressive tutorial is continued from the [Part 4](/docs/unity/part-3.md) Tutorial. +This progressive tutorial is continued from the [Part 4](/docs/unity/part-3) Tutorial. **Oct 14th, 2023: This tutorial has not yet been updated for the recent 0.7.0 release, it will be updated asap!** From bb0efc66be695b461dbe4a9968379d9e2756c83c Mon Sep 17 00:00:00 2001 From: Zeke Foppa <196249+bfops@users.noreply.github.com> Date: Wed, 22 May 2024 17:01:27 -0700 Subject: [PATCH 041/164] Fix the C# module link in overview (#54) * [bfops/docs]: C# fix * [bfops/docs]: empty --------- Co-authored-by: Zeke Foppa --- docs/unity/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/unity/index.md b/docs/unity/index.md index 76970748..0fa181c6 100644 --- a/docs/unity/index.md +++ b/docs/unity/index.md @@ -14,7 +14,7 @@ Get started with the core client-server setup. For part 2, you may choose your s - [Part 1 - Setup](/docs/unity/part-1) - [Part 2a - Server (Rust)](/docs/unity/part-2a-rust) -- [Part 2b - Server (C#)](/docs/unity/part-2b-csharp) +- [Part 2b - Server (C#)](/docs/unity/part-2b-c-sharp) - [Part 3 - Client](/docs/unity/part-3) ## Unity Tutorial - Advanced From 38e769c0ae1a7fa222717316d0ffd66b5dbaa136 Mon Sep 17 00:00:00 2001 From: Zeke Foppa <196249+bfops@users.noreply.github.com> Date: Wed, 22 May 2024 19:23:32 -0700 Subject: [PATCH 042/164] Fix the C# module link in part 1 (#55) * [bfops/docs]: empty * [bfops/docs]: one more fix --------- Co-authored-by: Zeke Foppa --- docs/unity/part-1.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/unity/part-1.md b/docs/unity/part-1.md index 0db2f5aa..c53814d1 100644 --- a/docs/unity/part-1.md +++ b/docs/unity/part-1.md @@ -120,4 +120,4 @@ We chose ECS for this example project because it promotes scalability, modularit From here, the tutorial continues with your favorite server module language of choice: - [Rust](part-2a-rust) -- [C#](part-2b-csharp) +- [C#](part-2b-c-sharp) From 0ddd4eb3dadd5b5979301bfe4c2cd190859563a0 Mon Sep 17 00:00:00 2001 From: Zeke Foppa <196249+bfops@users.noreply.github.com> Date: Thu, 6 Jun 2024 13:41:59 -0700 Subject: [PATCH 043/164] Update docs for `#[spacetimedb(table)]` (#61) * [bfops/public-tables]: update docs for #[spacetimedb(table)] * [bfops/public-tables]: review --------- Co-authored-by: Zeke Foppa --- docs/modules/rust/index.md | 24 +++++++++++++----------- docs/modules/rust/quickstart.md | 12 ++++++++---- docs/unity/part-2a-rust.md | 12 ++++++------ docs/unity/part-2b-c-sharp.md | 2 +- docs/unity/part-4.md | 6 +++--- 5 files changed, 31 insertions(+), 25 deletions(-) diff --git a/docs/modules/rust/index.md b/docs/modules/rust/index.md index 05d62bdc..5e64051d 100644 --- a/docs/modules/rust/index.md +++ b/docs/modules/rust/index.md @@ -31,7 +31,7 @@ use spacetimedb::{spacetimedb, println}; // This macro lets us interact with a SpacetimeDB table of Person rows. // We can insert and delete into, and query, this table by the collection // of functions generated by the macro. -#[spacetimedb(table)] +#[spacetimedb(table(public))] pub struct Person { name: String, } @@ -88,10 +88,12 @@ Now we'll get into details on all the macro APIs SpacetimeDB provides, starting ### Defining tables -`#[spacetimedb(table)]` takes no further arguments, and is applied to a Rust struct with named fields: +The `#[spacetimedb(table)]` is applied to a Rust struct with named fields. +By default, tables are considered **private**. This means that they are only readable by the table owner, and by server module code. +The `#[spacetimedb(table(public))]` macro makes a table public. **Public** tables are readable by all users, but can still only be modified by your server module code. ```rust -#[spacetimedb(table)] +#[spacetimedb(table(public))] struct Table { field1: String, field2: u32, @@ -116,10 +118,10 @@ And common data structures: - `Option where T: SpacetimeType` - `Vec where T: SpacetimeType` -All `#[spacetimedb(table)]` types are `SpacetimeType`s, and accordingly, all of their fields have to be. +All `#[spacetimedb(table(...))]` types are `SpacetimeType`s, and accordingly, all of their fields have to be. ```rust -#[spacetimedb(table)] +#[spacetimedb(table(public))] struct AnotherTable { // Fine, some builtin types. id: u64, @@ -151,7 +153,7 @@ enum Serial { Once the table is created via the macro, other attributes described below can control more aspects of the table. For instance, a particular column can be indexed, or take on values of an automatically incremented counter. These are described in detail below. ```rust -#[spacetimedb(table)] +#[spacetimedb(table(public))] struct Person { #[unique] id: u64, @@ -269,7 +271,7 @@ We'll work off these structs to see what functions SpacetimeDB generates: This table has a plain old column. ```rust -#[spacetimedb(table)] +#[spacetimedb(table(public))] struct Ordinary { ordinary_field: u64, } @@ -278,7 +280,7 @@ struct Ordinary { This table has a unique column. Every row in the `Person` table must have distinct values of the `unique_field` column. Attempting to insert a row with a duplicate value will fail. ```rust -#[spacetimedb(table)] +#[spacetimedb(table(public))] struct Unique { // A unique column: #[unique] @@ -291,7 +293,7 @@ This table has an automatically incrementing column. SpacetimeDB automatically p Only integer types can be `#[unique]`: `u8`, `u16`, `u32`, `u64`, `u128`, `i8`, `i16`, `i32`, `i64` and `i128`. ```rust -#[spacetimedb(table)] +#[spacetimedb(table(public))] struct Autoinc { #[autoinc] autoinc_field: u64, @@ -301,7 +303,7 @@ struct Autoinc { These attributes can be combined, to create an automatically assigned ID usable for filtering. ```rust -#[spacetimedb(table)] +#[spacetimedb(table(public))] struct Identity { #[autoinc] #[unique] @@ -375,7 +377,7 @@ fn insert_id() { Given a table, we can iterate over all the rows in it. ```rust -#[spacetimedb(table)] +#[spacetimedb(table(public))] struct Person { #[unique] id: u64, diff --git a/docs/modules/rust/quickstart.md b/docs/modules/rust/quickstart.md index e015b881..062aa163 100644 --- a/docs/modules/rust/quickstart.md +++ b/docs/modules/rust/quickstart.md @@ -6,7 +6,11 @@ A SpacetimeDB module is code that gets compiled to WebAssembly and is uploaded t Each SpacetimeDB module defines a set of tables and a set of reducers. -Each table is defined as a Rust `struct` annotated with `#[spacetimedb(table)]`, where an instance represents a row, and each field represents a column. +Each table is defined as a Rust `struct` annotated with `#[spacetimedb(table(...))]`, where an instance represents a row, and each field represents a column. +By default, tables are **private**. This means that they are only readable by the table owner, and by server module code. +The `#[spacetimedb(table(public))]` macro makes a table public. **Public** tables are readable by all users, but can still only be modified by your server module code. + +_Coming soon: We plan to add much more robust access controls than just `public` or `private`. Stay tuned!_ A reducer is a function which 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. In Rust, reducers are defined as functions annotated with `#[spacetimedb(reducer)]`, and may return a `Result<()>`, with an `Err` return aborting the transaction. @@ -67,7 +71,7 @@ For each `User`, we'll store their `Identity`, an optional name they can set to To `server/src/lib.rs`, add the definition of the table `User`: ```rust -#[spacetimedb(table)] +#[spacetimedb(table(public))] pub struct User { #[primarykey] identity: Identity, @@ -81,7 +85,7 @@ For each `Message`, we'll store the `Identity` of the user who sent it, the `Tim To `server/src/lib.rs`, add the definition of the table `Message`: ```rust -#[spacetimedb(table)] +#[spacetimedb(table(public))] pub struct Message { sender: Identity, sent: Timestamp, @@ -179,7 +183,7 @@ You could extend the validation in `validate_message` in similar ways to `valida Whenever a client connects, the module will run a special reducer, annotated with `#[spacetimedb(connect)]`, if it's defined. By convention, it's named `identity_connected`. We'll use it to create a `User` record for the client if it doesn't yet exist, and to set its online status. -We'll use `User::filter_by_identity` to look up a `User` row for `ctx.sender`, if one exists. If we find one, we'll use `User::update_by_identity` to overwrite it with a row that has `online: true`. If not, we'll use `User::insert` to insert a new row for our new user. All three of these methods are generated by the `#[spacetimedb(table)]` attribute, with rows and behavior based on the row attributes. `filter_by_identity` returns an `Option`, because the unique constraint from the `#[primarykey]` attribute means there will be either zero or one matching rows. `insert` returns a `Result<(), UniqueConstraintViolation>` because of the same unique constraint; if we want to overwrite a `User` row, we need to do so explicitly using `update_by_identity`. +We'll use `User::filter_by_identity` to look up a `User` row for `ctx.sender`, if one exists. If we find one, we'll use `User::update_by_identity` to overwrite it with a row that has `online: true`. If not, we'll use `User::insert` to insert a new row for our new user. All three of these methods are generated by the `#[spacetimedb(table(...))]` attribute, with rows and behavior based on the row attributes. `filter_by_identity` returns an `Option`, because the unique constraint from the `#[primarykey]` attribute means there will be either zero or one matching rows. `insert` returns a `Result<(), UniqueConstraintViolation>` because of the same unique constraint; if we want to overwrite a `User` row, we need to do so explicitly using `update_by_identity`. To `server/src/lib.rs`, add the definition of the connect reducer: diff --git a/docs/unity/part-2a-rust.md b/docs/unity/part-2a-rust.md index fd9361f2..0acac51c 100644 --- a/docs/unity/part-2a-rust.md +++ b/docs/unity/part-2a-rust.md @@ -29,13 +29,13 @@ use spacetimedb::{spacetimedb, Identity, SpacetimeType, ReducerContext}; use log; ``` -Then we are going to start by adding the global `Config` table. Right now it only contains the "message of the day" but it can be extended to store other configuration variables. This also uses a couple of macros, like `#[spacetimedb(table)]` which you can learn more about in our [Rust module reference](/docs/modules/rust). Simply put, this just tells SpacetimeDB to create a table which uses this struct as the schema for the table. +Then we are going to start by adding the global `Config` table. Right now it only contains the "message of the day" but it can be extended to store other configuration variables. This also uses a couple of macros, like `#[spacetimedb(table(...))]` which you can learn more about in our [Rust module reference](/docs/modules/rust) (including making your tables `private`!). Simply put, this just tells SpacetimeDB to create a table which uses this struct as the schema for the table. **Append to the bottom of lib.rs:** ```rust // We're using this table as a singleton, so there should typically only be one element where the version is 0. -#[spacetimedb(table)] +#[spacetimedb(table(public))] #[derive(Clone)] pub struct Config { #[primarykey] @@ -44,7 +44,7 @@ pub struct Config { } ``` -Next, we're going to define a new `SpacetimeType` called `StdbVector3` which we're going to use to store positions. The difference between a `#[derive(SpacetimeType)]` and a `#[spacetimedb(table)]` is that tables actually store data, whereas the deriving `SpacetimeType` just allows you to create a new column of that type in a SpacetimeDB table. Therefore, `StdbVector3` is not, itself, a table. +Next, we're going to define a new `SpacetimeType` called `StdbVector3` which we're going to use to store positions. The difference between a `#[derive(SpacetimeType)]` and a `#[spacetimedb(table(...))]` is that tables actually store data, whereas the deriving `SpacetimeType` just allows you to create a new column of that type in a SpacetimeDB table. Therefore, `StdbVector3` is not, itself, a table. **Append to the bottom of lib.rs:** @@ -64,7 +64,7 @@ Now we're going to create a table which actually uses the `StdbVector3` that we // This stores information related to all entities in our game. In this tutorial // all entities must at least have an entity_id, a position, a direction and they // must specify whether or not they are moving. -#[spacetimedb(table)] +#[spacetimedb(table(public))] #[derive(Clone)] pub struct EntityComponent { #[primarykey] @@ -87,7 +87,7 @@ Next, we will define the `PlayerComponent` table. The `PlayerComponent` table is // All players have this component and it associates an entity with the user's // Identity. It also stores their username and whether or not they're logged in. #[derive(Clone)] -#[spacetimedb(table)] +#[spacetimedb(table(public))] pub struct PlayerComponent { // An entity_id that matches an entity_id in the `EntityComponent` table. #[primarykey] @@ -264,7 +264,7 @@ First lets add a new `ChatMessage` table to the SpacetimeDB module. Add the foll **Append to the bottom of server/src/lib.rs:** ```rust -#[spacetimedb(table)] +#[spacetimedb(table(public))] pub struct ChatMessage { // The primary key for this table will be auto-incremented #[primarykey] diff --git a/docs/unity/part-2b-c-sharp.md b/docs/unity/part-2b-c-sharp.md index ee6c0028..8cdb0947 100644 --- a/docs/unity/part-2b-c-sharp.md +++ b/docs/unity/part-2b-c-sharp.md @@ -30,7 +30,7 @@ using SpacetimeDB.Module; using static SpacetimeDB.Runtime; ``` -Then we are going to start by adding the global `Config` table. Right now it only contains the "message of the day" but it can be extended to store other configuration variables. This also uses a couple of macros, like `#[spacetimedb(table)]` which you can learn more about in our [C# module reference](/docs/modules/c-sharp). Simply put, this just tells SpacetimeDB to create a table which uses this struct as the schema for the table. +Then we are going to start by adding the global `Config` table. Right now it only contains the "message of the day" but it can be extended to store other configuration variables. This also uses a couple of attributes, like `[SpacetimeDB.Table]` which you can learn more about in our [C# module reference](/docs/modules/c-sharp). Simply put, this just tells SpacetimeDB to create a table which uses this struct as the schema for the table. **Append to the bottom of lib.cs:** diff --git a/docs/unity/part-4.md b/docs/unity/part-4.md index b8af1018..b3a17439 100644 --- a/docs/unity/part-4.md +++ b/docs/unity/part-4.md @@ -34,7 +34,7 @@ pub enum ResourceNodeType { Iron, } -#[spacetimedb(table)] +#[spacetimedb(table(public))] #[derive(Clone)] pub struct ResourceNodeComponent { #[primarykey] @@ -48,7 +48,7 @@ pub struct ResourceNodeComponent { Because resource nodes never move, the `MobileEntityComponent` is overkill. Instead, we will add a new entity component named `StaticLocationComponent` that only stores the position and rotation. ```rust -#[spacetimedb(table)] +#[spacetimedb(table(public))] #[derive(Clone)] pub struct StaticLocationComponent { #[primarykey] @@ -62,7 +62,7 @@ pub struct StaticLocationComponent { 3. We are also going to add a couple of additional column to our Config table. `map_extents` let's our spawner know where it can spawn the nodes. `num_resource_nodes` is the maximum number of nodes to spawn on the map. Update the config table in lib.rs. ```rust -#[spacetimedb(table)] +#[spacetimedb(table(public))] pub struct Config { // Config is a global table with a single row. This table will be used to // store configuration or global variables From 93ac70933bdb02911e2f917a043d743298a8d771 Mon Sep 17 00:00:00 2001 From: Zeke Foppa <196249+bfops@users.noreply.github.com> Date: Thu, 6 Jun 2024 13:48:05 -0700 Subject: [PATCH 044/164] Revert "Update docs for `#[spacetimedb(table)]` (#61)" (#62) This reverts commit 0ddd4eb3dadd5b5979301bfe4c2cd190859563a0. --- docs/modules/rust/index.md | 24 +++++++++++------------- docs/modules/rust/quickstart.md | 12 ++++-------- docs/unity/part-2a-rust.md | 12 ++++++------ docs/unity/part-2b-c-sharp.md | 2 +- docs/unity/part-4.md | 6 +++--- 5 files changed, 25 insertions(+), 31 deletions(-) diff --git a/docs/modules/rust/index.md b/docs/modules/rust/index.md index 5e64051d..05d62bdc 100644 --- a/docs/modules/rust/index.md +++ b/docs/modules/rust/index.md @@ -31,7 +31,7 @@ use spacetimedb::{spacetimedb, println}; // This macro lets us interact with a SpacetimeDB table of Person rows. // We can insert and delete into, and query, this table by the collection // of functions generated by the macro. -#[spacetimedb(table(public))] +#[spacetimedb(table)] pub struct Person { name: String, } @@ -88,12 +88,10 @@ Now we'll get into details on all the macro APIs SpacetimeDB provides, starting ### Defining tables -The `#[spacetimedb(table)]` is applied to a Rust struct with named fields. -By default, tables are considered **private**. This means that they are only readable by the table owner, and by server module code. -The `#[spacetimedb(table(public))]` macro makes a table public. **Public** tables are readable by all users, but can still only be modified by your server module code. +`#[spacetimedb(table)]` takes no further arguments, and is applied to a Rust struct with named fields: ```rust -#[spacetimedb(table(public))] +#[spacetimedb(table)] struct Table { field1: String, field2: u32, @@ -118,10 +116,10 @@ And common data structures: - `Option where T: SpacetimeType` - `Vec where T: SpacetimeType` -All `#[spacetimedb(table(...))]` types are `SpacetimeType`s, and accordingly, all of their fields have to be. +All `#[spacetimedb(table)]` types are `SpacetimeType`s, and accordingly, all of their fields have to be. ```rust -#[spacetimedb(table(public))] +#[spacetimedb(table)] struct AnotherTable { // Fine, some builtin types. id: u64, @@ -153,7 +151,7 @@ enum Serial { Once the table is created via the macro, other attributes described below can control more aspects of the table. For instance, a particular column can be indexed, or take on values of an automatically incremented counter. These are described in detail below. ```rust -#[spacetimedb(table(public))] +#[spacetimedb(table)] struct Person { #[unique] id: u64, @@ -271,7 +269,7 @@ We'll work off these structs to see what functions SpacetimeDB generates: This table has a plain old column. ```rust -#[spacetimedb(table(public))] +#[spacetimedb(table)] struct Ordinary { ordinary_field: u64, } @@ -280,7 +278,7 @@ struct Ordinary { This table has a unique column. Every row in the `Person` table must have distinct values of the `unique_field` column. Attempting to insert a row with a duplicate value will fail. ```rust -#[spacetimedb(table(public))] +#[spacetimedb(table)] struct Unique { // A unique column: #[unique] @@ -293,7 +291,7 @@ This table has an automatically incrementing column. SpacetimeDB automatically p Only integer types can be `#[unique]`: `u8`, `u16`, `u32`, `u64`, `u128`, `i8`, `i16`, `i32`, `i64` and `i128`. ```rust -#[spacetimedb(table(public))] +#[spacetimedb(table)] struct Autoinc { #[autoinc] autoinc_field: u64, @@ -303,7 +301,7 @@ struct Autoinc { These attributes can be combined, to create an automatically assigned ID usable for filtering. ```rust -#[spacetimedb(table(public))] +#[spacetimedb(table)] struct Identity { #[autoinc] #[unique] @@ -377,7 +375,7 @@ fn insert_id() { Given a table, we can iterate over all the rows in it. ```rust -#[spacetimedb(table(public))] +#[spacetimedb(table)] struct Person { #[unique] id: u64, diff --git a/docs/modules/rust/quickstart.md b/docs/modules/rust/quickstart.md index 062aa163..e015b881 100644 --- a/docs/modules/rust/quickstart.md +++ b/docs/modules/rust/quickstart.md @@ -6,11 +6,7 @@ A SpacetimeDB module is code that gets compiled to WebAssembly and is uploaded t Each SpacetimeDB module defines a set of tables and a set of reducers. -Each table is defined as a Rust `struct` annotated with `#[spacetimedb(table(...))]`, where an instance represents a row, and each field represents a column. -By default, tables are **private**. This means that they are only readable by the table owner, and by server module code. -The `#[spacetimedb(table(public))]` macro makes a table public. **Public** tables are readable by all users, but can still only be modified by your server module code. - -_Coming soon: We plan to add much more robust access controls than just `public` or `private`. Stay tuned!_ +Each table is defined as a Rust `struct` annotated with `#[spacetimedb(table)]`, where an instance represents a row, and each field represents a column. A reducer is a function which 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. In Rust, reducers are defined as functions annotated with `#[spacetimedb(reducer)]`, and may return a `Result<()>`, with an `Err` return aborting the transaction. @@ -71,7 +67,7 @@ For each `User`, we'll store their `Identity`, an optional name they can set to To `server/src/lib.rs`, add the definition of the table `User`: ```rust -#[spacetimedb(table(public))] +#[spacetimedb(table)] pub struct User { #[primarykey] identity: Identity, @@ -85,7 +81,7 @@ For each `Message`, we'll store the `Identity` of the user who sent it, the `Tim To `server/src/lib.rs`, add the definition of the table `Message`: ```rust -#[spacetimedb(table(public))] +#[spacetimedb(table)] pub struct Message { sender: Identity, sent: Timestamp, @@ -183,7 +179,7 @@ You could extend the validation in `validate_message` in similar ways to `valida Whenever a client connects, the module will run a special reducer, annotated with `#[spacetimedb(connect)]`, if it's defined. By convention, it's named `identity_connected`. We'll use it to create a `User` record for the client if it doesn't yet exist, and to set its online status. -We'll use `User::filter_by_identity` to look up a `User` row for `ctx.sender`, if one exists. If we find one, we'll use `User::update_by_identity` to overwrite it with a row that has `online: true`. If not, we'll use `User::insert` to insert a new row for our new user. All three of these methods are generated by the `#[spacetimedb(table(...))]` attribute, with rows and behavior based on the row attributes. `filter_by_identity` returns an `Option`, because the unique constraint from the `#[primarykey]` attribute means there will be either zero or one matching rows. `insert` returns a `Result<(), UniqueConstraintViolation>` because of the same unique constraint; if we want to overwrite a `User` row, we need to do so explicitly using `update_by_identity`. +We'll use `User::filter_by_identity` to look up a `User` row for `ctx.sender`, if one exists. If we find one, we'll use `User::update_by_identity` to overwrite it with a row that has `online: true`. If not, we'll use `User::insert` to insert a new row for our new user. All three of these methods are generated by the `#[spacetimedb(table)]` attribute, with rows and behavior based on the row attributes. `filter_by_identity` returns an `Option`, because the unique constraint from the `#[primarykey]` attribute means there will be either zero or one matching rows. `insert` returns a `Result<(), UniqueConstraintViolation>` because of the same unique constraint; if we want to overwrite a `User` row, we need to do so explicitly using `update_by_identity`. To `server/src/lib.rs`, add the definition of the connect reducer: diff --git a/docs/unity/part-2a-rust.md b/docs/unity/part-2a-rust.md index 0acac51c..fd9361f2 100644 --- a/docs/unity/part-2a-rust.md +++ b/docs/unity/part-2a-rust.md @@ -29,13 +29,13 @@ use spacetimedb::{spacetimedb, Identity, SpacetimeType, ReducerContext}; use log; ``` -Then we are going to start by adding the global `Config` table. Right now it only contains the "message of the day" but it can be extended to store other configuration variables. This also uses a couple of macros, like `#[spacetimedb(table(...))]` which you can learn more about in our [Rust module reference](/docs/modules/rust) (including making your tables `private`!). Simply put, this just tells SpacetimeDB to create a table which uses this struct as the schema for the table. +Then we are going to start by adding the global `Config` table. Right now it only contains the "message of the day" but it can be extended to store other configuration variables. This also uses a couple of macros, like `#[spacetimedb(table)]` which you can learn more about in our [Rust module reference](/docs/modules/rust). Simply put, this just tells SpacetimeDB to create a table which uses this struct as the schema for the table. **Append to the bottom of lib.rs:** ```rust // We're using this table as a singleton, so there should typically only be one element where the version is 0. -#[spacetimedb(table(public))] +#[spacetimedb(table)] #[derive(Clone)] pub struct Config { #[primarykey] @@ -44,7 +44,7 @@ pub struct Config { } ``` -Next, we're going to define a new `SpacetimeType` called `StdbVector3` which we're going to use to store positions. The difference between a `#[derive(SpacetimeType)]` and a `#[spacetimedb(table(...))]` is that tables actually store data, whereas the deriving `SpacetimeType` just allows you to create a new column of that type in a SpacetimeDB table. Therefore, `StdbVector3` is not, itself, a table. +Next, we're going to define a new `SpacetimeType` called `StdbVector3` which we're going to use to store positions. The difference between a `#[derive(SpacetimeType)]` and a `#[spacetimedb(table)]` is that tables actually store data, whereas the deriving `SpacetimeType` just allows you to create a new column of that type in a SpacetimeDB table. Therefore, `StdbVector3` is not, itself, a table. **Append to the bottom of lib.rs:** @@ -64,7 +64,7 @@ Now we're going to create a table which actually uses the `StdbVector3` that we // This stores information related to all entities in our game. In this tutorial // all entities must at least have an entity_id, a position, a direction and they // must specify whether or not they are moving. -#[spacetimedb(table(public))] +#[spacetimedb(table)] #[derive(Clone)] pub struct EntityComponent { #[primarykey] @@ -87,7 +87,7 @@ Next, we will define the `PlayerComponent` table. The `PlayerComponent` table is // All players have this component and it associates an entity with the user's // Identity. It also stores their username and whether or not they're logged in. #[derive(Clone)] -#[spacetimedb(table(public))] +#[spacetimedb(table)] pub struct PlayerComponent { // An entity_id that matches an entity_id in the `EntityComponent` table. #[primarykey] @@ -264,7 +264,7 @@ First lets add a new `ChatMessage` table to the SpacetimeDB module. Add the foll **Append to the bottom of server/src/lib.rs:** ```rust -#[spacetimedb(table(public))] +#[spacetimedb(table)] pub struct ChatMessage { // The primary key for this table will be auto-incremented #[primarykey] diff --git a/docs/unity/part-2b-c-sharp.md b/docs/unity/part-2b-c-sharp.md index 8cdb0947..ee6c0028 100644 --- a/docs/unity/part-2b-c-sharp.md +++ b/docs/unity/part-2b-c-sharp.md @@ -30,7 +30,7 @@ using SpacetimeDB.Module; using static SpacetimeDB.Runtime; ``` -Then we are going to start by adding the global `Config` table. Right now it only contains the "message of the day" but it can be extended to store other configuration variables. This also uses a couple of attributes, like `[SpacetimeDB.Table]` which you can learn more about in our [C# module reference](/docs/modules/c-sharp). Simply put, this just tells SpacetimeDB to create a table which uses this struct as the schema for the table. +Then we are going to start by adding the global `Config` table. Right now it only contains the "message of the day" but it can be extended to store other configuration variables. This also uses a couple of macros, like `#[spacetimedb(table)]` which you can learn more about in our [C# module reference](/docs/modules/c-sharp). Simply put, this just tells SpacetimeDB to create a table which uses this struct as the schema for the table. **Append to the bottom of lib.cs:** diff --git a/docs/unity/part-4.md b/docs/unity/part-4.md index b3a17439..b8af1018 100644 --- a/docs/unity/part-4.md +++ b/docs/unity/part-4.md @@ -34,7 +34,7 @@ pub enum ResourceNodeType { Iron, } -#[spacetimedb(table(public))] +#[spacetimedb(table)] #[derive(Clone)] pub struct ResourceNodeComponent { #[primarykey] @@ -48,7 +48,7 @@ pub struct ResourceNodeComponent { Because resource nodes never move, the `MobileEntityComponent` is overkill. Instead, we will add a new entity component named `StaticLocationComponent` that only stores the position and rotation. ```rust -#[spacetimedb(table(public))] +#[spacetimedb(table)] #[derive(Clone)] pub struct StaticLocationComponent { #[primarykey] @@ -62,7 +62,7 @@ pub struct StaticLocationComponent { 3. We are also going to add a couple of additional column to our Config table. `map_extents` let's our spawner know where it can spawn the nodes. `num_resource_nodes` is the maximum number of nodes to spawn on the map. Update the config table in lib.rs. ```rust -#[spacetimedb(table(public))] +#[spacetimedb(table)] pub struct Config { // Config is a global table with a single row. This table will be used to // store configuration or global variables From 78a67b263f0c0153bb2b38c92f6a7d5a97586919 Mon Sep 17 00:00:00 2001 From: Kim Altintop Date: Fri, 14 Jun 2024 16:29:25 +0200 Subject: [PATCH 045/164] Update response of `/database/info` (#64) To match changes in clockworklabs/spacetimedb#1305 --- docs/http/database.md | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/docs/http/database.md b/docs/http/database.md index 2d55188a..16ee729c 100644 --- a/docs/http/database.md +++ b/docs/http/database.md @@ -507,20 +507,18 @@ Returns JSON in the form: ```typescript { "address": string, - "identity": string, - "host_type": "wasmer", - "num_replicas": number, - "program_bytes_address": string + "owner_identity": string, + "host_type": "wasm", + "initial_program": string } ``` -| Field | Type | Meaning | -| ------------------------- | ------ | ----------------------------------------------------------- | -| `"address"` | String | The address of the database. | -| `"identity"` | String | The Spacetime identity of the database's owner. | -| `"host_type"` | String | The module host type; currently always `"wasmer"`. | -| `"num_replicas"` | Number | The number of replicas of the database. Currently always 1. | -| `"program_bytes_address"` | String | Hash of the WASM module for the database. | +| Field | Type | Meaning | +| --------------------| ------ | ---------------------------------------------------------------- | +| `"address"` | String | The address of the database. | +| `"owner_identity"` | String | The Spacetime identity of the database's owner. | +| `"host_type"` | String | The module host type; currently always `"wasm"`. | +| `"initial_program"` | String | Hash of the WASM module with which the database was initialized. | ## `/database/logs/:name_or_address GET` From a12354b919adb988d9abdb91d18e14a2a2134e7f Mon Sep 17 00:00:00 2001 From: Zeke Foppa <196249+bfops@users.noreply.github.com> Date: Fri, 14 Jun 2024 08:59:41 -0700 Subject: [PATCH 046/164] Update docs for making tables public/private (#63) * [bfops/revert-premature-merge]: do revert * [bfops/redo-61]: Redo #61 * [bfops/redo-61]: redo * [bfops/redo-61]: add C# * [bfops/redo-61]: review * [bfops/redo-61]: review --------- Co-authored-by: Zeke Foppa --- docs/modules/c-sharp/index.md | 8 ++++++-- docs/modules/c-sharp/quickstart.md | 8 ++++++-- docs/modules/rust/index.md | 24 ++++++++++++++---------- docs/modules/rust/quickstart.md | 10 +++++++--- docs/unity/part-2a-rust.md | 10 +++++----- docs/unity/part-2b-c-sharp.md | 10 +++++----- docs/unity/part-4.md | 6 +++--- 7 files changed, 46 insertions(+), 30 deletions(-) diff --git a/docs/modules/c-sharp/index.md b/docs/modules/c-sharp/index.md index 31ebd1d4..1af76f84 100644 --- a/docs/modules/c-sharp/index.md +++ b/docs/modules/c-sharp/index.md @@ -22,7 +22,7 @@ static partial class Module // `[SpacetimeDB.Table]` registers a struct or a class as a SpacetimeDB table. // // It generates methods to insert, filter, update, and delete rows of the given type in the table. - [SpacetimeDB.Table] + [SpacetimeDB.Table(Public = true)] public partial struct Person { // `[SpacetimeDB.Column]` allows to specify column attributes / constraints such as @@ -172,11 +172,15 @@ if (option.IsSome) ### Tables `[SpacetimeDB.Table]` attribute can be used on any `struct` or `class` to mark it as a SpacetimeDB table. It will register a table in the database with the given name and fields as well as will generate C# methods to insert, filter, update, and delete rows of the given type. +By default, tables are **private**. This means that they are only readable by the table owner, and by server module code. +Adding `[SpacetimeDB.Table(Public = true))]` annotation makes a table public. **Public** tables are readable by all users, but can still only be modified by your server module code. + +_Coming soon: We plan to add much more robust access controls than just public or private. Stay tuned!_ It implies `[SpacetimeDB.Type]`, so you must not specify both attributes on the same type. ```csharp -[SpacetimeDB.Table] +[SpacetimeDB.Table(Public = true)] public partial struct Person { [SpacetimeDB.Column(ColumnAttrs.Unique | ColumnAttrs.AutoInc)] diff --git a/docs/modules/c-sharp/quickstart.md b/docs/modules/c-sharp/quickstart.md index f5f73401..747f4260 100644 --- a/docs/modules/c-sharp/quickstart.md +++ b/docs/modules/c-sharp/quickstart.md @@ -7,6 +7,10 @@ A SpacetimeDB module is code that gets compiled to WebAssembly and is uploaded t Each SpacetimeDB module defines a set of tables and a set of reducers. Each table is defined as a C# `class` annotated with `[SpacetimeDB.Table]`, where an instance represents a row, and each field represents a column. +By default, tables are **private**. This means that they are only readable by the table owner, and by server module code. +The `[SpacetimeDB.Table(Public = true))]` annotation makes a table public. **Public** tables are readable by all users, but can still only be modified by your server module code. + +_Coming soon: We plan to add much more robust access controls than just public or private tables. Stay tuned!_ A reducer is a function which 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. In C#, reducers are defined as functions annotated with `[SpacetimeDB.Reducer]`. If an exception is thrown, the reducer call fails, the database is not updated, and a failed message is reported to the client. @@ -84,7 +88,7 @@ For each `User`, we'll store their `Identity`, an optional name they can set to In `server/Lib.cs`, add the definition of the table `User` to the `Module` class: ```csharp -[SpacetimeDB.Table] +[SpacetimeDB.Table(Public = true)] public partial class User { [SpacetimeDB.Column(ColumnAttrs.PrimaryKey)] @@ -99,7 +103,7 @@ For each `Message`, we'll store the `Identity` of the user who sent it, the `Tim In `server/Lib.cs`, add the definition of the table `Message` to the `Module` class: ```csharp -[SpacetimeDB.Table] +[SpacetimeDB.Table(Public = true)] public partial class Message { public Identity Sender; diff --git a/docs/modules/rust/index.md b/docs/modules/rust/index.md index 05d62bdc..b08075a0 100644 --- a/docs/modules/rust/index.md +++ b/docs/modules/rust/index.md @@ -31,7 +31,7 @@ use spacetimedb::{spacetimedb, println}; // This macro lets us interact with a SpacetimeDB table of Person rows. // We can insert and delete into, and query, this table by the collection // of functions generated by the macro. -#[spacetimedb(table)] +#[spacetimedb(table(public))] pub struct Person { name: String, } @@ -88,10 +88,14 @@ Now we'll get into details on all the macro APIs SpacetimeDB provides, starting ### Defining tables -`#[spacetimedb(table)]` takes no further arguments, and is applied to a Rust struct with named fields: +The `#[spacetimedb(table)]` is applied to a Rust struct with named fields. +By default, tables are considered **private**. This means that they are only readable by the table owner, and by server module code. +The `#[spacetimedb(table(public))]` macro makes a table public. **Public** tables are readable by all users, but can still only be modified by your server module code. + +_Coming soon: We plan to add much more robust access controls than just public or private. Stay tuned!_ ```rust -#[spacetimedb(table)] +#[spacetimedb(table(public))] struct Table { field1: String, field2: u32, @@ -119,7 +123,7 @@ And common data structures: All `#[spacetimedb(table)]` types are `SpacetimeType`s, and accordingly, all of their fields have to be. ```rust -#[spacetimedb(table)] +#[spacetimedb(table(public))] struct AnotherTable { // Fine, some builtin types. id: u64, @@ -151,7 +155,7 @@ enum Serial { Once the table is created via the macro, other attributes described below can control more aspects of the table. For instance, a particular column can be indexed, or take on values of an automatically incremented counter. These are described in detail below. ```rust -#[spacetimedb(table)] +#[spacetimedb(table(public))] struct Person { #[unique] id: u64, @@ -269,7 +273,7 @@ We'll work off these structs to see what functions SpacetimeDB generates: This table has a plain old column. ```rust -#[spacetimedb(table)] +#[spacetimedb(table(public))] struct Ordinary { ordinary_field: u64, } @@ -278,7 +282,7 @@ struct Ordinary { This table has a unique column. Every row in the `Person` table must have distinct values of the `unique_field` column. Attempting to insert a row with a duplicate value will fail. ```rust -#[spacetimedb(table)] +#[spacetimedb(table(public))] struct Unique { // A unique column: #[unique] @@ -291,7 +295,7 @@ This table has an automatically incrementing column. SpacetimeDB automatically p Only integer types can be `#[unique]`: `u8`, `u16`, `u32`, `u64`, `u128`, `i8`, `i16`, `i32`, `i64` and `i128`. ```rust -#[spacetimedb(table)] +#[spacetimedb(table(public))] struct Autoinc { #[autoinc] autoinc_field: u64, @@ -301,7 +305,7 @@ struct Autoinc { These attributes can be combined, to create an automatically assigned ID usable for filtering. ```rust -#[spacetimedb(table)] +#[spacetimedb(table(public))] struct Identity { #[autoinc] #[unique] @@ -375,7 +379,7 @@ fn insert_id() { Given a table, we can iterate over all the rows in it. ```rust -#[spacetimedb(table)] +#[spacetimedb(table(public))] struct Person { #[unique] id: u64, diff --git a/docs/modules/rust/quickstart.md b/docs/modules/rust/quickstart.md index e015b881..ed9fc376 100644 --- a/docs/modules/rust/quickstart.md +++ b/docs/modules/rust/quickstart.md @@ -7,6 +7,10 @@ A SpacetimeDB module is code that gets compiled to WebAssembly and is uploaded t Each SpacetimeDB module defines a set of tables and a set of reducers. Each table is defined as a Rust `struct` annotated with `#[spacetimedb(table)]`, where an instance represents a row, and each field represents a column. +By default, tables are **private**. This means that they are only readable by the table owner, and by server module code. +The `#[spacetimedb(table(public))]` macro makes a table public. **Public** tables are readable by all users, but can still only be modified by your server module code. + +_Coming soon: We plan to add much more robust access controls than just public or private. Stay tuned!_ A reducer is a function which 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. In Rust, reducers are defined as functions annotated with `#[spacetimedb(reducer)]`, and may return a `Result<()>`, with an `Err` return aborting the transaction. @@ -67,7 +71,7 @@ For each `User`, we'll store their `Identity`, an optional name they can set to To `server/src/lib.rs`, add the definition of the table `User`: ```rust -#[spacetimedb(table)] +#[spacetimedb(table(public))] pub struct User { #[primarykey] identity: Identity, @@ -81,7 +85,7 @@ For each `Message`, we'll store the `Identity` of the user who sent it, the `Tim To `server/src/lib.rs`, add the definition of the table `Message`: ```rust -#[spacetimedb(table)] +#[spacetimedb(table(public))] pub struct Message { sender: Identity, sent: Timestamp, @@ -179,7 +183,7 @@ You could extend the validation in `validate_message` in similar ways to `valida Whenever a client connects, the module will run a special reducer, annotated with `#[spacetimedb(connect)]`, if it's defined. By convention, it's named `identity_connected`. We'll use it to create a `User` record for the client if it doesn't yet exist, and to set its online status. -We'll use `User::filter_by_identity` to look up a `User` row for `ctx.sender`, if one exists. If we find one, we'll use `User::update_by_identity` to overwrite it with a row that has `online: true`. If not, we'll use `User::insert` to insert a new row for our new user. All three of these methods are generated by the `#[spacetimedb(table)]` attribute, with rows and behavior based on the row attributes. `filter_by_identity` returns an `Option`, because the unique constraint from the `#[primarykey]` attribute means there will be either zero or one matching rows. `insert` returns a `Result<(), UniqueConstraintViolation>` because of the same unique constraint; if we want to overwrite a `User` row, we need to do so explicitly using `update_by_identity`. +We'll use `User::filter_by_identity` to look up a `User` row for `ctx.sender`, if one exists. If we find one, we'll use `User::update_by_identity` to overwrite it with a row that has `online: true`. If not, we'll use `User::insert` to insert a new row for our new user. All three of these methods are generated by the `#[spacetimedb(table)]` macro, with rows and behavior based on the row attributes. `filter_by_identity` returns an `Option`, because the unique constraint from the `#[primarykey]` attribute means there will be either zero or one matching rows. `insert` returns a `Result<(), UniqueConstraintViolation>` because of the same unique constraint; if we want to overwrite a `User` row, we need to do so explicitly using `update_by_identity`. To `server/src/lib.rs`, add the definition of the connect reducer: diff --git a/docs/unity/part-2a-rust.md b/docs/unity/part-2a-rust.md index fd9361f2..dbfdc888 100644 --- a/docs/unity/part-2a-rust.md +++ b/docs/unity/part-2a-rust.md @@ -29,13 +29,13 @@ use spacetimedb::{spacetimedb, Identity, SpacetimeType, ReducerContext}; use log; ``` -Then we are going to start by adding the global `Config` table. Right now it only contains the "message of the day" but it can be extended to store other configuration variables. This also uses a couple of macros, like `#[spacetimedb(table)]` which you can learn more about in our [Rust module reference](/docs/modules/rust). Simply put, this just tells SpacetimeDB to create a table which uses this struct as the schema for the table. +Then we are going to start by adding the global `Config` table. Right now it only contains the "message of the day" but it can be extended to store other configuration variables. This also uses a couple of macros, like `#[spacetimedb(table)]` which you can learn more about in our [Rust module reference](/docs/modules/rust) (including making your tables `private`!). Simply put, this just tells SpacetimeDB to create a table which uses this struct as the schema for the table. **Append to the bottom of lib.rs:** ```rust // We're using this table as a singleton, so there should typically only be one element where the version is 0. -#[spacetimedb(table)] +#[spacetimedb(table(public))] #[derive(Clone)] pub struct Config { #[primarykey] @@ -64,7 +64,7 @@ Now we're going to create a table which actually uses the `StdbVector3` that we // This stores information related to all entities in our game. In this tutorial // all entities must at least have an entity_id, a position, a direction and they // must specify whether or not they are moving. -#[spacetimedb(table)] +#[spacetimedb(table(public))] #[derive(Clone)] pub struct EntityComponent { #[primarykey] @@ -87,7 +87,7 @@ Next, we will define the `PlayerComponent` table. The `PlayerComponent` table is // All players have this component and it associates an entity with the user's // Identity. It also stores their username and whether or not they're logged in. #[derive(Clone)] -#[spacetimedb(table)] +#[spacetimedb(table(public))] pub struct PlayerComponent { // An entity_id that matches an entity_id in the `EntityComponent` table. #[primarykey] @@ -264,7 +264,7 @@ First lets add a new `ChatMessage` table to the SpacetimeDB module. Add the foll **Append to the bottom of server/src/lib.rs:** ```rust -#[spacetimedb(table)] +#[spacetimedb(table(public))] pub struct ChatMessage { // The primary key for this table will be auto-incremented #[primarykey] diff --git a/docs/unity/part-2b-c-sharp.md b/docs/unity/part-2b-c-sharp.md index ee6c0028..f1956b70 100644 --- a/docs/unity/part-2b-c-sharp.md +++ b/docs/unity/part-2b-c-sharp.md @@ -30,14 +30,14 @@ using SpacetimeDB.Module; using static SpacetimeDB.Runtime; ``` -Then we are going to start by adding the global `Config` table. Right now it only contains the "message of the day" but it can be extended to store other configuration variables. This also uses a couple of macros, like `#[spacetimedb(table)]` which you can learn more about in our [C# module reference](/docs/modules/c-sharp). Simply put, this just tells SpacetimeDB to create a table which uses this struct as the schema for the table. +Then we are going to start by adding the global `Config` table. Right now it only contains the "message of the day" but it can be extended to store other configuration variables. This also uses a couple of attributes, like `[SpacetimeDB.Table]` which you can learn more about in our [C# module reference](/docs/modules/c-sharp). Simply put, this just tells SpacetimeDB to create a table which uses this struct as the schema for the table. **Append to the bottom of lib.cs:** ```csharp /// We're using this table as a singleton, /// so there should typically only be one element where the version is 0. -[SpacetimeDB.Table] +[SpacetimeDB.Table(Public = true)] public partial class Config { [SpacetimeDB.Column(ColumnAttrs.PrimaryKey)] @@ -67,7 +67,7 @@ Now we're going to create a table which actually uses the `StdbVector3` that we /// This stores information related to all entities in our game. In this tutorial /// all entities must at least have an entity_id, a position, a direction and they /// must specify whether or not they are moving. -[SpacetimeDB.Table] +[SpacetimeDB.Table(Public = true)] public partial class EntityComponent { [SpacetimeDB.Column(ColumnAttrs.PrimaryKeyAuto)] @@ -85,7 +85,7 @@ Next, we will define the `PlayerComponent` table. The `PlayerComponent` table is ```csharp /// All players have this component and it associates an entity with the user's /// Identity. It also stores their username and whether or not they're logged in. -[SpacetimeDB.Table] +[SpacetimeDB.Table(Public = true)] public partial class PlayerComponent { // An EntityId that matches an EntityId in the `EntityComponent` table. @@ -293,7 +293,7 @@ First lets add a new `ChatMessage` table to the SpacetimeDB module. Add the foll **Append to the bottom of server/src/lib.cs:** ```csharp -[SpacetimeDB.Table] +[SpacetimeDB.Table(Public = true)] public partial class ChatMessage { // The primary key for this table will be auto-incremented diff --git a/docs/unity/part-4.md b/docs/unity/part-4.md index b8af1018..b3a17439 100644 --- a/docs/unity/part-4.md +++ b/docs/unity/part-4.md @@ -34,7 +34,7 @@ pub enum ResourceNodeType { Iron, } -#[spacetimedb(table)] +#[spacetimedb(table(public))] #[derive(Clone)] pub struct ResourceNodeComponent { #[primarykey] @@ -48,7 +48,7 @@ pub struct ResourceNodeComponent { Because resource nodes never move, the `MobileEntityComponent` is overkill. Instead, we will add a new entity component named `StaticLocationComponent` that only stores the position and rotation. ```rust -#[spacetimedb(table)] +#[spacetimedb(table(public))] #[derive(Clone)] pub struct StaticLocationComponent { #[primarykey] @@ -62,7 +62,7 @@ pub struct StaticLocationComponent { 3. We are also going to add a couple of additional column to our Config table. `map_extents` let's our spawner know where it can spawn the nodes. `num_resource_nodes` is the maximum number of nodes to spawn on the map. Update the config table in lib.rs. ```rust -#[spacetimedb(table)] +#[spacetimedb(table(public))] pub struct Config { // Config is a global table with a single row. This table will be used to // store configuration or global variables From 6983d70a47b5c498d490f4b6084d4dbff347ce34 Mon Sep 17 00:00:00 2001 From: Ingvar Stepanyan Date: Tue, 18 Jun 2024 16:01:53 +0100 Subject: [PATCH 047/164] Remove CreateInstance() from C# client docs (#69) This method no longer exists - an instance with a default logger is set up automatically. --- docs/sdks/c-sharp/index.md | 42 ++++----------------------------- docs/sdks/c-sharp/quickstart.md | 3 --- 2 files changed, 4 insertions(+), 41 deletions(-) diff --git a/docs/sdks/c-sharp/index.md b/docs/sdks/c-sharp/index.md index 7c920cf5..d0d15237 100644 --- a/docs/sdks/c-sharp/index.md +++ b/docs/sdks/c-sharp/index.md @@ -11,7 +11,6 @@ The SpacetimeDB client C# for Rust contains all the tools you need to build nati - [Using Unity](#using-unity) - [Generate module bindings](#generate-module-bindings) - [Initialization](#initialization) - - [Static Method `SpacetimeDBClient.CreateInstance`](#static-method-spacetimedbclientcreateinstance) - [Property `SpacetimeDBClient.instance`](#property-spacetimedbclientinstance) - [Class `NetworkManager`](#class-networkmanager) - [Method `SpacetimeDBClient.Connect`](#method-spacetimedbclientconnect) @@ -84,32 +83,6 @@ Replace `PATH-TO-MODULE-DIRECTORY` with the path to your SpacetimeDB module. ## Initialization -### Static Method `SpacetimeDBClient.CreateInstance` - -```cs -namespace SpacetimeDB { - -public class SpacetimeDBClient { - public static void CreateInstance(ISpacetimeDBLogger loggerToUse); -} - -} -``` - -Create a global SpacetimeDBClient instance, accessible via [`SpacetimeDBClient.instance`](#property-spacetimedbclientinstance) - -| Argument | Type | Meaning | -| ------------- | ----------------------------------------------------- | --------------------------------- | -| `loggerToUse` | [`ISpacetimeDBLogger`](#interface-ispacetimedblogger) | The logger to use to log messages | - -There is a provided logger called [`ConsoleLogger`](#class-consolelogger) which logs to `System.Console`, and can be used as follows: - -```cs -using SpacetimeDB; -using SpacetimeDB.Types; -SpacetimeDBClient.CreateInstance(new ConsoleLogger()); -``` - ### Property `SpacetimeDBClient.instance` ```cs @@ -130,7 +103,7 @@ The Unity SpacetimeDB SDK relies on there being a `NetworkManager` somewhere in ![Unity-AddNetworkManager](/images/unity-tutorial/Unity-AddNetworkManager.JPG) -This component will handle calling [`SpacetimeDBClient.CreateInstance`](#static-method-spacetimedbclientcreateinstance) for you, but will not call [`SpacetimeDBClient.Connect`](#method-spacetimedbclientconnect), you still need to handle that yourself. See the [Unity Quickstart](./UnityQuickStart) and [Unity Tutorial](./UnityTutorialPart1) for more information. +This component will handle updating and closing the [`SpacetimeDBClient.instance`](#property-spacetimedbclientinstance) for you, but will not call [`SpacetimeDBClient.Connect`](#method-spacetimedbclientconnect), you still need to handle that yourself. See the [Unity Quickstart](./UnityQuickStart) and [Unity Tutorial](./UnityTutorialPart1) for more information. ### Method `SpacetimeDBClient.Connect` @@ -264,7 +237,6 @@ using SpacetimeDB.Types; void Main() { AuthToken.Init(); - SpacetimeDBClient.CreateInstance(new ConsoleLogger()); SpacetimeDBClient.instance.onConnect += OnConnect; @@ -903,17 +875,11 @@ An opaque identifier for a client connection to a database, intended to differen ## Customizing logging -The SpacetimeDB C# SDK performs internal logging. Instances of [`ISpacetimeDBLogger`](#interface-ispacetimedblogger) can be passed to [`SpacetimeDBClient.CreateInstance`](#static-method-spacetimedbclientcreateinstance) to customize how SDK logs are delivered to your application. +The SpacetimeDB C# SDK performs internal logging. -This is set up automatically for you if you use Unity-- adding a [`NetworkManager`](#class-networkmanager) component to your unity scene will automatically initialize the `SpacetimeDBClient` with a [`UnityDebugLogger`](#class-unitydebuglogger). +A default logger is set up automatically for you - a [`ConsoleLogger`](#class-consolelogger) for C# projects and [`UnityDebugLogger`](#class-unitydebuglogger) for Unity projects. -Outside of unity, all you need to do is the following: - -```cs -using SpacetimeDB; -using SpacetimeDB.Types; -SpacetimeDBClient.CreateInstance(new ConsoleLogger()); -``` +If you want to redirect SDK logs elsewhere, you can inherit from the [`ISpacetimeDBLogger`](#interface-ispacetimedblogger) and assign an instance of your class to the `SpacetimeDB.Logger.Current` static property. ### Interface `ISpacetimeDBLogger` diff --git a/docs/sdks/c-sharp/quickstart.md b/docs/sdks/c-sharp/quickstart.md index 28f3c2e1..122465d3 100644 --- a/docs/sdks/c-sharp/quickstart.md +++ b/docs/sdks/c-sharp/quickstart.md @@ -89,9 +89,6 @@ void Main() { AuthToken.Init(".spacetime_csharp_quickstart"); - // create the client, pass in a logger to see debug messages - SpacetimeDBClient.CreateInstance(new ConsoleLogger()); - RegisterCallbacks(); // spawn a thread to call process updates and process commands From b1af7b19dc4f024667349b3e079c381c93a1645c Mon Sep 17 00:00:00 2001 From: Ingvar Stepanyan Date: Tue, 18 Jun 2024 16:12:13 +0100 Subject: [PATCH 048/164] DbEventArgs -> ReducerContext in C# API docs (#66) This API was recently renamed. --- docs/modules/c-sharp/index.md | 8 +++---- docs/modules/c-sharp/quickstart.md | 34 +++++++++++++++--------------- docs/unity/part-2b-c-sharp.md | 28 ++++++++++++------------ 3 files changed, 35 insertions(+), 35 deletions(-) diff --git a/docs/modules/c-sharp/index.md b/docs/modules/c-sharp/index.md index 1af76f84..7037a2a8 100644 --- a/docs/modules/c-sharp/index.md +++ b/docs/modules/c-sharp/index.md @@ -254,11 +254,11 @@ public static void Add(string name, int age) } ``` -If a reducer has an argument with a type `DbEventArgs` (`SpacetimeDB.Runtime.DbEventArgs`), it will be provided with event details such as the sender identity (`SpacetimeDB.Runtime.Identity`), sender address (`SpacetimeDB.Runtime.Address?`) and the time (`DateTimeOffset`) of the invocation: +If a reducer has an argument with a type `ReducerContext` (`SpacetimeDB.Runtime.ReducerContext`), it will be provided with event details such as the sender identity (`SpacetimeDB.Runtime.Identity`), sender address (`SpacetimeDB.Runtime.Address?`) and the time (`DateTimeOffset`) of the invocation: ```csharp [SpacetimeDB.Reducer] -public static void PrintInfo(DbEventArgs e) +public static void PrintInfo(ReducerContext e) { Log($"Sender identity: {e.Sender}"); Log($"Sender address: {e.Address}"); @@ -268,7 +268,7 @@ public static void PrintInfo(DbEventArgs e) `[SpacetimeDB.Reducer]` also generates a function to schedule the given reducer in the future. -Since it's not possible to generate extension methods on existing methods, the codegen will instead add a `Schedule`-prefixed method colocated in the same namespace as the original method instead. The generated method will accept `DateTimeOffset` argument for the time when the reducer should be invoked, followed by all the arguments of the reducer itself, except those that have type `DbEventArgs`. +Since it's not possible to generate extension methods on existing methods, the codegen will instead add a `Schedule`-prefixed method colocated in the same namespace as the original method instead. The generated method will accept `DateTimeOffset` argument for the time when the reducer should be invoked, followed by all the arguments of the reducer itself, except those that have type `ReducerContext`. ```csharp // Example reducer: @@ -280,7 +280,7 @@ public static void ScheduleAdd(DateTimeOffset time, string name, int age) { ... // Usage from another reducer: [SpacetimeDB.Reducer] -public static void AddIn5Minutes(DbEventArgs e, string name, int age) +public static void AddIn5Minutes(ReducerContext e, string name, int age) { // Note that we're using `e.Time` instead of `DateTimeOffset.Now` which is not allowed in modules. var scheduleToken = ScheduleAdd(e.Time.AddMinutes(5), name, age); diff --git a/docs/modules/c-sharp/quickstart.md b/docs/modules/c-sharp/quickstart.md index 747f4260..66c6e5cb 100644 --- a/docs/modules/c-sharp/quickstart.md +++ b/docs/modules/c-sharp/quickstart.md @@ -116,7 +116,7 @@ public partial class Message We want to allow users to set their names, because `Identity` is not a terribly user-friendly identifier. To that effect, we define a reducer `SetName` which clients can invoke to set their `User.Name`. It will validate the caller's chosen name, using a function `ValidateName` which we'll define next, then look up the `User` record for the caller and update it to store the validated name. If the name fails the validation, the reducer will fail. -Each reducer may accept as its first argument a `DbEventArgs`, which includes the `Identity` and `Address` of the client that called the reducer, and the `Timestamp` when it was invoked. For now, we only need the `Identity`, `dbEvent.Sender`. +Each reducer may accept as its first argument a `ReducerContext`, which includes the `Identity` and `Address` of the client that called the reducer, and the `Timestamp` when it was invoked. For now, we only need the `Identity`, `ctx.Sender`. It's also possible to call `SetName` via the SpacetimeDB CLI's `spacetime call` command without a connection, in which case no `User` record will exist for the caller. We'll return an error in this case, but you could alter the reducer to insert a `User` row for the module owner. You'll have to decide whether the module owner is always online or always offline, though. @@ -124,15 +124,15 @@ In `server/Lib.cs`, add to the `Module` class: ```csharp [SpacetimeDB.Reducer] -public static void SetName(DbEventArgs dbEvent, string name) +public static void SetName(ReducerContext ctx, string name) { name = ValidateName(name); - var user = User.FindByIdentity(dbEvent.Sender); + var user = User.FindByIdentity(ctx.Sender); if (user is not null) { user.Name = name; - User.UpdateByIdentity(dbEvent.Sender, user); + User.UpdateByIdentity(ctx.Sender, user); } } ``` @@ -161,21 +161,21 @@ public static string ValidateName(string name) ## Send messages -We define a reducer `SendMessage`, which clients will call to send messages. It will validate the message's text, then insert a new `Message` record using `Message.Insert`, with the `Sender` identity and `Time` timestamp taken from the `DbEventArgs`. +We define a reducer `SendMessage`, which clients will call to send messages. It will validate the message's text, then insert a new `Message` record using `Message.Insert`, with the `Sender` identity and `Time` timestamp taken from the `ReducerContext`. In `server/Lib.cs`, add to the `Module` class: ```csharp [SpacetimeDB.Reducer] -public static void SendMessage(DbEventArgs dbEvent, string text) +public static void SendMessage(ReducerContext ctx, string text) { text = ValidateMessage(text); Log(text); new Message { - Sender = dbEvent.Sender, + Sender = ctx.Sender, Text = text, - Sent = dbEvent.Time.ToUnixTimeMilliseconds(), + Sent = ctx.Time.ToUnixTimeMilliseconds(), }.Insert(); } ``` @@ -205,23 +205,23 @@ You could extend the validation in `ValidateMessage` in similar ways to `Validat In C# modules, you can register for `Connect` and `Disconnect` events by using a special `ReducerKind`. We'll use the `Connect` event to create a `User` record for the client if it doesn't yet exist, and to set its online status. -We'll use `User.FindByIdentity` to look up a `User` row for `dbEvent.Sender`, if one exists. If we find one, we'll use `User.UpdateByIdentity` to overwrite it with a row that has `Online: true`. If not, we'll use `User.Insert` to insert a new row for our new user. All three of these methods are generated by the `[SpacetimeDB.Table]` attribute, with rows and behavior based on the row attributes. `FindByIdentity` returns a nullable `User`, because the unique constraint from the `[SpacetimeDB.Column(ColumnAttrs.PrimaryKey)]` attribute means there will be either zero or one matching rows. `Insert` will throw an exception if the insert violates this constraint; if we want to overwrite a `User` row, we need to do so explicitly using `UpdateByIdentity`. +We'll use `User.FindByIdentity` to look up a `User` row for `ctx.Sender`, if one exists. If we find one, we'll use `User.UpdateByIdentity` to overwrite it with a row that has `Online: true`. If not, we'll use `User.Insert` to insert a new row for our new user. All three of these methods are generated by the `[SpacetimeDB.Table]` attribute, with rows and behavior based on the row attributes. `FindByIdentity` returns a nullable `User`, because the unique constraint from the `[SpacetimeDB.Column(ColumnAttrs.PrimaryKey)]` attribute means there will be either zero or one matching rows. `Insert` will throw an exception if the insert violates this constraint; if we want to overwrite a `User` row, we need to do so explicitly using `UpdateByIdentity`. In `server/Lib.cs`, add the definition of the connect reducer to the `Module` class: ```csharp [SpacetimeDB.Reducer(ReducerKind.Connect)] -public static void OnConnect(DbEventArgs dbEventArgs) +public static void OnConnect(ReducerContext ReducerContext) { - Log($"Connect {dbEventArgs.Sender}"); - var user = User.FindByIdentity(dbEventArgs.Sender); + Log($"Connect {ReducerContext.Sender}"); + var user = User.FindByIdentity(ReducerContext.Sender); if (user is not null) { // 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.Online = true; - User.UpdateByIdentity(dbEventArgs.Sender, user); + User.UpdateByIdentity(ReducerContext.Sender, user); } else { @@ -230,7 +230,7 @@ public static void OnConnect(DbEventArgs dbEventArgs) new User { Name = null, - Identity = dbEventArgs.Sender, + Identity = ReducerContext.Sender, Online = true, }.Insert(); } @@ -243,15 +243,15 @@ Add the following code after the `OnConnect` lambda: ```csharp [SpacetimeDB.Reducer(ReducerKind.Disconnect)] -public static void OnDisconnect(DbEventArgs dbEventArgs) +public static void OnDisconnect(ReducerContext ReducerContext) { - var user = User.FindByIdentity(dbEventArgs.Sender); + var user = User.FindByIdentity(ReducerContext.Sender); if (user is not null) { // This user should exist, so set `Online: false`. user.Online = false; - User.UpdateByIdentity(dbEventArgs.Sender, user); + User.UpdateByIdentity(ReducerContext.Sender, user); } else { diff --git a/docs/unity/part-2b-c-sharp.md b/docs/unity/part-2b-c-sharp.md index f1956b70..e311714a 100644 --- a/docs/unity/part-2b-c-sharp.md +++ b/docs/unity/part-2b-c-sharp.md @@ -108,10 +108,10 @@ Next, we write our very first reducer, `CreatePlayer`. From the client we will c /// This reducer is called when the user logs in for the first time and /// enters a username. [SpacetimeDB.Reducer] -public static void CreatePlayer(DbEventArgs dbEvent, string username) +public static void CreatePlayer(ReducerContext ctx, string username) { // Get the Identity of the client who called this reducer - Identity sender = dbEvent.Sender; + Identity sender = ctx.Sender; // Make sure we don't already have a player with this identity PlayerComponent? user = PlayerComponent.FindByIdentity(sender); @@ -144,7 +144,7 @@ public static void CreatePlayer(DbEventArgs dbEvent, string username) new PlayerComponent { // EntityId = 0, // 0 is the same as leaving null to get a new, unique Id - Identity = dbEvent.Sender, + Identity = ctx.Sender, Username = username, LoggedIn = true, }.Insert(); @@ -204,30 +204,30 @@ We use the `Connect` and `Disconnect` reducers to update the logged in state of ```csharp /// Called when the client connects, we update the LoggedIn state to true [SpacetimeDB.Reducer(ReducerKind.Init)] -public static void ClientConnected(DbEventArgs dbEvent) => - UpdatePlayerLoginState(dbEvent, loggedIn:true); +public static void ClientConnected(ReducerContext ctx) => + UpdatePlayerLoginState(ctx, loggedIn:true); ``` ```csharp /// Called when the client disconnects, we update the logged_in state to false [SpacetimeDB.Reducer(ReducerKind.Disconnect)] -public static void ClientDisonnected(DbEventArgs dbEvent) => - UpdatePlayerLoginState(dbEvent, loggedIn:false); +public static void ClientDisonnected(ReducerContext ctx) => + UpdatePlayerLoginState(ctx, loggedIn:false); ``` ```csharp /// This helper function gets the PlayerComponent, sets the LoggedIn /// variable and updates the PlayerComponent table row. -private static void UpdatePlayerLoginState(DbEventArgs dbEvent, bool loggedIn) +private static void UpdatePlayerLoginState(ReducerContext ctx, bool loggedIn) { - PlayerComponent? player = PlayerComponent.FindByIdentity(dbEvent.Sender); + PlayerComponent? player = PlayerComponent.FindByIdentity(ctx.Sender); if (player is null) { throw new ArgumentException("Player not found"); } player.LoggedIn = loggedIn; - PlayerComponent.UpdateByIdentity(dbEvent.Sender, player); + PlayerComponent.UpdateByIdentity(ctx.Sender, player); } ``` @@ -241,13 +241,13 @@ Using the `EntityId` in the `PlayerComponent` we retrieved, we can lookup the `E /// Updates the position of a player. This is also called when the player stops moving. [SpacetimeDB.Reducer] private static void UpdatePlayerPosition( - DbEventArgs dbEvent, + ReducerContext ctx, StdbVector3 position, float direction, bool moving) { // First, look up the player using the sender identity - PlayerComponent? player = PlayerComponent.FindByIdentity(dbEvent.Sender); + PlayerComponent? player = PlayerComponent.FindByIdentity(ctx.Sender); if (player is null) { throw new ArgumentException("Player not found"); @@ -314,10 +314,10 @@ Now we need to add a reducer to handle inserting new chat messages. ```csharp /// Adds a chat entry to the ChatMessage table [SpacetimeDB.Reducer] -public static void SendChatMessage(DbEventArgs dbEvent, string text) +public static void SendChatMessage(ReducerContext ctx, string text) { // Get the player's entity id - PlayerComponent? player = PlayerComponent.FindByIdentity(dbEvent.Sender); + PlayerComponent? player = PlayerComponent.FindByIdentity(ctx.Sender); if (player is null) { throw new ArgumentException("Player not found"); From 7a54f2655e526b0ef4c71c20c1fb541b49114402 Mon Sep 17 00:00:00 2001 From: Ingvar Stepanyan Date: Tue, 18 Jun 2024 16:12:24 +0100 Subject: [PATCH 049/164] Remove obsolete C# module imports (#67) --- docs/modules/c-sharp/quickstart.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/docs/modules/c-sharp/quickstart.md b/docs/modules/c-sharp/quickstart.md index 66c6e5cb..559dca92 100644 --- a/docs/modules/c-sharp/quickstart.md +++ b/docs/modules/c-sharp/quickstart.md @@ -64,10 +64,7 @@ using SpacetimeDB.Module; using static SpacetimeDB.Runtime; ``` -- `System.Runtime.CompilerServices` -- `SpacetimeDB.Module` - - Contains the special attributes we'll use to define our module. - - Allows us to use the `ModuleInitializer` attribute, which we'll use to register our `OnConnect` and `OnDisconnect` callbacks. +- `SpacetimeDB.Module` contains the special attributes we'll use to define tables and reducers in our module. - `SpacetimeDB.Runtime` contains the raw API bindings SpacetimeDB uses to communicate with the database. We also need to create our static module class which all of the module code will live in. In `server/Lib.cs`, add: @@ -239,7 +236,7 @@ public static void OnConnect(ReducerContext ReducerContext) Similarly, whenever a client disconnects, the module will execute the `OnDisconnect` event if it's registered with `ReducerKind.Disconnect`. We'll use it to un-set the `Online` status of the `User` for the disconnected client. -Add the following code after the `OnConnect` lambda: +Add the following code after the `OnConnect` handler: ```csharp [SpacetimeDB.Reducer(ReducerKind.Disconnect)] From 6a7ecbf9492fb59c0af0ec6c3a74f9d7fa39a563 Mon Sep 17 00:00:00 2001 From: Ingvar Stepanyan Date: Tue, 18 Jun 2024 16:12:58 +0100 Subject: [PATCH 050/164] Update docs for Consistent Filtering proposal (#68) * Update docs for Consistent Filtering proposal Updating docs for FilterBy methods across SDKs, adding docs for FindBy methods, and a couple of drive-by fixes for broken links. * Update docs/sdks/c-sharp/index.md Co-authored-by: Phoebe Goldman --------- Co-authored-by: Phoebe Goldman --- docs/index.md | 2 +- docs/modules/rust/index.md | 6 ++-- docs/modules/rust/quickstart.md | 8 ++--- docs/sdks/c-sharp/index.md | 43 +++++++++++++++++---------- docs/sdks/c-sharp/quickstart.md | 4 +-- docs/sdks/rust/index.md | 26 ++++++++++++----- docs/sdks/rust/quickstart.md | 4 +-- docs/sdks/typescript/index.md | 47 ++++++++++++++++++++++++++---- docs/sdks/typescript/quickstart.md | 6 ++-- docs/unity/part-2a-rust.md | 12 ++++---- docs/unity/part-2b-c-sharp.md | 2 +- docs/unity/part-3.md | 18 ++++++------ docs/unity/part-4.md | 4 +-- 13 files changed, 120 insertions(+), 62 deletions(-) diff --git a/docs/index.md b/docs/index.md index 7a95f4f8..eaee2c83 100644 --- a/docs/index.md +++ b/docs/index.md @@ -111,7 +111,7 @@ spacetime publish ``` 5. How do I create a Unity game with SpacetimeDB? - Follow our [Unity Project](/docs/unity-project) guide! + Follow our [Unity Project](/docs/unity-tutorial) guide! TL;DR in an empty directory: diff --git a/docs/modules/rust/index.md b/docs/modules/rust/index.md index b08075a0..f4d02490 100644 --- a/docs/modules/rust/index.md +++ b/docs/modules/rust/index.md @@ -176,7 +176,7 @@ struct Person { fn give_player_item(player_id: u64, item_id: u64) -> Result<(), GameErr> { // Notice how the exact name of the filter function derives from // the name of the field of the struct. - let mut item = Item::filter_by_item_id(id).ok_or(GameErr::InvalidId)?; + let mut item = Item::find_by_item_id(id).ok_or(GameErr::InvalidId)?; item.owner = Some(player_id); Item::update_by_id(id, item); Ok(()) @@ -424,7 +424,7 @@ The name of the filter method just corresponds to the column name. ```rust #[spacetimedb(reducer)] fn filtering(id: u64) { - match Person::filter_by_id(&id) { + match Person::find_by_id(&id) { Some(person) => println!("Found {person}"), None => println!("No person with id {id}"), } @@ -436,7 +436,7 @@ Our `Person` table also has a column for age. Unlike IDs, ages aren't unique. Fi ```rust #[spacetimedb(reducer)] fn filtering_non_unique() { - for person in Person::filter_by_age(&21) { + for person in Person::find_by_age(&21) { println!("{person} has turned 21"); } } diff --git a/docs/modules/rust/quickstart.md b/docs/modules/rust/quickstart.md index ed9fc376..346810d7 100644 --- a/docs/modules/rust/quickstart.md +++ b/docs/modules/rust/quickstart.md @@ -108,7 +108,7 @@ To `server/src/lib.rs`, add: /// Clientss invoke this reducer to set their user names. 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) { + if let Some(user) = User::find_by_identity(&ctx.sender) { User::update_by_identity(&ctx.sender, User { name: Some(name), ..user }); Ok(()) } else { @@ -183,7 +183,7 @@ You could extend the validation in `validate_message` in similar ways to `valida Whenever a client connects, the module will run a special reducer, annotated with `#[spacetimedb(connect)]`, if it's defined. By convention, it's named `identity_connected`. We'll use it to create a `User` record for the client if it doesn't yet exist, and to set its online status. -We'll use `User::filter_by_identity` to look up a `User` row for `ctx.sender`, if one exists. If we find one, we'll use `User::update_by_identity` to overwrite it with a row that has `online: true`. If not, we'll use `User::insert` to insert a new row for our new user. All three of these methods are generated by the `#[spacetimedb(table)]` macro, with rows and behavior based on the row attributes. `filter_by_identity` returns an `Option`, because the unique constraint from the `#[primarykey]` attribute means there will be either zero or one matching rows. `insert` returns a `Result<(), UniqueConstraintViolation>` because of the same unique constraint; if we want to overwrite a `User` row, we need to do so explicitly using `update_by_identity`. +We'll use `User::find_by_identity` to look up a `User` row for `ctx.sender`, if one exists. If we find one, we'll use `User::update_by_identity` to overwrite it with a row that has `online: true`. If not, we'll use `User::insert` to insert a new row for our new user. All three of these methods are generated by the `#[spacetimedb(table)]` macro, with rows and behavior based on the row attributes. `find_by_identity` returns an `Option`, because the unique constraint from the `#[primarykey]` attribute means there will be either zero or one matching rows. `insert` returns a `Result<(), UniqueConstraintViolation>` because of the same unique constraint; if we want to overwrite a `User` row, we need to do so explicitly using `update_by_identity`. To `server/src/lib.rs`, add the definition of the connect reducer: @@ -191,7 +191,7 @@ To `server/src/lib.rs`, add the definition of the connect reducer: #[spacetimedb(connect)] // Called when a client connects to the SpacetimeDB pub fn identity_connected(ctx: ReducerContext) { - if let Some(user) = User::filter_by_identity(&ctx.sender) { + if let Some(user) = User::find_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 }); @@ -213,7 +213,7 @@ Similarly, whenever a client disconnects, the module will run the `#[spacetimedb #[spacetimedb(disconnect)] // Called when a client disconnects from SpacetimeDB pub fn identity_disconnected(ctx: ReducerContext) { - if let Some(user) = User::filter_by_identity(&ctx.sender) { + if let Some(user) = User::find_by_identity(&ctx.sender) { User::update_by_identity(&ctx.sender, User { online: false, ..user }); } else { // This branch should be unreachable, diff --git a/docs/sdks/c-sharp/index.md b/docs/sdks/c-sharp/index.md index d0d15237..e8a3d01a 100644 --- a/docs/sdks/c-sharp/index.md +++ b/docs/sdks/c-sharp/index.md @@ -19,11 +19,12 @@ The SpacetimeDB client C# for Rust contains all the tools you need to build nati - [Query subscriptions & one-time actions](#subscribe-to-queries) - [Method `SpacetimeDBClient.Subscribe`](#method-spacetimedbclientsubscribe) - [Event `SpacetimeDBClient.onSubscriptionApplied`](#event-spacetimedbclientonsubscriptionapplied) - - [Method `SpacetimeDBClient.OneOffQuery`](#event-spacetimedbclientoneoffquery) + - [Method `SpacetimeDBClient.OneOffQuery`](#method-spacetimedbclientoneoffquery) - [View rows of subscribed tables](#view-rows-of-subscribed-tables) - [Class `{TABLE}`](#class-table) - [Static Method `{TABLE}.Iter`](#static-method-tableiter) - [Static Method `{TABLE}.FilterBy{COLUMN}`](#static-method-tablefilterbycolumn) + - [Static Method `{TABLE}.FindBy{COLUMN}`](#static-method-tablefindbycolumn) - [Static Method `{TABLE}.Count`](#static-method-tablecount) - [Static Event `{TABLE}.OnInsert`](#static-event-tableoninsert) - [Static Event `{TABLE}.OnBeforeDelete`](#static-event-tableonbeforedelete) @@ -171,7 +172,7 @@ class SpacetimeDBClient { } ``` -+Called when we receive an auth token, [`Identity`](#class-identity) and [`Address`](#class-address) from the server. The [`Identity`](#class-identity) serves as a unique public identifier for a user of the database. It can be for several purposes, such as filtering rows in a database for the rows created by a particular user. The auth token is a private access token that allows us to assume an identity. The [`Address`](#class-address) is opaque identifier for a client connection to a database, intended to differentiate between connections from the same [`Identity`](#class-identity). +Called when we receive an auth token, [`Identity`](#class-identity) and [`Address`](#class-address) from the server. The [`Identity`](#class-identity) serves as a unique public identifier for a user of the database. It can be for several purposes, such as filtering rows in a database for the rows created by a particular user. The auth token is a private access token that allows us to assume an identity. The [`Address`](#class-address) is opaque identifier for a client connection to a database, intended to differentiate between connections from the same [`Identity`](#class-identity). To store the auth token to the filesystem, use the static method [`AuthToken.SaveToken`](#static-method-authtokensavetoken). You may also want to store the returned [`Identity`](#class-identity) in a local variable. @@ -224,11 +225,11 @@ class SpacetimeDBClient { Subscribe to a set of queries, to be notified when rows which match those queries are altered. -`Subscribe` will return an error if called before establishing a connection with the [`SpacetimeDBClient.Connect`](#method-connect) function. In that case, the queries are not registered. +`Subscribe` will return an error if called before establishing a connection with the [`SpacetimeDBClient.Connect`](#method-spacetimedbclientconnect) function. In that case, the queries are not registered. The `Subscribe` method does not return data directly. `spacetime generate` will generate classes [`SpacetimeDB.Types.{TABLE}`](#class-table) for each table in your module. These classes are used to reecive information from the database. See the section [View Rows of Subscribed Tables](#view-rows-of-subscribed-tables) for more information. -A new call to `Subscribe` will remove all previous subscriptions and replace them with the new `queries`. If any rows matched the previous subscribed queries but do not match the new queries, those rows will be removed from the client cache, and [`{TABLE}.OnDelete`](#event-tableondelete) callbacks will be invoked for them. +A new call to `Subscribe` will remove all previous subscriptions and replace them with the new `queries`. If any rows matched the previous subscribed queries but do not match the new queries, those rows will be removed from the client cache, and [`{TABLE}.OnDelete`](#static-event-tableoninsert) callbacks will be invoked for them. ```cs using SpacetimeDB; @@ -290,7 +291,7 @@ void Main() } ``` -### Method [`OneTimeQuery`](#method-spacetimedbclientsubscribe) +### Method [`SpacetimeDBClient.OneOffQuery`] You may not want to subscribe to a query, but instead want to run a query once and receive the results immediately via a `Task` result: @@ -317,6 +318,7 @@ Static Methods: - [`{TABLE}.Iter()`](#static-method-tableiter) iterates all subscribed rows in the client cache. - [`{TABLE}.FilterBy{COLUMN}(value)`](#static-method-tablefilterbycolumn) filters subscribed rows in the client cache by a column value. +- [`{TABLE}.FindBy{COLUMN}(value)`](#static-method-tablefindbycolumn) finds a subscribed row in the client cache by a unique column value. - [`{TABLE}.Count()`](#static-method-tablecount) counts the number of subscribed rows in the client cache. Static Events: @@ -334,7 +336,7 @@ Note that it is not possible to directly insert into the database from the clien namespace SpacetimeDB.Types { class TABLE { - public static System.Collections.Generic.IEnumerable Iter(); + public static IEnumerable
Iter(); } } @@ -342,7 +344,7 @@ class TABLE { Iterate over all the subscribed rows in the table. This method is only available after [`SpacetimeDBClient.onSubscriptionApplied`](#event-spacetimedbclientonsubscriptionapplied) has occurred. -When iterating over rows and filtering for those containing a particular column, [`TableType::filter`](#method-filter) will be more efficient, so prefer it when possible. +When iterating over rows and filtering for those containing a particular column, [`{TABLE}.FilterBy{COLUMN}`](#static-method-tablefilterbycolumn) and [`{TABLE}.FindBy{COLUMN}`](#static-method-tablefindbycolumn) will be more efficient, so prefer those when possible. ```cs using SpacetimeDB; @@ -366,22 +368,32 @@ SpacetimeDBClient.instance.connect(/* ... */); namespace SpacetimeDB.Types { class TABLE { - // If the column has no #[unique] or #[primarykey] constraint - public static System.Collections.Generic.IEnumerable
FilterBySender(COLUMNTYPE value); + public static IEnumerable
FilterBySender(COLUMNTYPE value); +} + +} +``` + +For each column of a table, `spacetime generate` generates a static method on the [table class](#class-table) to filter subscribed rows where that column matches a requested value. + +These methods are named `filterBy{COLUMN}`, where `{COLUMN}` is the column name converted to `PascalCase`. The method's return type is an `IEnumerable` over the [table class](#class-table). + +#### Static Method `{TABLE}.FindBy{COLUMN}` +```cs +namespace SpacetimeDB.Types { + +class TABLE { // If the column has a #[unique] or #[primarykey] constraint - public static TABLE? FilterBySender(COLUMNTYPE value); + public static TABLE? FindBySender(COLUMNTYPE value); } } ``` -For each column of a table, `spacetime generate` generates a static method on the [table class](#class-table) to filter or seek subscribed rows where that column matches a requested value. These methods are named `filterBy{COLUMN}`, where `{COLUMN}` is the column name converted to `PascalCase`. - -The method's return type depends on the column's attributes: +For each unique column of a table (those annotated `#[unique]` or `#[primarykey]`), `spacetime generate` generates a static method on the [table class](#class-table) to seek a subscribed row where that column matches a requested value. -- For unique columns, including those annotated `#[unique]` and `#[primarykey]`, the `filterBy{COLUMN}` method returns a `{TABLE}?`, where `{TABLE}` is the [table class](#class-table). -- For non-unique columns, the `filter_by` method returns an `IEnumerator<{TABLE}>`. +These methods are named `findBy{COLUMN}`, where `{COLUMN}` is the column name converted to `PascalCase`. Those methods return a single instance of the [table class](#class-table) if a row is found, or `null` if no row matches the query. #### Static Method `{TABLE}.Count` @@ -856,7 +868,6 @@ A unique public identifier for a user of a database. Columns of type `Identity` inside a module will be represented in the C# SDK as properties of type `byte[]`. `Identity` is essentially just a wrapper around `byte[]`, and you can use the `Bytes` property to get a `byte[]` that can be used to filter tables and so on. -### Class `Identity` ```cs namespace SpacetimeDB { diff --git a/docs/sdks/c-sharp/quickstart.md b/docs/sdks/c-sharp/quickstart.md index 122465d3..db06d9a4 100644 --- a/docs/sdks/c-sharp/quickstart.md +++ b/docs/sdks/c-sharp/quickstart.md @@ -200,14 +200,14 @@ void User_OnUpdate(User oldValue, User newValue, ReducerEvent dbEvent) When we receive a new message, we'll print it to standard output, along with the name of the user who sent it. Keep in mind that we only want to do this for new messages, i.e. those inserted by a `SendMessage` reducer invocation. We have to handle the backlog we receive when our subscription is initialized separately, to ensure they're printed in the correct order. To that effect, our `OnInsert` callback will check if its `ReducerEvent` argument is not `null`, and only print in that case. -To find the `User` based on the message's `Sender` identity, we'll use `User::FilterByIdentity`, which behaves like the same function on the server. The key difference is that, unlike on the module side, the client's `FilterByIdentity` accepts a `byte[]`, rather than an `Identity`. The `Sender` identity stored in the message is also a `byte[]`, not an `Identity`, so we can just pass it to the filter method. +To find the `User` based on the message's `Sender` identity, we'll use `User::FindByIdentity`, which behaves like the same function on the server. We'll print the user's name or identity in the same way as we did when notifying about `User` table events, but here we have to handle the case where we don't find a matching `User` row. This can happen when the module owner sends a message using the CLI's `spacetime call`. In this case, we'll print `unknown`. ```csharp void PrintMessage(Message message) { - var sender = User.FilterByIdentity(message.Sender); + var sender = User.FindByIdentity(message.Sender); var senderName = "unknown"; if (sender != null) { diff --git a/docs/sdks/rust/index.md b/docs/sdks/rust/index.md index bd914b00..dbc23112 100644 --- a/docs/sdks/rust/index.md +++ b/docs/sdks/rust/index.md @@ -51,13 +51,14 @@ mod module_bindings; | Function [`spacetimedb_sdk::identity::token`](#function-token) | Return the current connection's `Token`. | | Function [`spacetimedb_sdk::identity::credentials`](#function-credentials) | Return the current connection's [`Credentials`](#type-credentials). | | Function [`spacetimedb_sdk::identity::address`](#function-address) | Return the current connection's [`Address`](#type-address). | -| Function [`spacetimedb_sdk::identity::on_connect`](#function-on-connect) | Register a `FnMut` callback to run when the connection's [`Credentials`](#type-credentials) are verified with the database. | +| Function [`spacetimedb_sdk::identity::on_connect`](#function-on_connect) | Register a `FnMut` callback to run when the connection's [`Credentials`](#type-credentials) are verified with the database. | | Function [`spacetimedb_sdk::identity::once_on_connect`](#function-once_on_connect) | Register a `FnOnce` callback to run when the connection's [`Credentials`](#type-credentials) are verified with the database. | | Function [`spacetimedb_sdk::identity::remove_on_connect`](#function-remove_on_connect) | Cancel an `on_connect` or `once_on_connect` callback. | | Function [`spacetimedb_sdk::identity::load_credentials`](#function-load_credentials) | Load a saved [`Credentials`](#type-credentials) from a file. | | Function [`spacetimedb_sdk::identity::save_credentials`](#function-save_credentials) | Save a [`Credentials`](#type-credentials) to a file. | | Type [`module_bindings::{TABLE}`](#type-table) | Autogenerated `struct` type for a table, holding one row. | -| Method [`module_bindings::{TABLE}::filter_by_{COLUMN}`](#method-filter_by_column) | Autogenerated method to iterate over or seek subscribed rows where a column matches a value. | +| Method [`module_bindings::{TABLE}::filter_by_{COLUMN}`](#method-filter_by_column) | Autogenerated method to iterate over subscribed rows where a column matches a value. | +| Method [`module_bindings::{TABLE}::find_by_{COLUMN}`](#method-find_by_column) | Autogenerated method to seek a subscribed row where a unique column matches a value. | | Trait [`spacetimedb_sdk::table::TableType`](#trait-tabletype) | Automatically implemented for all tables defined by a module. | | Method [`spacetimedb_sdk::table::TableType::count`](#method-count) | Count the number of subscribed rows in a table. | | Method [`spacetimedb_sdk::table::TableType::iter`](#method-iter) | Iterate over all subscribed rows. | @@ -686,15 +687,24 @@ For each table defined by a module, `spacetime generate` generates a struct in t ```rust module_bindings::{TABLE}::filter_by_{COLUMN}( value: {COLUMN_TYPE}, -) -> {FILTER_RESULT}<{TABLE}> +) -> impl Iterator ``` -For each column of a table, `spacetime generate` generates a static method on the [table struct](#type-table) to filter or seek subscribed rows where that column matches a requested value. These methods are named `filter_by_{COLUMN}`, where `{COLUMN}` is the column name converted to `snake_case`. +For each column of a table, `spacetime generate` generates a static method on the [table struct](#type-table) to filter subscribed rows where that column matches a requested value. + +These methods are named `filter_by_{COLUMN}`, where `{COLUMN}` is the column name converted to `snake_case`. The method's return type is an `Iterator` over the `{TABLE}` rows which match the requested value. + +### Method `find_by_{COLUMN}` + +```rust +module_bindings::{TABLE}::find_by_{COLUMN}( + value: {COLUMN_TYPE}, +) -> {FILTER_RESULT}<{TABLE}> +``` -The method's return type depends on the column's attributes: +For each unique column of a table (those annotated `#[unique]` and `#[primarykey]`), `spacetime generate` generates a static method on the [table struct](#type-table) to seek a subscribed row where that column matches a requested value. -- For unique columns, including those annotated `#[unique]` and `#[primarykey]`, the `filter_by` method returns an `Option<{TABLE}>`, where `{TABLE}` is the [table struct](#type-table). -- For non-unique columns, the `filter_by` method returns an `impl Iterator`. +These methods are named `find_by_{COLUMN}`, where `{COLUMN}` is the column name converted to `snake_case`. The method's return type is `Option<{TABLE}>`. ### Trait `TableType` @@ -816,7 +826,7 @@ This method acquires a global lock. If multiple subscribed rows match `predicate`, one is chosen arbitrarily. The choice may not be stable across different calls to `find` with the same `predicate`. -Client authors should prefer calling [tables' generated `filter_by_{COLUMN}` methods](#method-filter_by_column) when possible rather than calling `TableType::find`. +Client authors should prefer calling [tables' generated `find_by_{COLUMN}` methods](#method-find_by_column) when possible rather than calling `TableType::find`. ```rust connect(SPACETIMEDB_URI, DB_NAME, None) diff --git a/docs/sdks/rust/quickstart.md b/docs/sdks/rust/quickstart.md index d1969fc3..6df255e8 100644 --- a/docs/sdks/rust/quickstart.md +++ b/docs/sdks/rust/quickstart.md @@ -260,7 +260,7 @@ fn on_user_updated(old: &User, new: &User, _: Option<&ReducerEvent>) { When we receive a new message, we'll print it to standard output, along with the name of the user who sent it. Keep in mind that we only want to do this for new messages, i.e. those inserted by a `send_message` reducer invocation. We have to handle the backlog we receive when our subscription is initialized separately, to ensure they're printed in the correct order. To that effect, our `print_new_message` callback will check if its `reducer_event` argument is `Some`, and only print in that case. -To find the `User` based on the message's `sender` identity, we'll use `User::filter_by_identity`, which behaves like the same function on the server. The key difference is that, unlike on the module side, the client's `filter_by_identity` accepts an owned `Identity`, rather than a reference. We can `clone` the identity held in `message.sender`. +To find the `User` based on the message's `sender` identity, we'll use `User::find_by_identity`, which behaves like the same function on the server. The key difference is that, unlike on the module side, the client's `find_by_identity` accepts an owned `Identity`, rather than a reference. We can `clone` the identity held in `message.sender`. We'll print the user's name or identity in the same way as we did when notifying about `User` table events, but here we have to handle the case where we don't find a matching `User` row. This can happen when the module owner sends a message using the CLI's `spacetime call`. In this case, we'll print `unknown`. @@ -275,7 +275,7 @@ fn on_message_inserted(message: &Message, reducer_event: Option<&ReducerEvent>) } fn print_message(message: &Message) { - let sender = User::filter_by_identity(message.sender.clone()) + let sender = User::find_by_identity(message.sender.clone()) .map(|u| user_name_or_identity(&u)) .unwrap_or_else(|| "unknown".to_string()); println!("{}: {}", sender, message.text); diff --git a/docs/sdks/typescript/index.md b/docs/sdks/typescript/index.md index 166c1575..00917813 100644 --- a/docs/sdks/typescript/index.md +++ b/docs/sdks/typescript/index.md @@ -553,7 +553,8 @@ The generated class has a field for each of the table's columns, whose names are | Methods | | | [`Table.isEqual`](#table-isequal) | Method to compare two identities. | | [`Table.all`](#table-all) | Return all the subscribed rows in the table. | -| [`Table.filterBy{COLUMN}`](#table-filterbycolumn) | Autogenerated; returned subscribed rows with a given value in a particular column. `{COLUMN}` is a placeholder for a column name. | +| [`Table.filterBy{COLUMN}`](#table-filterbycolumn) | Autogenerated; return subscribed rows with a given value in a particular column. `{COLUMN}` is a placeholder for a column name. | +| [`Table.findBy{COLUMN}`](#table-findbycolumn) | Autogenerated; return a subscribed row with a given value in a particular unique column. `{COLUMN}` is a placeholder for a column name. | | Events | | | [`Table.onInsert`](#table-oninsert) | Register an `onInsert` callback for when a subscribed row is newly inserted into the database. | | [`Table.removeOnInsert`](#table-removeoninsert) | Unregister a previously-registered [`onInsert`](#table-oninsert) callback. | @@ -638,12 +639,12 @@ spacetimeDBClient.onConnect((token, identity, address) => { ### {Table} filterBy{COLUMN} -For each column of a table, `spacetime generate` generates a static method on the `Class` to filter or seek subscribed rows where that column matches a requested value. +For each column of a table, `spacetime generate` generates a static method on the `Class` to filter subscribed rows where that column matches a requested value. These methods are named `filterBy{COLUMN}`, where `{COLUMN}` is the column name converted to `camelCase`. ```ts -{Table}.filterBy{COLUMN}(value): {Table}[] +{Table}.filterBy{COLUMN}(value): Iterable<{Table}> ``` #### Parameters @@ -654,7 +655,43 @@ These methods are named `filterBy{COLUMN}`, where `{COLUMN}` is the column name #### Returns -`{Table}[]` +`Iterable<{Table}>` + +#### Example + +```ts +var spacetimeDBClient = new SpacetimeDBClient('ws://localhost:3000', 'database_name'); + +spacetimeDBClient.onConnect((token, identity, address) => { + spacetimeDBClient.subscribe(['SELECT * FROM Person']); + + setTimeout(() => { + console.log(...Person.filterByName('John')); // prints all the `Person` rows named John. + }, 5000); +}); +``` + +--- + +### {Table} findBy{COLUMN} + +For each unique column of a table, `spacetime generate` generates a static method on the `Class` to find the subscribed row where that column matches a requested value. + +These methods are named `findBy{COLUMN}`, where `{COLUMN}` is the column name converted to `camelCase`. + +```ts +{Table}.findBy{COLUMN}(value): {Table} | undefined +``` + +#### Parameters + +| Name | Type | +| :------ | :-------------------------- | +| `value` | The type of the `{COLUMN}`. | + +#### Returns + +`{Table} | undefined` #### Example @@ -665,7 +702,7 @@ spacetimeDBClient.onConnect((token, identity, address) => { spacetimeDBClient.subscribe(['SELECT * FROM Person']); setTimeout(() => { - console.log(Person.filterByName('John')); // prints all the `Person` rows named John. + console.log(Person.findById(0)); // prints a `Person` row with id 0. }, 5000); }); ``` diff --git a/docs/sdks/typescript/quickstart.md b/docs/sdks/typescript/quickstart.md index ca8abff9..46b758ea 100644 --- a/docs/sdks/typescript/quickstart.md +++ b/docs/sdks/typescript/quickstart.md @@ -257,7 +257,7 @@ This callback fires when our local client cache of the database is populated. Th We'll define a helper function, `setAllMessagesInOrder`, to supply the `MessageType` class for our React application. It will call the autogenerated `Message.all` function to get an array of `Message` rows, then sort them and convert them to `MessageType`. -To find the `User` based on the message's `sender` identity, we'll use `User::filterByIdentity`, which behaves like the same function on the server. The key difference is that, unlike on the module side, the client's `filterByIdentity` accepts a `UInt8Array`, rather than an `Identity`. The `sender` identity stored in the message is also a `UInt8Array`, not an `Identity`, so we can just pass it to the filter method. +To find the `User` based on the message's `sender` identity, we'll use `User::findByIdentity`, which behaves like the same function on the server. Whenever we want to display a user name, if they have set a name, we'll use that. If they haven't set a name, we'll instead use the first 8 bytes of their identity, encoded as hexadecimal. We'll define the function `userNameOrIdentity` to handle this. @@ -282,7 +282,7 @@ function setAllMessagesInOrder() { messages.sort((a, b) => (a.sent > b.sent ? 1 : a.sent < b.sent ? -1 : 0)); let messagesType: MessageType[] = messages.map((message) => { - let sender_identity = User.filterByIdentity(message.sender); + let sender_identity = User.findByIdentity(message.sender); let display_name = sender_identity ? userNameOrIdentity(sender_identity) : "unknown"; @@ -298,7 +298,7 @@ function setAllMessagesInOrder() { client.current.on("initialStateSync", () => { setAllMessagesInOrder(); - var user = User.filterByIdentity(local_identity?.current?.toUint8Array()!); + var user = User.findByIdentity(local_identity?.current?.toUint8Array()!); setName(userNameOrIdentity(user!)); }); ``` diff --git a/docs/unity/part-2a-rust.md b/docs/unity/part-2a-rust.md index dbfdc888..58523f57 100644 --- a/docs/unity/part-2a-rust.md +++ b/docs/unity/part-2a-rust.md @@ -114,7 +114,7 @@ pub fn create_player(ctx: ReducerContext, username: String) -> Result<(), String let owner_id = ctx.sender; // Make sure we don't already have a player with this identity - if PlayerComponent::filter_by_owner_id(&owner_id).is_some() { + if PlayerComponent::find_by_owner_id(&owner_id).is_some() { log::info!("Player already exists"); return Err("Player already exists".to_string()); } @@ -157,7 +157,7 @@ SpacetimeDB gives you the ability to define custom reducers that automatically t - `connect` - Called when a user connects to the SpacetimeDB module. Their identity can be found in the `sender` value of the `ReducerContext`. - `disconnect` - Called when a user disconnects from the SpacetimeDB module. -Next, we are going to write a custom `Init` reducer that inserts the default message of the day into our `Config` table. The `Config` table only ever contains a single row with version 0, which we retrieve using `Config.FilterByVersion(0)`. +Next, we are going to write a custom `Init` reducer that inserts the default message of the day into our `Config` table. **Append to the bottom of lib.rs:** @@ -196,7 +196,7 @@ pub fn client_disconnected(ctx: ReducerContext) { // This helper function gets the PlayerComponent, sets the logged // in variable and updates the PlayerComponent table row. pub fn update_player_login_state(ctx: ReducerContext, logged_in: bool) { - if let Some(player) = PlayerComponent::filter_by_owner_id(&ctx.sender) { + if let Some(player) = PlayerComponent::find_by_owner_id(&ctx.sender) { // We clone the PlayerComponent so we can edit it and pass it back. let mut player = player.clone(); player.logged_in = logged_in; @@ -222,8 +222,8 @@ pub fn update_player_position( ) -> Result<(), String> { // First, look up the player using the sender identity, then use that // entity_id to retrieve and update the EntityComponent - if let Some(player) = PlayerComponent::filter_by_owner_id(&ctx.sender) { - if let Some(mut entity) = EntityComponent::filter_by_entity_id(&player.entity_id) { + if let Some(player) = PlayerComponent::find_by_owner_id(&ctx.sender) { + if let Some(mut entity) = EntityComponent::find_by_entity_id(&player.entity_id) { entity.position = position; entity.direction = direction; entity.moving = moving; @@ -286,7 +286,7 @@ Now we need to add a reducer to handle inserting new chat messages. // Adds a chat entry to the ChatMessage table #[spacetimedb(reducer)] pub fn send_chat_message(ctx: ReducerContext, text: String) -> Result<(), String> { - if let Some(player) = PlayerComponent::filter_by_owner_id(&ctx.sender) { + if let Some(player) = PlayerComponent::find_by_owner_id(&ctx.sender) { // Now that we have the player we can insert the chat message using the player entity id. ChatMessage::insert(ChatMessage { // this column auto-increments so we can set it to 0 diff --git a/docs/unity/part-2b-c-sharp.md b/docs/unity/part-2b-c-sharp.md index e311714a..e4dcac7a 100644 --- a/docs/unity/part-2b-c-sharp.md +++ b/docs/unity/part-2b-c-sharp.md @@ -172,7 +172,7 @@ SpacetimeDB gives you the ability to define custom reducers that automatically t - `Connect` - Called when a user connects to the SpacetimeDB module. Their identity can be found in the `Sender` value of the `ReducerContext`. - `Disconnect` - Called when a user disconnects from the SpacetimeDB module. -Next, we are going to write a custom `Init` reducer that inserts the default message of the day into our `Config` table. The `Config` table only ever contains a single row with version 0, which we retrieve using `Config.FilterByVersion(0)`. +Next, we are going to write a custom `Init` reducer that inserts the default message of the day into our `Config` table. **Append to the bottom of lib.cs:** diff --git a/docs/unity/part-3.md b/docs/unity/part-3.md index d1db4dbb..5c47cdc8 100644 --- a/docs/unity/part-3.md +++ b/docs/unity/part-3.md @@ -117,7 +117,7 @@ Subscribing to tables tells SpacetimeDB what rows we want in our local client ca **Local Client Cache** -The "local client cache" is a client-side view of the database defined by the supplied queries to the `Subscribe` function. It contains the requested data which allows efficient access without unnecessary server queries. Accessing data from the client cache is done using the auto-generated iter and filter_by functions for each table, and it ensures that update and event callbacks are limited to the subscribed rows. +The "local client cache" is a client-side view of the database defined by the supplied queries to the `Subscribe` function. It contains the requested data which allows efficient access without unnecessary server queries. Accessing data from the client cache is done using the auto-generated `Iter`, `FilterBy`, and `FindBy` functions for each table, and it ensures that update and event callbacks are limited to the subscribed rows. --- @@ -131,7 +131,7 @@ void OnSubscriptionApplied() // If we don't have any data for our player, then we are creating a // new one. Let's show the username dialog, which will then call the // create player reducer - var player = PlayerComponent.FilterByOwnerId(local_identity); + var player = PlayerComponent.FindByOwnerId(local_identity); if (player == null) { // Show username selection @@ -139,7 +139,7 @@ void OnSubscriptionApplied() } // Show the Message of the Day in our Config table of the Client Cache - UIChatController.instance.OnChatMessageReceived("Message of the Day: " + Config.FilterByVersion(0).MessageOfTheDay); + UIChatController.instance.OnChatMessageReceived("Message of the Day: " + Config.FindByVersion(0).MessageOfTheDay); // Now that we've done this work we can unregister this callback SpacetimeDBClient.instance.onSubscriptionApplied -= OnSubscriptionApplied; @@ -200,7 +200,7 @@ public class RemotePlayer : MonoBehaviour canvas.worldCamera = Camera.main; // Get the username from the PlayerComponent for this object and set it in the UI - PlayerComponent? playerComp = PlayerComponent.FilterByEntityId(EntityId).FirstOrDefault(); + PlayerComponent? playerComp = PlayerComponent.FindByEntityId(EntityId); if (playerComp is null) { string inputUsername = UsernameElement.Text; @@ -208,13 +208,13 @@ public class RemotePlayer : MonoBehaviour Reducer.CreatePlayer(inputUsername); // Try again, optimistically assuming success for simplicity - PlayerComponent? playerComp = PlayerComponent.FilterByEntityId(EntityId).FirstOrDefault(); + PlayerComponent? playerComp = PlayerComponent.FindByEntityId(EntityId); } Username = playerComp.Username; // Get the last location for this player and set the initial position - EntityComponent entity = EntityComponent.FilterByEntityId(EntityId); + EntityComponent entity = EntityComponent.FindByEntityId(EntityId); transform.position = new Vector3(entity.Position.X, entity.Position.Y, entity.Position.Z); // Register for a callback that is called when the client gets an @@ -271,7 +271,7 @@ private void PlayerComponent_OnInsert(PlayerComponent obj, ReducerEvent callInfo var remotePlayer = Instantiate(PlayerPrefab); // Lookup and apply the position for this new player - var entity = EntityComponent.FilterByEntityId(obj.EntityId); + var entity = EntityComponent.FindByEntityId(obj.EntityId); var position = new Vector3(entity.Position.X, entity.Position.Y, entity.Position.Z); remotePlayer.transform.position = position; @@ -382,7 +382,7 @@ private void OnPlayerComponentChanged(PlayerComponent obj) var remotePlayer = Instantiate(PlayerPrefab); // Lookup and apply the position for this new player - var entity = EntityComponent.FilterByEntityId(obj.EntityId); + var entity = EntityComponent.FindByEntityId(obj.EntityId); var position = new Vector3(entity.Position.X, entity.Position.Y, entity.Position.Z); remotePlayer.transform.position = position; @@ -448,7 +448,7 @@ Now we write the `OnSendChatMessageEvent` function. We can find the `PlayerCompo ```csharp private void OnSendChatMessageEvent(ReducerEvent dbEvent, string message) { - var player = PlayerComponent.FilterByOwnerId(dbEvent.Identity); + var player = PlayerComponent.FindByOwnerId(dbEvent.Identity); if (player != null) { UIChatController.instance.OnChatMessageReceived(player.Username + ": " + message); diff --git a/docs/unity/part-4.md b/docs/unity/part-4.md index b3a17439..10738e84 100644 --- a/docs/unity/part-4.md +++ b/docs/unity/part-4.md @@ -103,7 +103,7 @@ pub struct Config { ```rust #[spacetimedb(reducer, repeat = 1000ms)] pub fn resource_spawner_agent(_ctx: ReducerContext, _prev_time: Timestamp) -> Result<(), String> { - let config = Config::filter_by_version(&0).unwrap(); + let config = Config::find_by_version(&0).unwrap(); // Retrieve the maximum number of nodes we want to spawn from the Config table let num_resource_nodes = config.num_resource_nodes as usize; @@ -247,7 +247,7 @@ To get the position and the rotation of the node, we look up the `StaticLocation { case ResourceNodeType.Iron: var iron = Instantiate(IronPrefab); - StaticLocationComponent loc = StaticLocationComponent.FilterByEntityId(insertedValue.EntityId); + StaticLocationComponent loc = StaticLocationComponent.FindByEntityId(insertedValue.EntityId); Vector3 nodePos = new Vector3(loc.Location.X, 0.0f, loc.Location.Z); iron.transform.position = new Vector3(nodePos.x, MathUtil.GetTerrainHeight(nodePos), nodePos.z); iron.transform.rotation = Quaternion.Euler(0.0f, loc.Rotation, 0.0f); From fed397e37acd7ae58965bda372462fbfe48f007a Mon Sep 17 00:00:00 2001 From: Ingvar Stepanyan Date: Tue, 18 Jun 2024 16:13:16 +0100 Subject: [PATCH 051/164] Update C# tagged enum docs (#65) * Update C# tagged enum docs * Apply suggestions from code review Co-authored-by: Phoebe Goldman * Reword --------- Co-authored-by: Phoebe Goldman --- docs/modules/c-sharp/index.md | 47 ++++++++++++++++++++++++++++------- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/docs/modules/c-sharp/index.md b/docs/modules/c-sharp/index.md index 7037a2a8..6fdc84be 100644 --- a/docs/modules/c-sharp/index.md +++ b/docs/modules/c-sharp/index.md @@ -152,21 +152,50 @@ public enum Color SpacetimeDB has support for tagged enums which can be found in languages like Rust, but not C#. -To bridge the gap, a special marker interface `SpacetimeDB.TaggedEnum` can be used on any `SpacetimeDB.Type`-marked `struct` or `class` to mark it as a SpacetimeDB tagged enum. It accepts a tuple of 2 or more named items and will generate methods to check which variant is currently active, as well as accessors for each variant. +We provide a tagged enum support for C# modules via a special `record SpacetimeDB.TaggedEnum<(...types and names of the variants as a tuple...)>`. -It is expected that you will use the `Is*` methods to check which variant is active before accessing the corresponding field, as the accessor will throw an exception on a state mismatch. +When you inherit from the `SpacetimeDB.TaggedEnum` marker, it will generate variants as subclasses of the annotated type, so you can use regular C# pattern matching operators like `is` or `switch` to determine which variant a given tagged enum holds at any time. + +For unit variants (those without any data payload) you can use a built-in `SpacetimeDB.Unit` as the variant type. + +Example: ```csharp -// Example declaration: +// Define a tagged enum named `MyEnum` with three variants, +// `MyEnum.String`, `MyEnum.Int` and `MyEnum.None`. [SpacetimeDB.Type] -partial struct Option : SpacetimeDB.TaggedEnum<(T Some, Unit None)> { } - -// Usage: -var option = new Option { Some = 42 }; -if (option.IsSome) +public partial record MyEnum : SpacetimeDB.TaggedEnum<( + string String, + int Int, + SpacetimeDB.Unit None +)>; + +// Print an instance of `MyEnum`, using `switch`/`case` to determine the active variant. +void PrintEnum(MyEnum e) { - Log($"Value: {option.Some}"); + switch (e) + { + case MyEnum.String(var s): + Console.WriteLine(s); + break; + + case MyEnum.Int(var i): + Console.WriteLine(i); + break; + + case MyEnum.None: + Console.WriteLine("(none)"); + break; + } } + +// Test whether an instance of `MyEnum` holds some value (either a string or an int one). +bool IsSome(MyEnum e) => e is not MyEnum.None; + +// Construct an instance of `MyEnum` with the `String` variant active. +var myEnum = new MyEnum.String("Hello, world!"); +Console.WriteLine($"IsSome: {IsSome(myEnum)}"); +PrintEnum(myEnum); ``` ### Tables From 2f308933d4572acb75975389e1eec7e95f32bbe9 Mon Sep 17 00:00:00 2001 From: Chip <36650721+Lethalchip@users.noreply.github.com> Date: Thu, 27 Jun 2024 09:39:46 -0700 Subject: [PATCH 052/164] CSharp Module tweak & Unity Tutorial part 1, 2b, 3 tweaks (#56) * expanded on taggedenums and added examples for each special ReducerKind Fixed a few typos/mistakes here and there also. * fixed part2 hyperlinks * fixed config version type from Identity to uint * update Throw => throw * update log typo * fix type on connect reducerkind from init=>connect * private=>public for UpdatePlayerLoginState reducer * remove double "publish" condenses it into one publish at the end after chat * fixed name of GameManager file, tweaks to instructions kept application.runInBackground (it wasn't included) renamed many instances of "TutorialGameManager.cs" to "BitcraftMiniGameManager.cs" to represent accurate filename * fixed onConnectError * more TutorialGameManager renames to BitcraftMiniGameManager.cs and also a FilterByX fix * added clarity to UIUsernameChooser.cs and LocalPlayer.cs -- Also fixed RemotePlayer.cs errors * some small tweaks again to GameManager name * updated tagged enums to reflect record usage and pattern matching * filter -> find fixes * expanded on taggedenums and added examples for each special ReducerKind Fixed a few typos/mistakes here and there also. * fixed config version type from Identity to uint * update Throw => throw * update log typo * fix type on connect reducerkind from init=>connect * private=>public for UpdatePlayerLoginState reducer * remove double "publish" condenses it into one publish at the end after chat * fixed name of GameManager file, tweaks to instructions kept application.runInBackground (it wasn't included) renamed many instances of "TutorialGameManager.cs" to "BitcraftMiniGameManager.cs" to represent accurate filename * fixed onConnectError * more TutorialGameManager renames to BitcraftMiniGameManager.cs and also a FilterByX fix * added clarity to UIUsernameChooser.cs and LocalPlayer.cs -- Also fixed RemotePlayer.cs errors * some small tweaks again to GameManager name * updated tagged enums to reflect record usage and pattern matching * filter -> find fixes * updated based on feedback --- docs/modules/c-sharp/index.md | 21 +++++++++++++++++++-- docs/unity/part-1.md | 4 ++-- docs/unity/part-2b-c-sharp.md | 19 ++++++------------- docs/unity/part-3.md | 26 ++++++++++++++------------ 4 files changed, 41 insertions(+), 29 deletions(-) diff --git a/docs/modules/c-sharp/index.md b/docs/modules/c-sharp/index.md index 6fdc84be..ad1446fb 100644 --- a/docs/modules/c-sharp/index.md +++ b/docs/modules/c-sharp/index.md @@ -321,7 +321,7 @@ public static void AddIn5Minutes(ReducerContext e, string name, int age) #### Special reducers -These are two special kinds of reducers that can be used to respond to module lifecycle events. They're stored in the `SpacetimeDB.Module.ReducerKind` class and can be used as an argument to the `[SpacetimeDB.Reducer]` attribute: +These are four special kinds of reducers that can be used to respond to module lifecycle events. They're stored in the `SpacetimeDB.Module.ReducerKind` class and can be used as an argument to the `[SpacetimeDB.Reducer]` attribute: - `ReducerKind.Init` - this reducer will be invoked when the module is first published. - `ReducerKind.Update` - this reducer will be invoked when the module is updated. @@ -337,4 +337,21 @@ public static void Init() { Log("...and we're live!"); } -``` + +[SpacetimeDB.Reducer(ReducerKind.Update)] +public static void Update() +{ + Log("Update get!"); +} + +[SpacetimeDB.Reducer(ReducerKind.Connect)] +public static void OnConnect(DbEventArgs ctx) +{ + Log($"{ctx.Sender} has connected from {ctx.Address}!"); +} + +[SpacetimeDB.Reducer(ReducerKind.Disconnect)] +public static void OnDisconnect(DbEventArgs ctx) +{ + Log($"{ctx.Sender} has disconnected."); +}``` diff --git a/docs/unity/part-1.md b/docs/unity/part-1.md index c53814d1..5643a285 100644 --- a/docs/unity/part-1.md +++ b/docs/unity/part-1.md @@ -119,5 +119,5 @@ We chose ECS for this example project because it promotes scalability, modularit From here, the tutorial continues with your favorite server module language of choice: -- [Rust](part-2a-rust) -- [C#](part-2b-c-sharp) + - [Rust](part-2a-rust.md) + - [C#](part-2b-csharp.md) diff --git a/docs/unity/part-2b-c-sharp.md b/docs/unity/part-2b-c-sharp.md index e4dcac7a..5be1c7cb 100644 --- a/docs/unity/part-2b-c-sharp.md +++ b/docs/unity/part-2b-c-sharp.md @@ -41,7 +41,7 @@ Then we are going to start by adding the global `Config` table. Right now it onl public partial class Config { [SpacetimeDB.Column(ColumnAttrs.PrimaryKey)] - public Identity Version; + public uint Version; public string? MessageOfTheDay; } ``` @@ -133,8 +133,8 @@ public static void CreatePlayer(ReducerContext ctx, string username) } catch { - Log("Error: Failed to create a unique PlayerComponent", LogLevel.Error); - Throw; + Log("Error: Failed to create a unique EntityComponent", LogLevel.Error); + throw; } // The PlayerComponent uses the same entity_id and stores the identity of @@ -275,15 +275,6 @@ In a fully developed game, the server would typically perform server-side valida --- -### Publishing a Module to SpacetimeDB - -Now that we've written the code for our server module and reached a clean checkpoint, we need to publish it to SpacetimeDB. This will create the database and call the init reducer. In your terminal or command window, run the following commands. - -```bash -cd server -spacetime publish -c unity-tutorial -``` - ### Finally, Add Chat Support The client project has a chat window, but so far, all it's used for is the message of the day. We are going to add the ability for players to send chat messages to each other. @@ -335,11 +326,13 @@ public static void SendChatMessage(ReducerContext ctx, string text) ## Wrapping Up +### Publishing a Module to SpacetimeDB 💡View the [entire lib.cs file](https://gist.github.com/dylanh724/68067b4e843ea6e99fbd297fe1a87c49) -Now that we added chat support, let's publish the latest module version to SpacetimeDB, assuming we're still in the `server` dir: +Now that we've written the code for our server module and reached a clean checkpoint, we need to publish it to SpacetimeDB. This will create the database and call the init reducer. In your terminal or command window, run the following commands. ```bash +cd server spacetime publish -c unity-tutorial ``` diff --git a/docs/unity/part-3.md b/docs/unity/part-3.md index 5c47cdc8..d3eeec8c 100644 --- a/docs/unity/part-3.md +++ b/docs/unity/part-3.md @@ -34,9 +34,9 @@ The Unity SpacetimeDB SDK relies on there being a `NetworkManager` somewhere in ![Unity-AddNetworkManager](/images/unity-tutorial/Unity-AddNetworkManager.JPG) -Next we are going to connect to our SpacetimeDB module. Open `TutorialGameManager.cs` in your editor of choice and add the following code at the top of the file: +Next we are going to connect to our SpacetimeDB module. Open `Assets/_Project/Game/BitcraftMiniGameManager.cs` in your editor of choice and add the following code at the top of the file: -**Append to the top of TutorialGameManager.cs** +**Append to the top of BitcraftMiniGameManager.cs** ```csharp using SpacetimeDB; @@ -46,7 +46,7 @@ using System.Linq; At the top of the class definition add the following members: -**Append to the top of TutorialGameManager class inside of TutorialGameManager.cs** +**Append to the top of BitcraftMiniGameManager class inside of BitcraftMiniGameManager.cs** ```csharp // These are connection variables that are exposed on the GameManager @@ -64,7 +64,7 @@ The first three fields will appear in your Inspector so you can update your conn Now add the following code to the `Start()` function. For clarity, replace your entire `Start()` function with the function below. -**REPLACE the Start() function in TutorialGameManager.cs** +**REPLACE the Start() function in BitcraftMiniGameManager.cs** ```csharp // Start is called before the first frame update @@ -72,6 +72,8 @@ void Start() { instance = this; + Application.runInBackground = true; + SpacetimeDBClient.instance.onConnect += () => { Debug.Log("Connected."); @@ -86,7 +88,7 @@ void Start() // Called when we have an error connecting to SpacetimeDB SpacetimeDBClient.instance.onConnectError += (error, message) => { - Debug.LogError($"Connection error: " + message); + Debug.LogError($"Connection error: {error} - {message}"); }; // Called when we are disconnected from SpacetimeDB @@ -123,7 +125,7 @@ The "local client cache" is a client-side view of the database defined by the su Next we write the `OnSubscriptionApplied` callback. When this event occurs for the first time, it signifies that our local client cache is fully populated. At this point, we can verify if a player entity already exists for the corresponding user. If we do not have a player entity, we need to show the `UserNameChooser` dialog so the user can enter a username. We also put the message of the day into the chat window. Finally we unsubscribe from the callback since we only need to do this once. -**Append after the Start() function in TutorialGameManager.cs** +**Append after the Start() function in BitcraftMiniGameManager.cs** ```csharp void OnSubscriptionApplied() @@ -148,7 +150,7 @@ void OnSubscriptionApplied() ### Adding the Multiplayer Functionality -Now we have to change what happens when you press the "Continue" button in the name dialog window. Instead of calling start game like we did in the single player version, we call the `create_player` reducer on the SpacetimeDB module using the auto-generated code. Open `UIUsernameChooser.cs`. +Now we have to change what happens when you press the "Continue" button in the name dialog window. Instead of calling start game like we did in the single player version, we call the `create_player` reducer on the SpacetimeDB module using the auto-generated code. Open `Assets/_Project/Username/UIUsernameChooser.cs`. **Append to the top of UIUsernameChooser.cs** @@ -171,7 +173,7 @@ public void ButtonPressed() } ``` -We need to create a `RemotePlayer` script that we attach to remote player objects. In the same folder as `LocalPlayer.cs`, create a new C# script called `RemotePlayer`. In the start function, we will register an OnUpdate callback for the `EntityComponent` and query the local cache to get the player’s initial position. **Make sure you include a `using SpacetimeDB.Types;`** at the top of the file. +We need to create a `RemotePlayer` script that we attach to remote player objects. In the same folder as `Assets/_Project/Player/LocalPlayer.cs`, create a new C# script called `RemotePlayer`. In the start function, we will register an OnUpdate callback for the `EntityComponent` and query the local cache to get the player’s initial position. **Make sure you include a `using SpacetimeDB.Types;`** at the top of the file. First append this using to the top of `RemotePlayer.cs` @@ -203,7 +205,7 @@ public class RemotePlayer : MonoBehaviour PlayerComponent? playerComp = PlayerComponent.FindByEntityId(EntityId); if (playerComp is null) { - string inputUsername = UsernameElement.Text; + string inputUsername = UsernameElement.text; Debug.Log($"PlayerComponent not found - Creating a new player ({inputUsername})"); Reducer.CreatePlayer(inputUsername); @@ -246,7 +248,7 @@ private void EntityComponent_OnUpdate(EntityComponent oldObj, EntityComponent ob Next we need to handle what happens when a `PlayerComponent` is added to our local cache. We will handle it differently based on if it’s our local player entity or a remote player. We are going to register for the `OnInsert` event for our `PlayerComponent` table. Add the following code to the `Start` function in `TutorialGameManager`. -**Append to bottom of Start() function in TutorialGameManager.cs:** +**Append to bottom of Start() function in BitcraftMiniGameManager.cs:** ```csharp PlayerComponent.OnInsert += PlayerComponent_OnInsert; @@ -254,13 +256,13 @@ PlayerComponent.OnInsert += PlayerComponent_OnInsert; Create the `PlayerComponent_OnInsert` function which does something different depending on if it's the component for the local player or a remote player. If it's the local player, we set the local player object's initial position and call `StartGame`. If it's a remote player, we instantiate a `PlayerPrefab` with the `RemotePlayer` component. The start function of `RemotePlayer` handles initializing the player position. -**Append to bottom of TutorialGameManager class in TutorialGameManager.cs:** +**Append to bottom of TutorialGameManager class in BitcraftMiniGameManager.cs:** ```csharp private void PlayerComponent_OnInsert(PlayerComponent obj, ReducerEvent callInfo) { // If the identity of the PlayerComponent matches our user identity then this is the local player - if(obj.OwnerId == local_identity) + if(obj.Identity == local_identity) { // Now that we have our initial position we can start the game StartGame(); From 70cca46c76a476b4485b9b0e7ecf44d50330abf7 Mon Sep 17 00:00:00 2001 From: Zeke Foppa <196249+bfops@users.noreply.github.com> Date: Fri, 26 Jul 2024 18:18:43 +0000 Subject: [PATCH 053/164] Remove Python & update "coming soon" languages (#72) * [bfops/remove-python]: do thing * [bfops/remove-python]: empty --------- Co-authored-by: Zeke Foppa --- docs/getting-started.md | 1 - docs/http/index.md | 9 - docs/index.md | 3 +- docs/modules/c-sharp/quickstart.md | 2 +- docs/modules/rust/quickstart.md | 2 +- docs/nav.js | 2 - docs/sdks/index.md | 5 +- docs/sdks/python/index.md | 552 ----------------------------- docs/sdks/python/quickstart.md | 379 -------------------- nav.ts | 2 - 10 files changed, 5 insertions(+), 952 deletions(-) delete mode 100644 docs/sdks/python/index.md delete mode 100644 docs/sdks/python/quickstart.md diff --git a/docs/getting-started.md b/docs/getting-started.md index 177a0d25..4b0cddae 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -31,4 +31,3 @@ You are ready to start developing SpacetimeDB modules. See below for a quickstar - [C# (Standalone)](/docs/sdks/c-sharp/quickstart) - [C# (Unity)](/docs/unity/part-1) - [Typescript](/docs/sdks/typescript/quickstart) -- [Python](/docs/sdks/python/quickstart) \ No newline at end of file diff --git a/docs/http/index.md b/docs/http/index.md index a4e885b1..a59408e4 100644 --- a/docs/http/index.md +++ b/docs/http/index.md @@ -20,15 +20,6 @@ To construct an appropriate `Authorization` header value for a `token`: 2. Base64-encode. 3. Prepend the string `Basic `. -#### Python - -```python -def auth_header_value(token): - username_and_password = f"token:{token}".encode("utf-8") - base64_encoded = base64.b64encode(username_and_password).decode("utf-8") - return f"Basic {base64_encoded}" -``` - #### Rust ```rust diff --git a/docs/index.md b/docs/index.md index eaee2c83..700c2bfc 100644 --- a/docs/index.md +++ b/docs/index.md @@ -71,7 +71,6 @@ Currently, Rust is the best-supported language for writing SpacetimeDB modules. - [Rust](/docs/modules/rust) - [(Quickstart)](/docs/modules/rust/quickstart) - [C#](/docs/modules/c-sharp) - [(Quickstart)](/docs/modules/c-sharp/quickstart) - Python (Coming soon) -- C# (Coming soon) - Typescript (Coming soon) - C++ (Planned) - Lua (Planned) @@ -81,7 +80,7 @@ Currently, Rust is the best-supported language for writing SpacetimeDB modules. - [Rust](/docs/sdks/rust) - [(Quickstart)](/docs/sdks/rust/quickstart) - [C#](/docs/sdks/c-sharp) - [(Quickstart)](/docs/sdks/c-sharp/quickstart) - [TypeScript](/docs/sdks/typescript) - [(Quickstart)](/docs/sdks/typescript/quickstart) -- [Python](/docs/sdks/python) - [(Quickstart)](/docs/sdks/python/quickstart) +- Python (Planned) - C++ (Planned) - Lua (Planned) diff --git a/docs/modules/c-sharp/quickstart.md b/docs/modules/c-sharp/quickstart.md index 559dca92..027b7ef9 100644 --- a/docs/modules/c-sharp/quickstart.md +++ b/docs/modules/c-sharp/quickstart.md @@ -308,6 +308,6 @@ spacetime sql "SELECT * FROM Message" ## What's next? -You've just set up your first database in SpacetimeDB! The next step would be to create a client module that interacts with this module. You can use any of SpacetimDB's supported client languages to do this. Take a look at the quick start guide for your client language of choice: [Rust](/docs/languages/rust/rust-sdk-quickstart-guide), [C#](/docs/languages/csharp/csharp-sdk-quickstart-guide), [TypeScript](/docs/languages/typescript/typescript-sdk-quickstart-guide) or [Python](/docs/languages/python/python-sdk-quickstart-guide). +You've just set up your first database in SpacetimeDB! The next step would be to create a client module that interacts with this module. You can use any of SpacetimDB's supported client languages to do this. Take a look at the quick start guide for your client language of choice: [Rust](/docs/languages/rust/rust-sdk-quickstart-guide), [C#](/docs/languages/csharp/csharp-sdk-quickstart-guide), or [TypeScript](/docs/languages/typescript/typescript-sdk-quickstart-guide). If you are planning to use SpacetimeDB with the Unity game engine, you can skip right to the [Unity Comprehensive Tutorial](/docs/unity/part-1) or check out our example game, [BitcraftMini](/docs/unity/part-3). diff --git a/docs/modules/rust/quickstart.md b/docs/modules/rust/quickstart.md index 346810d7..e115ac97 100644 --- a/docs/modules/rust/quickstart.md +++ b/docs/modules/rust/quickstart.md @@ -271,6 +271,6 @@ spacetime sql "SELECT * FROM Message" You can find the full code for this module [in the SpacetimeDB module examples](https://github.com/clockworklabs/SpacetimeDB/tree/master/modules/quickstart-chat). -You've just set up your first database in SpacetimeDB! The next step would be to create a client module that interacts with this module. You can use any of SpacetimDB's supported client languages to do this. Take a look at the quickstart guide for your client language of choice: [Rust](/docs/sdks/rust/quickstart), [C#](/docs/sdks/c-sharp/quickstart), [TypeScript](/docs/sdks/typescript/quickstart) or [Python](/docs/sdks/python/quickstart). +You've just set up your first database in SpacetimeDB! The next step would be to create a client module that interacts with this module. You can use any of SpacetimDB's supported client languages to do this. Take a look at the quickstart guide for your client language of choice: [Rust](/docs/sdks/rust/quickstart), [C#](/docs/sdks/c-sharp/quickstart), or [TypeScript](/docs/sdks/typescript/quickstart). If you are planning to use SpacetimeDB with the Unity game engine, you can skip right to the [Unity Comprehensive Tutorial](/docs/unity/part-1) or check out our example game, [BitcraftMini](/docs/unity/part-3). diff --git a/docs/nav.js b/docs/nav.js index 4413888e..6949c4f7 100644 --- a/docs/nav.js +++ b/docs/nav.js @@ -34,8 +34,6 @@ const nav = { page("Typescript Reference", "sdks/typescript", "sdks/typescript/index.md"), page("Rust Quickstart", "sdks/rust/quickstart", "sdks/rust/quickstart.md"), page("Rust Reference", "sdks/rust", "sdks/rust/index.md"), - page("Python Quickstart", "sdks/python/quickstart", "sdks/python/quickstart.md"), - page("Python Reference", "sdks/python", "sdks/python/index.md"), page("C# Quickstart", "sdks/c-sharp/quickstart", "sdks/c-sharp/quickstart.md"), page("C# Reference", "sdks/c-sharp", "sdks/c-sharp/index.md"), section("WebAssembly ABI"), diff --git a/docs/sdks/index.md b/docs/sdks/index.md index 6357e653..940f06ac 100644 --- a/docs/sdks/index.md +++ b/docs/sdks/index.md @@ -5,7 +5,6 @@ The SpacetimeDB Client SDKs provide a comprehensive interface to interact with t - [Rust](/docs/sdks/rust) - [(Quickstart)](/docs/sdks/rust/quickstart) - [C#](/docs/sdks/c-sharp) - [(Quickstart)](/docs/sdks/c-sharp/quickstart) - [TypeScript](/docs/sdks/typescript) - [(Quickstart)](/docs/sdks/typescript/quickstart) -- [Python](/docs/sdks/python) - [(Quickstart)](/docs/sdks/python/quickstart) ## Key Features @@ -55,7 +54,7 @@ The familiarity of your development team with a particular language can greatly ### Application Type -Different languages are often better suited to different types of applications. For instance, if you are developing a web-based application, you might opt for TypeScript due to its seamless integration with web technologies. On the other hand, if you're developing a desktop application, you might choose C# or Python, depending on your requirements and platform. Python is also very useful for utility scripts and tools. +Different languages are often better suited to different types of applications. For instance, if you are developing a web-based application, you might opt for TypeScript due to its seamless integration with web technologies. On the other hand, if you're developing a desktop application, you might choose C#, depending on your requirements and platform. ### Performance @@ -71,4 +70,4 @@ Each language has its own ecosystem of libraries and tools that can help in deve Remember, the best language to use is the one that best fits your use case and the one you and your team are most comfortable with. It's worth noting that due to the consistent functionality across different SDKs, transitioning from one language to another should you need to in the future will primarily involve syntax changes rather than changes in the application's logic. -You may want to use multiple languages in your application. For instance, you might want to use C# in Unity for your game logic, TypeScript for a web-based administration panel, and Python for utility scripts. This is perfectly fine, as the SpacetimeDB server is completely client-agnostic. +You may want to use multiple languages in your application. For instance, you might want to use C# in Unity for your game logic and TypeScript for a web-based administration panel. This is perfectly fine, as the SpacetimeDB server is completely client-agnostic. diff --git a/docs/sdks/python/index.md b/docs/sdks/python/index.md deleted file mode 100644 index 8b1ceb8b..00000000 --- a/docs/sdks/python/index.md +++ /dev/null @@ -1,552 +0,0 @@ -# The SpacetimeDB Python client SDK - -The SpacetimeDB client SDK for Python contains all the tools you need to build native clients for SpacetimeDB modules using Python. - -## Install the SDK - -Use pip to install the SDK: - -```bash -pip install spacetimedb-sdk -``` - -## Generate module bindings - -Each SpacetimeDB client depends on some bindings specific to your module. Create a `module_bindings` directory in your project's directory and generate the Python interface files using the Spacetime CLI. From your project directory, run: - -```bash -mkdir -p module_bindings -spacetime generate --lang python \ - --out-dir module_bindings \ - --project-path PATH-TO-MODULE-DIRECTORY -``` - -Replace `PATH-TO-MODULE-DIRECTORY` with the path to your SpacetimeDB module. - -Import your bindings in your client's code: - -```python -import module_bindings -``` - -## Basic vs Async SpacetimeDB Client - -This SDK provides two different client modules for interacting with your SpacetimeDB module. - -The Basic client allows you to have control of the main loop of your application and you are responsible for regularly calling the client's `update` function. This is useful in settings like PyGame where you want to have full control of the main loop. - -The Async client has a run function that you call after you set up all your callbacks and it will take over the main loop and handle updating the client for you. With the async client, you can have a regular "tick" function by using the `schedule_event` function. - -## Common Client Reference - -The following functions and types are used in both the Basic and Async clients. - -### API at a glance - -| Definition | Description | -|---------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------| -| Type [`Identity`](#type-identity) | A unique public identifier for a client. | -| Type [`Address`](#type-address) | An opaque identifier for differentiating connections by the same `Identity`. | -| Type [`ReducerEvent`](#type-reducerevent) | `class` containing information about the reducer that triggered a row update event. | -| Type [`module_bindings::{TABLE}`](#type-table) | Autogenerated `class` type for a table, holding one row. | -| Method [`module_bindings::{TABLE}::filter_by_{COLUMN}`](#method-filter_by_column) | Autogenerated method to iterate over or seek subscribed rows where a column matches a value. | -| Method [`module_bindings::{TABLE}::iter`](#method-iter) | Autogenerated method to iterate over all subscribed rows. | -| Method [`module_bindings::{TABLE}::register_row_update`](#method-register_row_update) | Autogenerated method to register a callback that fires when a row changes. | -| Function [`module_bindings::{REDUCER_NAME}::{REDUCER_NAME}`](#function-reducer) | Autogenerated function to invoke a reducer. | -| Function [`module_bindings::{REDUCER_NAME}::register_on_{REDUCER_NAME}`](#function-register_on_reducer) | Autogenerated function to register a callback to run whenever the reducer is invoked. | - -### Type `Identity` - -```python -class Identity: - @staticmethod - def from_string(string) - - @staticmethod - def from_bytes(data) - - def __str__(self) - - def __eq__(self, other) -``` - -| Member | Args | Meaning | -| ------------- | ---------- | ------------------------------------ | -| `from_string` | `str` | Create an Identity from a hex string | -| `from_bytes` | `bytes` | Create an Identity from raw bytes | -| `__str__` | `None` | Convert the Identity to a hex string | -| `__eq__` | `Identity` | Compare two Identities for equality | - -A unique public identifier for a user of a database. - -### Type `Address` - -```python -class Address: - @staticmethod - def from_string(string) - - @staticmethod - def from_bytes(data) - - def __str__(self) - - def __eq__(self, other) -``` - -| Member | Type | Meaning | -|---------------|-----------|-------------------------------------| -| `from_string` | `str` | Create an Address from a hex string | -| `from_bytes` | `bytes` | Create an Address from raw bytes | -| `__str__` | `None` | Convert the Address to a hex string | -| `__eq__` | `Address` | Compare two Identities for equality | - -An opaque identifier for a client connection to a database, intended to differentiate between connections from the same [`Identity`](#type-identity). - -### Type `ReducerEvent` - -```python -class ReducerEvent: - def __init__(self, caller_identity, reducer_name, status, message, args): - self.caller_identity = caller_identity - self.reducer_name = reducer_name - self.status = status - self.message = message - self.args = args -``` - -| Member | Type | Meaning | -|-------------------|---------------------|------------------------------------------------------------------------------------| -| `caller_identity` | `Identity` | The identity of the user who invoked the reducer | -| `caller_address` | `Optional[Address]` | The address of the user who invoked the reducer, or `None` for scheduled reducers. | -| `reducer_name` | `str` | The name of the reducer that was invoked | -| `status` | `str` | The status of the reducer invocation ("committed", "failed", "outofenergy") | -| `message` | `str` | The message returned by the reducer if it fails | -| `args` | `List[str]` | The arguments passed to the reducer | - -This class contains the information about a reducer event to be passed to row update callbacks. - -### Type `{TABLE}` - -```python -class TABLE: - is_table_class = True - - primary_key = "identity" - - @classmethod - def register_row_update(cls, callback: Callable[[str,TABLE,TABLE,ReducerEvent], None]) - - @classmethod - def iter(cls) -> Iterator[User] - - @classmethod - def filter_by_COLUMN_NAME(cls, COLUMN_VALUE) -> TABLE -``` - -This class is autogenerated for each table in your module. It contains methods for filtering and iterating over subscribed rows. - -### Method `filter_by_{COLUMN}` - -```python -def filter_by_COLUMN(self, COLUMN_VALUE) -> TABLE -``` - -| Argument | Type | Meaning | -| -------------- | ------------- | ---------------------- | -| `column_value` | `COLUMN_TYPE` | The value to filter by | - -For each column of a table, `spacetime generate` generates a `classmethod` on the [table class](#type-table) to filter or seek subscribed rows where that column matches a requested value. These methods are named `filter_by_{COLUMN}`, where `{COLUMN}` is the column name converted to `snake_case`. - -The method's return type depends on the column's attributes: - -- For unique columns, including those annotated `#[unique]` and `#[primarykey]`, the `filter_by` method returns a `{TABLE}` or None, where `{TABLE}` is the [table struct](#type-table). -- For non-unique columns, the `filter_by` method returns an `Iterator` that can be used in a `for` loop. - -### Method `iter` - -```python -def iter(self) -> Iterator[TABLE] -``` - -Iterate over all the subscribed rows in the table. - -### Method `register_row_update` - -```python -def register_row_update(self, callback: Callable[[str,TABLE,TABLE,ReducerEvent], None]) -``` - -| Argument | Type | Meaning | -| ---------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------ | -| `callback` | `Callable[[str,TABLE,TABLE,ReducerEvent]` | Callback to be invoked when a row is updated (Args: row_op, old_value, new_value, reducer_event) | - -Register a callback function to be executed when a row is updated. Callback arguments are: - -- `row_op`: The type of row update event. One of `"insert"`, `"delete"`, or `"update"`. -- `old_value`: The previous value of the row, `None` if the row was inserted. -- `new_value`: The new value of the row, `None` if the row was deleted. -- `reducer_event`: The [`ReducerEvent`](#type-reducerevent) that caused the row update, or `None` if the row was updated as a result of a subscription change. - -### Function `{REDUCER_NAME}` - -```python -def {REDUCER_NAME}(arg1, arg2) -``` - -This function is autogenerated for each reducer in your module. It is used to invoke the reducer. The arguments match the arguments defined in the reducer's `#[reducer]` attribute. - -### Function `register_on_{REDUCER_NAME}` - -```python -def register_on_{REDUCER_NAME}(callback: Callable[[Identity, Optional[Address], str, str, ARG1_TYPE, ARG1_TYPE], None]) -``` - -| Argument | Type | Meaning | -| ---------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- | -| `callback` | `Callable[[Identity, str, str, ARG1_TYPE, ARG1_TYPE], None]` | Callback to be invoked when the reducer is invoked (Args: caller_identity, status, message, args) | - -Register a callback function to be executed when the reducer is invoked. Callback arguments are: - -- `caller_identity`: The identity of the user who invoked the reducer. -- `caller_address`: The address of the user who invoked the reducer, or `None` for scheduled reducers. -- `status`: The status of the reducer invocation ("committed", "failed", "outofenergy"). -- `message`: The message returned by the reducer if it fails. -- `args`: Variable number of arguments passed to the reducer. - -## Async Client Reference - -### API at a glance - -| Definition | Description | -| ----------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -| Function [`SpacetimeDBAsyncClient::run`](#function-run) | Run the client. This function will not return until the client is closed. | -| Function [`SpacetimeDBAsyncClient::subscribe`](#function-subscribe) | Subscribe to receive data and transaction updates for the provided queries. | -| Function [`SpacetimeDBAsyncClient::register_on_subscription_applied`](#function-register_on_subscription_applied) | Register a callback when the local cache is updated as a result of a change to the subscription queries. | -| Function [`SpacetimeDBAsyncClient::force_close`](#function-force_close) | Signal the client to stop processing events and close the connection to the server. | -| Function [`SpacetimeDBAsyncClient::schedule_event`](#function-schedule_event) | Schedule an event to be fired after a delay | - -### Function `run` - -```python -async def run( - self, - auth_token, - host, - address_or_name, - ssl_enabled, - on_connect, - subscription_queries=[], - ) -``` - -Run the client. This function will not return until the client is closed. - -| Argument | Type | Meaning | -| ---------------------- | --------------------------------- | -------------------------------------------------------------- | -| `auth_token` | `str` | Auth token to authenticate the user. (None if new user) | -| `host` | `str` | Hostname of SpacetimeDB server | -| `address_or_name` | `&str` | Name or address of the module. | -| `ssl_enabled` | `bool` | Whether to use SSL when connecting to the server. | -| `on_connect` | `Callable[[str, Identity], None]` | Callback to be invoked when the client connects to the server. | -| `subscription_queries` | `List[str]` | List of queries to subscribe to. | - -If `auth_token` is not None, they will be passed to the new connection to identify and authenticate the user. Otherwise, a new Identity and auth token will be generated by the server. An optional [local_config](#local_config) module can be used to store the user's auth token to local storage. - -If you are connecting to SpacetimeDB Cloud `testnet` the host should be `testnet.spacetimedb.com` and `ssl_enabled` should be `True`. If you are connecting to SpacetimeDB Standalone locally, the host should be `localhost:3000` and `ssl_enabled` should be `False`. For instructions on how to deploy to these environments, see the [Deployment Section](/docs/deploying/testnet) - -```python -asyncio.run( - spacetime_client.run( - AUTH_TOKEN, - "localhost:3000", - "my-module-name", - False, - on_connect, - ["SELECT * FROM User", "SELECT * FROM Message"], - ) -) -``` - -### Function `subscribe` - -```rust -def subscribe(self, queries: List[str]) -``` - -Subscribe to a set of queries, to be notified when rows which match those queries are altered. - -| Argument | Type | Meaning | -| --------- | ----------- | ---------------------------- | -| `queries` | `List[str]` | SQL queries to subscribe to. | - -The `queries` should be a slice of strings representing SQL queries. - -A new call to `subscribe` will remove all previous subscriptions and replace them with the new `queries`. If any rows matched the previous subscribed queries but do not match the new queries, those rows will be removed from the client cache. Row update events will be dispatched for any inserts and deletes that occur as a result of the new queries. For these events, the [`ReducerEvent`](#type-reducerevent) argument will be `None`. - -This should be called before the async client is started with [`run`](#function-run). - -```python -spacetime_client.subscribe(["SELECT * FROM User;", "SELECT * FROM Message;"]) -``` - -Subscribe to a set of queries, to be notified when rows which match those queries are altered. - -### Function `register_on_subscription_applied` - -```python -def register_on_subscription_applied(self, callback) -``` - -Register a callback function to be executed when the local cache is updated as a result of a change to the subscription queries. - -| Argument | Type | Meaning | -| ---------- | -------------------- | ------------------------------------------------------ | -| `callback` | `Callable[[], None]` | Callback to be invoked when subscriptions are applied. | - -The callback will be invoked after a successful [`subscribe`](#function-subscribe) call when the initial set of matching rows becomes available. - -```python -spacetime_client.register_on_subscription_applied(on_subscription_applied) -``` - -### Function `force_close` - -```python -def force_close(self) -) -``` - -Signal the client to stop processing events and close the connection to the server. - -```python -spacetime_client.force_close() -``` - -### Function `schedule_event` - -```python -def schedule_event(self, delay_secs, callback, *args) -``` - -Schedule an event to be fired after a delay - -To create a repeating event, call schedule_event() again from within the callback function. - -| Argument | Type | Meaning | -| ------------ | -------------------- | -------------------------------------------------------------- | -| `delay_secs` | `float` | number of seconds to wait before firing the event | -| `callback` | `Callable[[], None]` | Callback to be invoked when the event fires. | -| `args` | `*args` | Variable number of arguments to pass to the callback function. | - -```python -def application_tick(): - # ... do some work - - spacetime_client.schedule_event(0.1, application_tick) - -spacetime_client.schedule_event(0.1, application_tick) -``` - -## Basic Client Reference - -### API at a glance - -| Definition | Description | -|------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------| -| Function [`SpacetimeDBClient::init`](#function-init) | Create a network manager instance. | -| Function [`SpacetimeDBClient::subscribe`](#function-subscribe) | Subscribe to receive data and transaction updates for the provided queries. | -| Function [`SpacetimeDBClient::register_on_event`](#function-register_on_event) | Register a callback function to handle transaction update events. | -| Function [`SpacetimeDBClient::unregister_on_event`](#function-unregister_on_event) | Unregister a callback function that was previously registered using `register_on_event`. | -| Function [`SpacetimeDBClient::register_on_subscription_applied`](#function-register_on_subscription_applied) | Register a callback function to be executed when the local cache is updated as a result of a change to the subscription queries. | -| Function [`SpacetimeDBClient::unregister_on_subscription_applied`](#function-unregister_on_subscription_applied) | Unregister a callback function from the subscription update event. | -| Function [`SpacetimeDBClient::update`](#function-update) | Process all pending incoming messages from the SpacetimeDB module. | -| Function [`SpacetimeDBClient::close`](#function-close) | Close the WebSocket connection. | -| Type [`TransactionUpdateMessage`](#type-transactionupdatemessage) | Represents a transaction update message. | - -### Function `init` - -```python -@classmethod -def init( - auth_token: str, - host: str, - address_or_name: str, - ssl_enabled: bool, - autogen_package: module, - on_connect: Callable[[], NoneType] = None, - on_disconnect: Callable[[str], NoneType] = None, - on_identity: Callable[[str, Identity, Address], NoneType] = None, - on_error: Callable[[str], NoneType] = None -) -``` - -Create a network manager instance. - -| Argument | Type | Meaning | -|-------------------|--------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `auth_token` | `str` | This is the token generated by SpacetimeDB that matches the user's identity. If None, token will be generated | -| `host` | `str` | Hostname:port for SpacetimeDB connection | -| `address_or_name` | `str` | The name or address of the database to connect to | -| `ssl_enabled` | `bool` | Whether to use SSL when connecting to the server. | -| `autogen_package` | `ModuleType` | Python package where SpacetimeDB module generated files are located. | -| `on_connect` | `Callable[[], None]` | Optional callback called when a connection is made to the SpacetimeDB module. | -| `on_disconnect` | `Callable[[str], None]` | Optional callback called when the Python client is disconnected from the SpacetimeDB module. The argument is the close message. | -| `on_identity` | `Callable[[str, Identity, Address], None]` | Called when the user identity is recieved from SpacetimeDB. First argument is the auth token used to login in future sessions. Third argument is the client connection's [`Address`](#type-address). | -| `on_error` | `Callable[[str], None]` | Optional callback called when the Python client connection encounters an error. The argument is the error message. | - -This function creates a new SpacetimeDBClient instance. It should be called before any other functions in the SpacetimeDBClient class. This init will call connect for you. - -```python -SpacetimeDBClient.init(autogen, on_connect=self.on_connect) -``` - -### Function `subscribe` - -```python -def subscribe(queries: List[str]) -``` - -Subscribe to receive data and transaction updates for the provided queries. - -| Argument | Type | Meaning | -| --------- | ----------- | -------------------------------------------------------------------------------------------------------- | -| `queries` | `List[str]` | A list of queries to subscribe to. Each query is a string representing an sql formatted query statement. | - -This function sends a subscription request to the SpacetimeDB module, indicating that the client wants to receive data and transaction updates related to the specified queries. - -```python -queries = ["SELECT * FROM table1", "SELECT * FROM table2 WHERE col2 = 0"] -SpacetimeDBClient.instance.subscribe(queries) -``` - -### Function `register_on_event` - -```python -def register_on_event(callback: Callable[[TransactionUpdateMessage], NoneType]) -``` - -Register a callback function to handle transaction update events. - -| Argument | Type | Meaning | -| ---------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `callback` | `Callable[[TransactionUpdateMessage], None]` | A callback function that takes a single argument of type `TransactionUpdateMessage`. This function will be invoked with a `TransactionUpdateMessage` instance containing information about the transaction update event. | - -This function registers a callback function that will be called when a reducer modifies a table matching any of the subscribed queries or if a reducer called by this Python client encounters a failure. - -```python -def handle_event(transaction_update): - # Code to handle the transaction update event - -SpacetimeDBClient.instance.register_on_event(handle_event) -``` - -### Function `unregister_on_event` - -```python -def unregister_on_event(callback: Callable[[TransactionUpdateMessage], NoneType]) -``` - -Unregister a callback function that was previously registered using `register_on_event`. - -| Argument | Type | Meaning | -| ---------- | -------------------------------------------- | ------------------------------------ | -| `callback` | `Callable[[TransactionUpdateMessage], None]` | The callback function to unregister. | - -```python -SpacetimeDBClient.instance.unregister_on_event(handle_event) -``` - -### Function `register_on_subscription_applied` - -```python -def register_on_subscription_applied(callback: Callable[[], NoneType]) -``` - -Register a callback function to be executed when the local cache is updated as a result of a change to the subscription queries. - -| Argument | Type | Meaning | -| ---------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `callback` | `Callable[[], None]` | A callback function that will be invoked on each subscription update. The callback function should not accept any arguments and should not return any value. | - -```python -def subscription_callback(): - # Code to be executed on each subscription update - -SpacetimeDBClient.instance.register_on_subscription_applied(subscription_callback) -``` - -### Function `unregister_on_subscription_applied` - -```python -def unregister_on_subscription_applied(callback: Callable[[], NoneType]) -``` - -Unregister a callback function from the subscription update event. - -| Argument | Type | Meaning | -| ---------- | -------------------- | -------------------------------------------------------------------------------------------------------- | -| `callback` | `Callable[[], None]` | A callback function that was previously registered with the `register_on_subscription_applied` function. | - -```python -def subscription_callback(): - # Code to be executed on each subscription update - -SpacetimeDBClient.instance.register_on_subscription_applied(subscription_callback) -``` - -### Function `update` - -```python -def update() -``` - -Process all pending incoming messages from the SpacetimeDB module. - -This function must be called on a regular interval in the main loop to process incoming messages. - -```python -while True: - SpacetimeDBClient.instance.update() # Call the update function in a loop to process incoming messages - # Additional logic or code can be added here -``` - -### Function `close` - -```python -def close() -``` - -Close the WebSocket connection. - -This function closes the WebSocket connection to the SpacetimeDB module. - -```python -SpacetimeDBClient.instance.close() -``` - -### Type `TransactionUpdateMessage` - -```python -class TransactionUpdateMessage: - def __init__( - self, - caller_identity: Identity, - status: str, - message: str, - reducer_name: str, - args: Dict - ) -``` - -| Member | Args | Meaning | -| ----------------- | ---------- | ------------------------------------------------- | -| `caller_identity` | `Identity` | The identity of the caller. | -| `status` | `str` | The status of the transaction. | -| `message` | `str` | A message associated with the transaction update. | -| `reducer_name` | `str` | The reducer used for the transaction. | -| `args` | `Dict` | Additional arguments for the transaction. | - -Represents a transaction update message. Used in on_event callbacks. - -For more details, see [`register_on_event`](#function-register_on_event). diff --git a/docs/sdks/python/quickstart.md b/docs/sdks/python/quickstart.md deleted file mode 100644 index 2b9d7aa1..00000000 --- a/docs/sdks/python/quickstart.md +++ /dev/null @@ -1,379 +0,0 @@ -# Python Client SDK Quick Start - -In this guide, we'll show you how to get up and running with a simple SpacetimDB app with a client written in Python. - -We'll implement a command-line client for the module created in our [Rust Module Quickstart](/docs/modules/rust/quickstart) or [C# Module Quickstart](/docs/modules/c-charp/quickstart) guides. Make sure you follow one of these guides before you start on this one. - -## Install the SpacetimeDB SDK Python Package - -1. Run pip install - -```bash -pip install spacetimedb_sdk -``` - -## Project structure - -Enter the directory `quickstart-chat` you created in the Rust or C# Module Quickstart guides and create a `client` folder: - -```bash -cd quickstart-chat -mkdir client -``` - -## Create the Python main file - -Create a file called `main.py` in the `client` and open it in your favorite editor. We prefer [VS Code](https://code.visualstudio.com/). - -## Add imports - -We need to add several imports for this quickstart: - -- [`asyncio`](https://docs.python.org/3/library/asyncio.html) is required to run the async code in the SDK. -- [`multiprocessing.Queue`](https://docs.python.org/3/library/multiprocessing.html) allows us to pass our input to the async code, which we will run in a separate thread. -- [`threading`](https://docs.python.org/3/library/threading.html) allows us to spawn our async code in a separate thread so the main thread can run the input loop. - -- `spacetimedb_sdk.spacetimedb_async_client.SpacetimeDBAsyncClient` is the async wrapper around the SpacetimeDB client which we use to interact with our SpacetimeDB module. -- `spacetimedb_sdk.local_config` is an optional helper module to load the auth token from local storage. - -```python -import asyncio -from multiprocessing import Queue -import threading - -from spacetimedb_sdk.spacetimedb_async_client import SpacetimeDBAsyncClient -import spacetimedb_sdk.local_config as local_config -``` - -## Generate your module types - -The `spacetime` CLI's `generate` command will generate client-side interfaces for the tables, reducers and types defined in your server module. - -In your `client` directory, run: - -```bash -mkdir -p module_bindings -spacetime generate --lang python --out-dir module_bindings --project-path ../server -``` - -Take a look inside `client/module_bindings`. The CLI should have generated five files: - -``` -module_bindings -+-- message.py -+-- send_message_reducer.py -+-- set_name_reducer.py -+-- user.py -``` - -Now we import these types by adding the following lines to `main.py`: - -```python -import module_bindings -from module_bindings.user import User -from module_bindings.message import Message -import module_bindings.send_message_reducer as send_message_reducer -import module_bindings.set_name_reducer as set_name_reducer -``` - -## Global variables - -Next we will add our global `input_queue` and `local_identity` variables which we will explain later when they are used. - -```python -input_queue = Queue() -local_identity = None -``` - -## Define main function - -We'll work outside-in, first defining our `main` function at a high level, then implementing each behavior it needs. We need `main` to do four things: - -1. Init the optional local config module. The first parameter is the directory name to be created in the user home directory. -1. Create our async SpacetimeDB client. -1. Register our callbacks. -1. Start the async client in a thread. -1. Run a loop to read user input and send it to a repeating event in the async client. -1. When the user exits, stop the async client and exit the program. - -```python -if __name__ == "__main__": - local_config.init(".spacetimedb-python-quickstart") - - spacetime_client = SpacetimeDBAsyncClient(module_bindings) - - register_callbacks(spacetime_client) - - thread = threading.Thread(target=run_client, args=(spacetime_client,)) - thread.start() - - input_loop() - - spacetime_client.force_close() - thread.join() -``` - -## Register callbacks - -We need to handle several sorts of events: - -1. OnSubscriptionApplied is a special callback that is executed when the local client cache is populated. We will talk more about this later. -2. When a new user joins or a user is updated, we'll print an appropriate message. -3. When we receive a new message, we'll print it. -4. If the server rejects our attempt to set our name, we'll print an error. -5. If the server rejects a message we send, we'll print an error. -6. We use the `schedule_event` function to register a callback to be executed after 100ms. This callback will check the input queue for any user input and execute the appropriate command. - -Because python requires functions to be defined before they're used, the following code must be added to `main.py` before main block: - -```python -def register_callbacks(spacetime_client): - spacetime_client.client.register_on_subscription_applied(on_subscription_applied) - - User.register_row_update(on_user_row_update) - Message.register_row_update(on_message_row_update) - - set_name_reducer.register_on_set_name(on_set_name_reducer) - send_message_reducer.register_on_send_message(on_send_message_reducer) - - spacetime_client.schedule_event(0.1, check_commands) -``` - -### Handling User row updates - -For each table, we can register a row update callback to be run whenever a subscribed row is inserted, updated or deleted. We register these callbacks using the `register_row_update` methods that are generated automatically for each table by `spacetime generate`. - -These callbacks can fire in two contexts: - -- After a reducer runs, when the client's cache is updated about changes to subscribed rows. -- After calling `subscribe`, when the client's cache is initialized with all existing matching rows. - -This second case means that, even though the module only ever inserts online users, the client's `User::row_update` callbacks may be invoked with users who are offline. We'll only notify about online users. - -We are also going to check for updates to the user row. This can happen for three reasons: - -1. They've set their name using the `set_name` reducer. -2. They're an existing user re-connecting, so their `online` has been set to `true`. -3. They've disconnected, so their `online` has been set to `false`. - -We'll print an appropriate message in each of these cases. - -`row_update` callbacks take four arguments: the row operation ("insert", "update", or "delete"), the old row if it existed, the new or updated row, and a `ReducerEvent`. This will `None` for rows inserted when initializing the cache for a subscription. `ReducerEvent` is an class that contains information about the reducer that triggered this row update event. - -Whenever we want to print a user, if they have set a name, we'll use that. If they haven't set a name, we'll instead print the first 8 bytes of their identity, encoded as hexadecimal. We'll define a function `user_name_or_identity` handle this. - -Add these functions before the `register_callbacks` function: - -```python -def user_name_or_identity(user): - if user.name: - return user.name - else: - return (str(user.identity))[:8] - -def on_user_row_update(row_op, user_old, user, reducer_event): - if row_op == "insert": - if user.online: - print(f"User {user_name_or_identity(user)} connected.") - elif row_op == "update": - if user_old.online and not user.online: - print(f"User {user_name_or_identity(user)} disconnected.") - elif not user_old.online and user.online: - print(f"User {user_name_or_identity(user)} connected.") - - if user_old.name != user.name: - print( - f"User {user_name_or_identity(user_old)} renamed to {user_name_or_identity(user)}." - ) -``` - -### Print messages - -When we receive a new message, we'll print it to standard output, along with the name of the user who sent it. Keep in mind that we only want to do this for new messages, i.e. those inserted by a `send_message` reducer invocation. We have to handle the backlog we receive when our subscription is initialized separately, to ensure they're printed in the correct order. To that effect, our `on_message_row_update` callback will check if its `reducer_event` argument is not `None`, and only print in that case. - -To find the `User` based on the message's `sender` identity, we'll use `User::filter_by_identity`, which behaves like the same function on the server. The key difference is that, unlike on the module side, the client's `filter_by_identity` accepts a `bytes`, rather than an `&Identity`. The `sender` identity stored in the message is also a `bytes`, not an `Identity`, so we can just pass it to the filter method. - -We'll print the user's name or identity in the same way as we did when notifying about `User` table events, but here we have to handle the case where we don't find a matching `User` row. This can happen when the module owner sends a message using the CLI's `spacetime call`. In this case, we'll print `unknown`. - -Add these functions before the `register_callbacks` function: - -```python -def on_message_row_update(row_op, message_old, message, reducer_event): - if reducer_event is not None and row_op == "insert": - print_message(message) - -def print_message(message): - user = User.filter_by_identity(message.sender) - user_name = "unknown" - if user is not None: - user_name = user_name_or_identity(user) - - print(f"{user_name}: {message.text}") -``` - -### Warn if our name was rejected - -We can also register callbacks to run each time a reducer is invoked. We register these callbacks using the `register_on_` method, which is automatically implemented for each reducer by `spacetime generate`. - -Each reducer callback takes four fixed arguments: - -1. The `Identity` of the client who requested the reducer invocation. -2. The `Address` of the client who requested the reducer invocation, or `None` for scheduled reducers. -3. The `Status` of the reducer run, one of `committed`, `failed` or `outofenergy`. -4. The `Message` returned by the reducer in error cases, or `None` if the reducer succeeded. - -It also takes a variable number of arguments which match the calling arguments of the reducer. - -These callbacks will be invoked in one of two cases: - -1. If the reducer was successful and altered any of our subscribed rows. -2. If we requested an invocation which failed. - -Note that a status of `failed` or `outofenergy` implies that the caller identity is our own identity. - -We already handle successful `set_name` invocations using our `User::on_update` callback, but if the module rejects a user's chosen name, we'd like that user's client to let them know. We define a function `on_set_name_reducer` as a callback which checks if the reducer failed, and if it did, prints an error message including the rejected name. - -We'll test both that our identity matches the sender and that the status is `failed`, even though the latter implies the former, for demonstration purposes. - -Add this function before the `register_callbacks` function: - -```python -def on_set_name_reducer(sender_id, sender_address, status, message, name): - if sender_id == local_identity: - if status == "failed": - print(f"Failed to set name: {message}") -``` - -### Warn if our message was rejected - -We handle warnings on rejected messages the same way as rejected names, though the types and the error message are different. - -Add this function before the `register_callbacks` function: - -```python -def on_send_message_reducer(sender_id, sender_address, status, message, msg): - if sender_id == local_identity: - if status == "failed": - print(f"Failed to send message: {message}") -``` - -### OnSubscriptionApplied callback - -This callback fires after the client cache is updated as a result in a change to the client subscription. This happens after connect and if after calling `subscribe` to modify the subscription. - -In this case, we want to print all the existing messages when the subscription is applied. `print_messages_in_order` iterates over all the `Message`s we've received, sorts them, and then prints them. `Message.iter()` is generated for all table types, and returns an iterator over all the messages in the client's cache. - -Add these functions before the `register_callbacks` function: - -```python -def print_messages_in_order(): - all_messages = sorted(Message.iter(), key=lambda x: x.sent) - for entry in all_messages: - print(f"{user_name_or_identity(User.filter_by_identity(entry.sender))}: {entry.text}") - -def on_subscription_applied(): - print(f"\nSYSTEM: Connected.") - print_messages_in_order() -``` - -### Check commands repeating event - -We'll use a repeating event to check the user input queue every 100ms. If there's a command in the queue, we'll execute it. If not, we'll just keep waiting. Notice that at the end of the function we call `schedule_event` again to so the event will repeat. - -If the command is to send a message, we'll call the `send_message` reducer. If the command is to set our name, we'll call the `set_name` reducer. - -Add these functions before the `register_callbacks` function: - -```python -def check_commands(): - global input_queue - - if not input_queue.empty(): - choice = input_queue.get() - if choice[0] == "name": - set_name_reducer.set_name(choice[1]) - else: - send_message_reducer.send_message(choice[1]) - - spacetime_client.schedule_event(0.1, check_commands) -``` - -### OnConnect callback - -This callback fires after the client connects to the server. We'll use it to save our credentials to a file so that we can re-authenticate as the same user next time we connect. - -The `on_connect` callback takes three arguments: - -1. The `Auth Token` is the equivalent of your private key. This is the only way to authenticate with the SpacetimeDB module as this user. -2. The `Identity` is the equivalent of your public key. This is used to uniquely identify this user and will be sent to other clients. We store this in a global variable so we can use it to identify that a given message or transaction was sent by us. -3. The `Address` is an opaque identifier modules can use to distinguish multiple concurrent connections by the same `Identity`. We don't need to know our `Address`, so we'll ignore that argument. - -To store our auth token, we use the optional component `local_config`, which provides a simple interface for storing and retrieving a single `Identity` from a file. We'll use the `local_config::set_string` method to store the auth token. Other projects might want to associate this token with some other identifier such as an email address or Steam ID. - -The `on_connect` callback is passed to the client connect function so it just needs to be defined before the `run_client` described next. - -```python -def on_connect(auth_token, identity): - global local_identity - local_identity = identity - - local_config.set_string("auth_token", auth_token) -``` - -## Async client thread - -We are going to write a function that starts the async client, which will be executed on a separate thread. - -```python -def run_client(spacetime_client): - asyncio.run( - spacetime_client.run( - local_config.get_string("auth_token"), - "localhost:3000", - "chat", - False, - on_connect, - ["SELECT * FROM User", "SELECT * FROM Message"], - ) - ) -``` - -## Input loop - -Finally, we need a function to be executed on the main loop which listens for user input and adds it to the queue. - -```python -def input_loop(): - global input_queue - - while True: - user_input = input() - if len(user_input) == 0: - return - elif user_input.startswith("/name "): - input_queue.put(("name", user_input[6:])) - else: - input_queue.put(("message", user_input)) -``` - -## Run the client - -Make sure your module from the Rust or C# module quickstart is published. If you used a different module name than `chat`, you will need to update the `connect` call in the `run_client` function. - -Run the client: - -```bash -python main.py -``` - -If you want to connect another client, you can use the --client command line option, which is built into the local_config module. This will create different settings file for the new client's auth token. - -```bash -python main.py --client 2 -``` - -## Next steps - -Congratulations! You've built a simple chat app with a Python client. You can now use this as a starting point for your own SpacetimeDB apps. - -For a more complex example of the Spacetime Python SDK, check out our [AI Agent](https://github.com/clockworklabs/spacetime-mud/tree/main/ai-agent-python-client) for the [Spacetime Multi-User Dungeon](https://github.com/clockworklabs/spacetime-mud). The AI Agent uses the OpenAI API to create dynamic content on command. diff --git a/nav.ts b/nav.ts index 26a83f4c..8b21cc91 100644 --- a/nav.ts +++ b/nav.ts @@ -55,8 +55,6 @@ const nav: Nav = { page("Typescript Reference", "sdks/typescript", "sdks/typescript/index.md"), page("Rust Quickstart", "sdks/rust/quickstart", "sdks/rust/quickstart.md"), page("Rust Reference", "sdks/rust", "sdks/rust/index.md"), - page("Python Quickstart", "sdks/python/quickstart", "sdks/python/quickstart.md"), - page("Python Reference", "sdks/python", "sdks/python/index.md"), page("C# Quickstart", "sdks/c-sharp/quickstart", "sdks/c-sharp/quickstart.md"), page("C# Reference", "sdks/c-sharp", "sdks/c-sharp/index.md"), From 698140bf2cf44d11188f51b637b767dfe337da6d Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Fri, 2 Aug 2024 23:25:52 +0530 Subject: [PATCH 054/164] scheduler table doc update (#73) --- docs/modules/c-sharp/index.md | 77 ++++++++++++++++++++++++++++------- docs/modules/rust/index.md | 76 ++++++++++++++++++++++++---------- docs/unity/part-4.md | 21 +++++++--- 3 files changed, 133 insertions(+), 41 deletions(-) diff --git a/docs/modules/c-sharp/index.md b/docs/modules/c-sharp/index.md index ad1446fb..7380467f 100644 --- a/docs/modules/c-sharp/index.md +++ b/docs/modules/c-sharp/index.md @@ -295,30 +295,77 @@ public static void PrintInfo(ReducerContext e) } ``` -`[SpacetimeDB.Reducer]` also generates a function to schedule the given reducer in the future. -Since it's not possible to generate extension methods on existing methods, the codegen will instead add a `Schedule`-prefixed method colocated in the same namespace as the original method instead. The generated method will accept `DateTimeOffset` argument for the time when the reducer should be invoked, followed by all the arguments of the reducer itself, except those that have type `ReducerContext`. +### Scheduler Tables +Tables can be used to schedule a reducer calls either at a specific timestamp or at regular intervals. ```csharp -// Example reducer: -[SpacetimeDB.Reducer] -public static void Add(string name, int age) { ... } +public static partial class Timers +{ + + // The `Scheduled` attribute links this table to a reducer. + [SpacetimeDB.Table(Scheduled = nameof(SendScheduledMessage))] + public partial struct SendMessageTimer + { + public string Text; + } -// Auto-generated by the codegen: -public static void ScheduleAdd(DateTimeOffset time, string name, int age) { ... } + + // Define the reducer that will be invoked by the scheduler table. + // The first parameter is always `ReducerContext`, and the second parameter is an instance of the linked table struct. + [SpacetimeDB.Reducer] + public static void SendScheduledMessage(ReducerContext ctx, SendMessageTimer arg) + { + // ... + } -// Usage from another reducer: -[SpacetimeDB.Reducer] -public static void AddIn5Minutes(ReducerContext e, string name, int age) -{ - // Note that we're using `e.Time` instead of `DateTimeOffset.Now` which is not allowed in modules. - var scheduleToken = ScheduleAdd(e.Time.AddMinutes(5), name, age); - // We can cancel the scheduled reducer by calling `Cancel()` on the returned token. - scheduleToken.Cancel(); + // Scheduling reducers inside `init` reducer. + [SpacetimeDB.Reducer(ReducerKind.Init)] + public static void Init(ReducerContext ctx) + { + + // Schedule a one-time reducer call by inserting a row. + new SendMessageTimer + { + Text = "bot sending a message", + ScheduledAt = ctx.Time.AddSeconds(10), + ScheduledId = 1, + }.Insert(); + + + // Schedule a recurring reducer. + new SendMessageTimer + { + Text = "bot sending a message", + ScheduledAt = new TimeStamp(10), + ScheduledId = 2, + }.Insert(); + } } ``` +Annotating a struct with `Scheduled` automatically adds fields to support scheduling, It can be expanded as: + +```csharp +public static partial class Timers +{ + [SpacetimeDB.Table] + public partial struct SendMessageTimer + { + public string Text; // fields of original struct + + [SpacetimeDB.Column(ColumnAttrs.PrimaryKeyAuto)] + public ulong ScheduledId; // unique identifier to be used internally + + public SpacetimeDB.ScheduleAt ScheduleAt; // Scheduling details (Time or Inteval) + } +} + +// `ScheduledAt` definition +public abstract partial record ScheduleAt: SpacetimeDB.TaggedEnum<(DateTimeOffset Time, TimeSpan Interval)> +``` + #### Special reducers These are four special kinds of reducers that can be used to respond to module lifecycle events. They're stored in the `SpacetimeDB.Module.ReducerKind` class and can be used as an argument to the `[SpacetimeDB.Reducer]` attribute: diff --git a/docs/modules/rust/index.md b/docs/modules/rust/index.md index f4d02490..c2acf5cb 100644 --- a/docs/modules/rust/index.md +++ b/docs/modules/rust/index.md @@ -167,8 +167,6 @@ struct Person { ### Defining reducers -`#[spacetimedb(reducer)]` optionally takes a single argument, which is a frequency at which the reducer will be automatically called by the database. - `#[spacetimedb(reducer)]` is always applied to top level Rust functions. They can take arguments of types known to SpacetimeDB (just like fields of structs must be known to SpacetimeDB), and either return nothing, or return a `Result<(), E: Debug>`. ```rust @@ -192,39 +190,75 @@ struct Item { Note that reducers can call non-reducer functions, including standard library functions. -Reducers that are called periodically take an additional macro argument specifying the frequency at which they will be invoked. Durations are parsed according to https://docs.rs/humantime/latest/humantime/fn.parse_duration.html and will usually be a number of milliseconds or seconds. -Both of these examples are invoked every second. +There are several macros which modify the semantics of a column, which are applied to the members of the table struct. `#[unique]` and `#[autoinc]` are covered below, describing how those attributes affect the semantics of inserting, filtering, and so on. -```rust -#[spacetimedb(reducer, repeat = 1s)] -fn every_second() {} +#[SpacetimeType] -#[spacetimedb(reducer, repeat = 1000ms)] -fn every_thousand_milliseconds() {} -``` +#[sats] -Finally, reducers can also receive a ReducerContext object, or the Timestamp at which they are invoked, just by taking parameters of those types first. +### Defining Scheduler Tables +Tables can be used to schedule a reducer calls either at a specific timestamp or at regular intervals. ```rust -#[spacetimedb(reducer, repeat = 1s)] -fn tick_timestamp(time: Timestamp) { - println!("tick at {time}"); +// The `scheduled` attribute links this table to a reducer. +#[spacetimedb(table, scheduled(send_message))] +struct SendMessageTimer { + text: String, } +``` -#[spacetimedb(reducer, repeat = 500ms)] -fn tick_ctx(ctx: ReducerContext) { - println!("tick at {}", ctx.timestamp) +The `scheduled` attribute adds a couple of default fields and expands as follows: +```rust +#[spacetimedb(table)] + struct SendMessageTimer { + text: String, // original field + #[primary] + #[autoinc] + scheduled_id: u64, // identifier for internal purpose + scheduled_at: ScheduleAt, //schedule details +} + +pub enum ScheduleAt { + /// A specific time at which the reducer is scheduled. + /// Value is a UNIX timestamp in microseconds. + Time(u64), + /// A regular interval at which the repeated reducer is scheduled. + /// Value is a duration in microseconds. + Interval(u64), } ``` -Note that each distinct time a repeating reducer is invoked, a seperate schedule is created for that reducer. So invoking `every_second` three times from the spacetimedb cli will result in the reducer being called times times each second. +Managing timers with scheduled table is as simple as inserting or deleting rows from table. +```rust +#[spacetimedb(reducer)] -There are several macros which modify the semantics of a column, which are applied to the members of the table struct. `#[unique]` and `#[autoinc]` are covered below, describing how those attributes affect the semantics of inserting, filtering, and so on. +// Reducers linked to the scheduler table should have their first argument as `ReducerContext` +// and the second as an instance of the table struct it is linked to. +fn send_message(ctx: ReducerContext, arg: SendMessageTimer) -> Result<(), String> { + // ... +} -#[SpacetimeType] +// Scheduling reducers inside `init` reducer +fn init() { + // Scheduling a reducer for a specific Timestamp + SendMessageTimer::insert(SendMessageTimer { + scheduled_id: 1, + text:"bot sending a message".to_string(), + //`spacetimedb::Timestamp` implements `From` trait to `ScheduleAt::Time`. + scheduled_at: ctx.timestamp.plus(Duration::from_secs(10)).into() + }); + + // Scheduling a reducer to be called at fixed interval of 100 milliseconds. + SendMessageTimer::insert(SendMessageTimer { + scheduled_id: 0, + text:"bot sending a message".to_string(), + //`std::time::Duration` implements `From` trait to `ScheduleAt::Duration`. + scheduled_at: duration!(100ms).into(), + }); +} +``` -#[sats] ## Client API diff --git a/docs/unity/part-4.md b/docs/unity/part-4.md index 10738e84..d7c22280 100644 --- a/docs/unity/part-4.md +++ b/docs/unity/part-4.md @@ -98,11 +98,16 @@ pub struct Config { ### Step 2: Write our Resource Spawner Repeating Reducer -1. Add the following code to lib.rs. We are using a special attribute argument called repeat which will automatically schedule the reducer to run every 1000ms. +1. Add the following code to lib.rs. As we want to schedule `resource_spawn_agent` to run later, It will require to implement a scheduler table. ```rust -#[spacetimedb(reducer, repeat = 1000ms)] -pub fn resource_spawner_agent(_ctx: ReducerContext, _prev_time: Timestamp) -> Result<(), String> { +#[spacetimedb(table, scheduled(resource_spawner_agent))] +struct ResouceSpawnAgentSchedueler { + _prev_time: Timestamp, +} + +#[spacetimedb(reducer) +pub fn resource_spawner_agent(_ctx: ReducerContext, _arg: ResourceSpawnAgentScheduler) -> Result<(), String> { let config = Config::find_by_version(&0).unwrap(); // Retrieve the maximum number of nodes we want to spawn from the Config table @@ -157,18 +162,24 @@ pub fn resource_spawner_agent(_ctx: ReducerContext, _prev_time: Timestamp) -> Re } ``` + 2. Since this reducer uses `rand::Rng` we need add include it. Add this `use` statement to the top of lib.rs. ```rust use rand::Rng; ``` -3. Even though our reducer is set to repeat, we still need to schedule it the first time. Add the following code to the end of the `init` reducer. You can use this `schedule!` macro to schedule any reducer to run in the future after a certain amount of time. +3. Add the following code to the end of the `init` reducer to set the reducer to repeat at every regular interval. ```rust // Start our resource spawner repeating reducer - spacetimedb::schedule!("1000ms", resource_spawner_agent(_, Timestamp::now())); + ResouceSpawnAgentSchedueler::insert(ResouceSpawnAgentSchedueler { + _prev_time: TimeStamp::now(), + scheduled_id: 1, + scheduled_at: duration!(1000ms).into() + }).expect(); ``` +struct ResouceSpawnAgentSchedueler { 4. Next we need to generate our client code and publish the module. Since we changed the schema we need to make sure we include the `--clear-database` flag. Run the following commands from your Server directory: From 42c92ee40b7e011e302afcc38b89910df2000b77 Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Sat, 3 Aug 2024 00:25:37 +0530 Subject: [PATCH 055/164] Shub/revert scheduler table doc (#76) Revert "scheduler table doc update (#73)" This reverts commit 698140bf2cf44d11188f51b637b767dfe337da6d. --- docs/modules/c-sharp/index.md | 77 +++++++---------------------------- docs/modules/rust/index.md | 76 ++++++++++------------------------ docs/unity/part-4.md | 21 +++------- 3 files changed, 41 insertions(+), 133 deletions(-) diff --git a/docs/modules/c-sharp/index.md b/docs/modules/c-sharp/index.md index 7380467f..ad1446fb 100644 --- a/docs/modules/c-sharp/index.md +++ b/docs/modules/c-sharp/index.md @@ -295,75 +295,28 @@ public static void PrintInfo(ReducerContext e) } ``` +`[SpacetimeDB.Reducer]` also generates a function to schedule the given reducer in the future. -### Scheduler Tables -Tables can be used to schedule a reducer calls either at a specific timestamp or at regular intervals. +Since it's not possible to generate extension methods on existing methods, the codegen will instead add a `Schedule`-prefixed method colocated in the same namespace as the original method instead. The generated method will accept `DateTimeOffset` argument for the time when the reducer should be invoked, followed by all the arguments of the reducer itself, except those that have type `ReducerContext`. ```csharp -public static partial class Timers -{ - - // The `Scheduled` attribute links this table to a reducer. - [SpacetimeDB.Table(Scheduled = nameof(SendScheduledMessage))] - public partial struct SendMessageTimer - { - public string Text; - } - - - // Define the reducer that will be invoked by the scheduler table. - // The first parameter is always `ReducerContext`, and the second parameter is an instance of the linked table struct. - [SpacetimeDB.Reducer] - public static void SendScheduledMessage(ReducerContext ctx, SendMessageTimer arg) - { - // ... - } - - - // Scheduling reducers inside `init` reducer. - [SpacetimeDB.Reducer(ReducerKind.Init)] - public static void Init(ReducerContext ctx) - { - - // Schedule a one-time reducer call by inserting a row. - new SendMessageTimer - { - Text = "bot sending a message", - ScheduledAt = ctx.Time.AddSeconds(10), - ScheduledId = 1, - }.Insert(); - - - // Schedule a recurring reducer. - new SendMessageTimer - { - Text = "bot sending a message", - ScheduledAt = new TimeStamp(10), - ScheduledId = 2, - }.Insert(); - } -} -``` +// Example reducer: +[SpacetimeDB.Reducer] +public static void Add(string name, int age) { ... } -Annotating a struct with `Scheduled` automatically adds fields to support scheduling, It can be expanded as: +// Auto-generated by the codegen: +public static void ScheduleAdd(DateTimeOffset time, string name, int age) { ... } -```csharp -public static partial class Timers +// Usage from another reducer: +[SpacetimeDB.Reducer] +public static void AddIn5Minutes(ReducerContext e, string name, int age) { - [SpacetimeDB.Table] - public partial struct SendMessageTimer - { - public string Text; // fields of original struct - - [SpacetimeDB.Column(ColumnAttrs.PrimaryKeyAuto)] - public ulong ScheduledId; // unique identifier to be used internally - - public SpacetimeDB.ScheduleAt ScheduleAt; // Scheduling details (Time or Inteval) - } -} + // Note that we're using `e.Time` instead of `DateTimeOffset.Now` which is not allowed in modules. + var scheduleToken = ScheduleAdd(e.Time.AddMinutes(5), name, age); -// `ScheduledAt` definition -public abstract partial record ScheduleAt: SpacetimeDB.TaggedEnum<(DateTimeOffset Time, TimeSpan Interval)> + // We can cancel the scheduled reducer by calling `Cancel()` on the returned token. + scheduleToken.Cancel(); +} ``` #### Special reducers diff --git a/docs/modules/rust/index.md b/docs/modules/rust/index.md index c2acf5cb..f4d02490 100644 --- a/docs/modules/rust/index.md +++ b/docs/modules/rust/index.md @@ -167,6 +167,8 @@ struct Person { ### Defining reducers +`#[spacetimedb(reducer)]` optionally takes a single argument, which is a frequency at which the reducer will be automatically called by the database. + `#[spacetimedb(reducer)]` is always applied to top level Rust functions. They can take arguments of types known to SpacetimeDB (just like fields of structs must be known to SpacetimeDB), and either return nothing, or return a `Result<(), E: Debug>`. ```rust @@ -190,75 +192,39 @@ struct Item { Note that reducers can call non-reducer functions, including standard library functions. +Reducers that are called periodically take an additional macro argument specifying the frequency at which they will be invoked. Durations are parsed according to https://docs.rs/humantime/latest/humantime/fn.parse_duration.html and will usually be a number of milliseconds or seconds. -There are several macros which modify the semantics of a column, which are applied to the members of the table struct. `#[unique]` and `#[autoinc]` are covered below, describing how those attributes affect the semantics of inserting, filtering, and so on. +Both of these examples are invoked every second. -#[SpacetimeType] +```rust +#[spacetimedb(reducer, repeat = 1s)] +fn every_second() {} -#[sats] +#[spacetimedb(reducer, repeat = 1000ms)] +fn every_thousand_milliseconds() {} +``` -### Defining Scheduler Tables -Tables can be used to schedule a reducer calls either at a specific timestamp or at regular intervals. +Finally, reducers can also receive a ReducerContext object, or the Timestamp at which they are invoked, just by taking parameters of those types first. ```rust -// The `scheduled` attribute links this table to a reducer. -#[spacetimedb(table, scheduled(send_message))] -struct SendMessageTimer { - text: String, +#[spacetimedb(reducer, repeat = 1s)] +fn tick_timestamp(time: Timestamp) { + println!("tick at {time}"); } -``` -The `scheduled` attribute adds a couple of default fields and expands as follows: -```rust -#[spacetimedb(table)] - struct SendMessageTimer { - text: String, // original field - #[primary] - #[autoinc] - scheduled_id: u64, // identifier for internal purpose - scheduled_at: ScheduleAt, //schedule details -} - -pub enum ScheduleAt { - /// A specific time at which the reducer is scheduled. - /// Value is a UNIX timestamp in microseconds. - Time(u64), - /// A regular interval at which the repeated reducer is scheduled. - /// Value is a duration in microseconds. - Interval(u64), +#[spacetimedb(reducer, repeat = 500ms)] +fn tick_ctx(ctx: ReducerContext) { + println!("tick at {}", ctx.timestamp) } ``` -Managing timers with scheduled table is as simple as inserting or deleting rows from table. -```rust -#[spacetimedb(reducer)] +Note that each distinct time a repeating reducer is invoked, a seperate schedule is created for that reducer. So invoking `every_second` three times from the spacetimedb cli will result in the reducer being called times times each second. -// Reducers linked to the scheduler table should have their first argument as `ReducerContext` -// and the second as an instance of the table struct it is linked to. -fn send_message(ctx: ReducerContext, arg: SendMessageTimer) -> Result<(), String> { - // ... -} +There are several macros which modify the semantics of a column, which are applied to the members of the table struct. `#[unique]` and `#[autoinc]` are covered below, describing how those attributes affect the semantics of inserting, filtering, and so on. -// Scheduling reducers inside `init` reducer -fn init() { - // Scheduling a reducer for a specific Timestamp - SendMessageTimer::insert(SendMessageTimer { - scheduled_id: 1, - text:"bot sending a message".to_string(), - //`spacetimedb::Timestamp` implements `From` trait to `ScheduleAt::Time`. - scheduled_at: ctx.timestamp.plus(Duration::from_secs(10)).into() - }); - - // Scheduling a reducer to be called at fixed interval of 100 milliseconds. - SendMessageTimer::insert(SendMessageTimer { - scheduled_id: 0, - text:"bot sending a message".to_string(), - //`std::time::Duration` implements `From` trait to `ScheduleAt::Duration`. - scheduled_at: duration!(100ms).into(), - }); -} -``` +#[SpacetimeType] +#[sats] ## Client API diff --git a/docs/unity/part-4.md b/docs/unity/part-4.md index d7c22280..10738e84 100644 --- a/docs/unity/part-4.md +++ b/docs/unity/part-4.md @@ -98,16 +98,11 @@ pub struct Config { ### Step 2: Write our Resource Spawner Repeating Reducer -1. Add the following code to lib.rs. As we want to schedule `resource_spawn_agent` to run later, It will require to implement a scheduler table. +1. Add the following code to lib.rs. We are using a special attribute argument called repeat which will automatically schedule the reducer to run every 1000ms. ```rust -#[spacetimedb(table, scheduled(resource_spawner_agent))] -struct ResouceSpawnAgentSchedueler { - _prev_time: Timestamp, -} - -#[spacetimedb(reducer) -pub fn resource_spawner_agent(_ctx: ReducerContext, _arg: ResourceSpawnAgentScheduler) -> Result<(), String> { +#[spacetimedb(reducer, repeat = 1000ms)] +pub fn resource_spawner_agent(_ctx: ReducerContext, _prev_time: Timestamp) -> Result<(), String> { let config = Config::find_by_version(&0).unwrap(); // Retrieve the maximum number of nodes we want to spawn from the Config table @@ -162,24 +157,18 @@ pub fn resource_spawner_agent(_ctx: ReducerContext, _arg: ResourceSpawnAgentSche } ``` - 2. Since this reducer uses `rand::Rng` we need add include it. Add this `use` statement to the top of lib.rs. ```rust use rand::Rng; ``` -3. Add the following code to the end of the `init` reducer to set the reducer to repeat at every regular interval. +3. Even though our reducer is set to repeat, we still need to schedule it the first time. Add the following code to the end of the `init` reducer. You can use this `schedule!` macro to schedule any reducer to run in the future after a certain amount of time. ```rust // Start our resource spawner repeating reducer - ResouceSpawnAgentSchedueler::insert(ResouceSpawnAgentSchedueler { - _prev_time: TimeStamp::now(), - scheduled_id: 1, - scheduled_at: duration!(1000ms).into() - }).expect(); + spacetimedb::schedule!("1000ms", resource_spawner_agent(_, Timestamp::now())); ``` -struct ResouceSpawnAgentSchedueler { 4. Next we need to generate our client code and publish the module. Since we changed the schema we need to make sure we include the `--clear-database` flag. Run the following commands from your Server directory: From ef229dae37757c0bba2ea4fd30d4e9e02b76775c Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Fri, 9 Aug 2024 19:28:38 -0400 Subject: [PATCH 056/164] Update quickstart.md (#74) --- docs/sdks/rust/quickstart.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sdks/rust/quickstart.md b/docs/sdks/rust/quickstart.md index 6df255e8..af07e403 100644 --- a/docs/sdks/rust/quickstart.md +++ b/docs/sdks/rust/quickstart.md @@ -47,7 +47,7 @@ touch client/src/main.rs ## Generate your module types -The `spacetime` CLI's `generate` command will generate client-side interfaces for the tables, reducers and types defined in your server module. +The `spacetime` CLI's `generate` command will generate client-side interfaces for the tables, reducers and types referenced by tables or reducers defined in your server module. In your `quickstart-chat` directory, run: From c87ebba2200651d9b1a90e99b61d6816424779ed Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Sat, 10 Aug 2024 04:58:49 +0530 Subject: [PATCH 057/164] scheduler table doc update (#77) --- docs/modules/c-sharp/index.md | 77 ++++++++++++++++++++++++++++------- docs/modules/rust/index.md | 76 ++++++++++++++++++++++++---------- docs/unity/part-4.md | 21 +++++++--- 3 files changed, 133 insertions(+), 41 deletions(-) diff --git a/docs/modules/c-sharp/index.md b/docs/modules/c-sharp/index.md index ad1446fb..7380467f 100644 --- a/docs/modules/c-sharp/index.md +++ b/docs/modules/c-sharp/index.md @@ -295,30 +295,77 @@ public static void PrintInfo(ReducerContext e) } ``` -`[SpacetimeDB.Reducer]` also generates a function to schedule the given reducer in the future. -Since it's not possible to generate extension methods on existing methods, the codegen will instead add a `Schedule`-prefixed method colocated in the same namespace as the original method instead. The generated method will accept `DateTimeOffset` argument for the time when the reducer should be invoked, followed by all the arguments of the reducer itself, except those that have type `ReducerContext`. +### Scheduler Tables +Tables can be used to schedule a reducer calls either at a specific timestamp or at regular intervals. ```csharp -// Example reducer: -[SpacetimeDB.Reducer] -public static void Add(string name, int age) { ... } +public static partial class Timers +{ + + // The `Scheduled` attribute links this table to a reducer. + [SpacetimeDB.Table(Scheduled = nameof(SendScheduledMessage))] + public partial struct SendMessageTimer + { + public string Text; + } -// Auto-generated by the codegen: -public static void ScheduleAdd(DateTimeOffset time, string name, int age) { ... } + + // Define the reducer that will be invoked by the scheduler table. + // The first parameter is always `ReducerContext`, and the second parameter is an instance of the linked table struct. + [SpacetimeDB.Reducer] + public static void SendScheduledMessage(ReducerContext ctx, SendMessageTimer arg) + { + // ... + } -// Usage from another reducer: -[SpacetimeDB.Reducer] -public static void AddIn5Minutes(ReducerContext e, string name, int age) -{ - // Note that we're using `e.Time` instead of `DateTimeOffset.Now` which is not allowed in modules. - var scheduleToken = ScheduleAdd(e.Time.AddMinutes(5), name, age); - // We can cancel the scheduled reducer by calling `Cancel()` on the returned token. - scheduleToken.Cancel(); + // Scheduling reducers inside `init` reducer. + [SpacetimeDB.Reducer(ReducerKind.Init)] + public static void Init(ReducerContext ctx) + { + + // Schedule a one-time reducer call by inserting a row. + new SendMessageTimer + { + Text = "bot sending a message", + ScheduledAt = ctx.Time.AddSeconds(10), + ScheduledId = 1, + }.Insert(); + + + // Schedule a recurring reducer. + new SendMessageTimer + { + Text = "bot sending a message", + ScheduledAt = new TimeStamp(10), + ScheduledId = 2, + }.Insert(); + } } ``` +Annotating a struct with `Scheduled` automatically adds fields to support scheduling, It can be expanded as: + +```csharp +public static partial class Timers +{ + [SpacetimeDB.Table] + public partial struct SendMessageTimer + { + public string Text; // fields of original struct + + [SpacetimeDB.Column(ColumnAttrs.PrimaryKeyAuto)] + public ulong ScheduledId; // unique identifier to be used internally + + public SpacetimeDB.ScheduleAt ScheduleAt; // Scheduling details (Time or Inteval) + } +} + +// `ScheduledAt` definition +public abstract partial record ScheduleAt: SpacetimeDB.TaggedEnum<(DateTimeOffset Time, TimeSpan Interval)> +``` + #### Special reducers These are four special kinds of reducers that can be used to respond to module lifecycle events. They're stored in the `SpacetimeDB.Module.ReducerKind` class and can be used as an argument to the `[SpacetimeDB.Reducer]` attribute: diff --git a/docs/modules/rust/index.md b/docs/modules/rust/index.md index f4d02490..c2acf5cb 100644 --- a/docs/modules/rust/index.md +++ b/docs/modules/rust/index.md @@ -167,8 +167,6 @@ struct Person { ### Defining reducers -`#[spacetimedb(reducer)]` optionally takes a single argument, which is a frequency at which the reducer will be automatically called by the database. - `#[spacetimedb(reducer)]` is always applied to top level Rust functions. They can take arguments of types known to SpacetimeDB (just like fields of structs must be known to SpacetimeDB), and either return nothing, or return a `Result<(), E: Debug>`. ```rust @@ -192,39 +190,75 @@ struct Item { Note that reducers can call non-reducer functions, including standard library functions. -Reducers that are called periodically take an additional macro argument specifying the frequency at which they will be invoked. Durations are parsed according to https://docs.rs/humantime/latest/humantime/fn.parse_duration.html and will usually be a number of milliseconds or seconds. -Both of these examples are invoked every second. +There are several macros which modify the semantics of a column, which are applied to the members of the table struct. `#[unique]` and `#[autoinc]` are covered below, describing how those attributes affect the semantics of inserting, filtering, and so on. -```rust -#[spacetimedb(reducer, repeat = 1s)] -fn every_second() {} +#[SpacetimeType] -#[spacetimedb(reducer, repeat = 1000ms)] -fn every_thousand_milliseconds() {} -``` +#[sats] -Finally, reducers can also receive a ReducerContext object, or the Timestamp at which they are invoked, just by taking parameters of those types first. +### Defining Scheduler Tables +Tables can be used to schedule a reducer calls either at a specific timestamp or at regular intervals. ```rust -#[spacetimedb(reducer, repeat = 1s)] -fn tick_timestamp(time: Timestamp) { - println!("tick at {time}"); +// The `scheduled` attribute links this table to a reducer. +#[spacetimedb(table, scheduled(send_message))] +struct SendMessageTimer { + text: String, } +``` -#[spacetimedb(reducer, repeat = 500ms)] -fn tick_ctx(ctx: ReducerContext) { - println!("tick at {}", ctx.timestamp) +The `scheduled` attribute adds a couple of default fields and expands as follows: +```rust +#[spacetimedb(table)] + struct SendMessageTimer { + text: String, // original field + #[primary] + #[autoinc] + scheduled_id: u64, // identifier for internal purpose + scheduled_at: ScheduleAt, //schedule details +} + +pub enum ScheduleAt { + /// A specific time at which the reducer is scheduled. + /// Value is a UNIX timestamp in microseconds. + Time(u64), + /// A regular interval at which the repeated reducer is scheduled. + /// Value is a duration in microseconds. + Interval(u64), } ``` -Note that each distinct time a repeating reducer is invoked, a seperate schedule is created for that reducer. So invoking `every_second` three times from the spacetimedb cli will result in the reducer being called times times each second. +Managing timers with scheduled table is as simple as inserting or deleting rows from table. +```rust +#[spacetimedb(reducer)] -There are several macros which modify the semantics of a column, which are applied to the members of the table struct. `#[unique]` and `#[autoinc]` are covered below, describing how those attributes affect the semantics of inserting, filtering, and so on. +// Reducers linked to the scheduler table should have their first argument as `ReducerContext` +// and the second as an instance of the table struct it is linked to. +fn send_message(ctx: ReducerContext, arg: SendMessageTimer) -> Result<(), String> { + // ... +} -#[SpacetimeType] +// Scheduling reducers inside `init` reducer +fn init() { + // Scheduling a reducer for a specific Timestamp + SendMessageTimer::insert(SendMessageTimer { + scheduled_id: 1, + text:"bot sending a message".to_string(), + //`spacetimedb::Timestamp` implements `From` trait to `ScheduleAt::Time`. + scheduled_at: ctx.timestamp.plus(Duration::from_secs(10)).into() + }); + + // Scheduling a reducer to be called at fixed interval of 100 milliseconds. + SendMessageTimer::insert(SendMessageTimer { + scheduled_id: 0, + text:"bot sending a message".to_string(), + //`std::time::Duration` implements `From` trait to `ScheduleAt::Duration`. + scheduled_at: duration!(100ms).into(), + }); +} +``` -#[sats] ## Client API diff --git a/docs/unity/part-4.md b/docs/unity/part-4.md index 10738e84..d7c22280 100644 --- a/docs/unity/part-4.md +++ b/docs/unity/part-4.md @@ -98,11 +98,16 @@ pub struct Config { ### Step 2: Write our Resource Spawner Repeating Reducer -1. Add the following code to lib.rs. We are using a special attribute argument called repeat which will automatically schedule the reducer to run every 1000ms. +1. Add the following code to lib.rs. As we want to schedule `resource_spawn_agent` to run later, It will require to implement a scheduler table. ```rust -#[spacetimedb(reducer, repeat = 1000ms)] -pub fn resource_spawner_agent(_ctx: ReducerContext, _prev_time: Timestamp) -> Result<(), String> { +#[spacetimedb(table, scheduled(resource_spawner_agent))] +struct ResouceSpawnAgentSchedueler { + _prev_time: Timestamp, +} + +#[spacetimedb(reducer) +pub fn resource_spawner_agent(_ctx: ReducerContext, _arg: ResourceSpawnAgentScheduler) -> Result<(), String> { let config = Config::find_by_version(&0).unwrap(); // Retrieve the maximum number of nodes we want to spawn from the Config table @@ -157,18 +162,24 @@ pub fn resource_spawner_agent(_ctx: ReducerContext, _prev_time: Timestamp) -> Re } ``` + 2. Since this reducer uses `rand::Rng` we need add include it. Add this `use` statement to the top of lib.rs. ```rust use rand::Rng; ``` -3. Even though our reducer is set to repeat, we still need to schedule it the first time. Add the following code to the end of the `init` reducer. You can use this `schedule!` macro to schedule any reducer to run in the future after a certain amount of time. +3. Add the following code to the end of the `init` reducer to set the reducer to repeat at every regular interval. ```rust // Start our resource spawner repeating reducer - spacetimedb::schedule!("1000ms", resource_spawner_agent(_, Timestamp::now())); + ResouceSpawnAgentSchedueler::insert(ResouceSpawnAgentSchedueler { + _prev_time: TimeStamp::now(), + scheduled_id: 1, + scheduled_at: duration!(1000ms).into() + }).expect(); ``` +struct ResouceSpawnAgentSchedueler { 4. Next we need to generate our client code and publish the module. Since we changed the schema we need to make sure we include the `--clear-database` flag. Run the following commands from your Server directory: From 33626389ffa56818ea3c7160506e5117cb1737fa Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Thu, 29 Aug 2024 17:52:53 -0400 Subject: [PATCH 058/164] Update quickstart.md (#81) Revert the find_by changes in rust which were never made. --- docs/modules/rust/quickstart.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/modules/rust/quickstart.md b/docs/modules/rust/quickstart.md index e115ac97..d3544f19 100644 --- a/docs/modules/rust/quickstart.md +++ b/docs/modules/rust/quickstart.md @@ -108,7 +108,7 @@ To `server/src/lib.rs`, add: /// Clientss invoke this reducer to set their user names. pub fn set_name(ctx: ReducerContext, name: String) -> Result<(), String> { let name = validate_name(name)?; - if let Some(user) = User::find_by_identity(&ctx.sender) { + if let Some(user) = User::filter_by_identity(&ctx.sender) { User::update_by_identity(&ctx.sender, User { name: Some(name), ..user }); Ok(()) } else { @@ -183,7 +183,7 @@ You could extend the validation in `validate_message` in similar ways to `valida Whenever a client connects, the module will run a special reducer, annotated with `#[spacetimedb(connect)]`, if it's defined. By convention, it's named `identity_connected`. We'll use it to create a `User` record for the client if it doesn't yet exist, and to set its online status. -We'll use `User::find_by_identity` to look up a `User` row for `ctx.sender`, if one exists. If we find one, we'll use `User::update_by_identity` to overwrite it with a row that has `online: true`. If not, we'll use `User::insert` to insert a new row for our new user. All three of these methods are generated by the `#[spacetimedb(table)]` macro, with rows and behavior based on the row attributes. `find_by_identity` returns an `Option`, because the unique constraint from the `#[primarykey]` attribute means there will be either zero or one matching rows. `insert` returns a `Result<(), UniqueConstraintViolation>` because of the same unique constraint; if we want to overwrite a `User` row, we need to do so explicitly using `update_by_identity`. +We'll use `User::filter_by_identity` to look up a `User` row for `ctx.sender`, if one exists. If we find one, we'll use `User::update_by_identity` to overwrite it with a row that has `online: true`. If not, we'll use `User::insert` to insert a new row for our new user. All three of these methods are generated by the `#[spacetimedb(table)]` macro, with rows and behavior based on the row attributes. `filter_by_identity` returns an `Option`, because the unique constraint from the `#[primarykey]` attribute means there will be either zero or one matching rows. `insert` returns a `Result<(), UniqueConstraintViolation>` because of the same unique constraint; if we want to overwrite a `User` row, we need to do so explicitly using `update_by_identity`. To `server/src/lib.rs`, add the definition of the connect reducer: @@ -191,7 +191,7 @@ To `server/src/lib.rs`, add the definition of the connect reducer: #[spacetimedb(connect)] // Called when a client connects to the SpacetimeDB pub fn identity_connected(ctx: ReducerContext) { - if let Some(user) = User::find_by_identity(&ctx.sender) { + 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 }); @@ -213,7 +213,7 @@ Similarly, whenever a client disconnects, the module will run the `#[spacetimedb #[spacetimedb(disconnect)] // Called when a client disconnects from SpacetimeDB pub fn identity_disconnected(ctx: ReducerContext) { - if let Some(user) = User::find_by_identity(&ctx.sender) { + 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, From 4b28be5316658baee50192b6b6f7373ce0b19533 Mon Sep 17 00:00:00 2001 From: Mats Bennervall <44610444+Savalige@users.noreply.github.com> Date: Thu, 29 Aug 2024 23:54:13 +0200 Subject: [PATCH 059/164] Update Rust Quickstart to use correct function to find User (#80) Update quickstart.md From 12dcdfe1dff346cce7fc08f477e98f0886b99d98 Mon Sep 17 00:00:00 2001 From: ike709 Date: Thu, 29 Aug 2024 16:55:13 -0500 Subject: [PATCH 060/164] Explicitly remind the reader to start the server (#43) --- docs/modules/c-sharp/quickstart.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/modules/c-sharp/quickstart.md b/docs/modules/c-sharp/quickstart.md index 027b7ef9..21e4fcd0 100644 --- a/docs/modules/c-sharp/quickstart.md +++ b/docs/modules/c-sharp/quickstart.md @@ -258,6 +258,10 @@ public static void OnDisconnect(ReducerContext ReducerContext) } ``` +## Start the Server + +If you haven't already started the SpacetimeDB server, run the `spacetime start` command in a _separate_ terminal and leave it running while you continue following along. + ## Publish the module And that's all of our module code! We'll run `spacetime publish` to compile our module and publish it on SpacetimeDB. `spacetime publish` takes an optional name which will map to the database's unique address. Clients can connect either by name or by address, but names are much more pleasant. Come up with a unique name, and fill it in where we've written ``. From 3132663b573583a66be6ee08088015200d1c3f2c Mon Sep 17 00:00:00 2001 From: John Detter <4099508+jdetter@users.noreply.github.com> Date: Mon, 16 Sep 2024 11:08:13 -0500 Subject: [PATCH 061/164] Fix broken tutorial package link (#86) --- docs/unity/part-1.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/unity/part-1.md b/docs/unity/part-1.md index 5643a285..14eb2405 100644 --- a/docs/unity/part-1.md +++ b/docs/unity/part-1.md @@ -57,7 +57,7 @@ To work with SpacetimeDB and ensure compatibility, we need to add some essential In this step, we will import the provided Unity tutorial package that contains the basic single-player game setup. Follow these instructions: -1. Download the tutorial package from the releases page on GitHub: [https://github.com/clockworklabs/com.clockworklabs.spacetimedbsdk/releases/latest](https://github.com/clockworklabs/com.clockworklabs.spacetimedbsdk/releases/latest) +1. Download the tutorial package from the releases page on GitHub: [https://github.com/clockworklabs/SpacetimeDBUnityTutorial/releases/latest](https://github.com/clockworklabs/SpacetimeDBUnityTutorial/releases/latest) 2. In Unity, go to **Assets -> Import Package -> Custom Package**. ![Unity-ImportCustomPackageB](/images/unity-tutorial/Unity-ImportCustomPackageB.JPG) From 3b46a5d3aa5c68d87dfbd964822491023bc7dfe1 Mon Sep 17 00:00:00 2001 From: Puru Vijay <47742487+PuruVJ@users.noreply.github.com> Date: Thu, 19 Sep 2024 12:38:41 +0530 Subject: [PATCH 062/164] prettier (#85) Push --- .prettierrc | 11 ++ README.md | 1 + docs/getting-started.md | 2 +- docs/http/database.md | 72 ++++++------ docs/http/energy.md | 4 +- docs/http/identity.md | 12 +- docs/modules/c-sharp/index.md | 21 ++-- docs/modules/c-sharp/quickstart.md | 4 +- docs/modules/rust/index.md | 13 ++- docs/nav.js | 116 ++++++++++++-------- docs/sdks/c-sharp/index.md | 6 +- docs/sdks/index.md | 2 +- docs/sdks/rust/index.md | 20 ++-- docs/sdks/typescript/index.md | 170 +++++++++++++++++------------ docs/sdks/typescript/quickstart.md | 42 +++---- docs/unity/part-1.md | 4 +- docs/unity/part-2b-c-sharp.md | 1 + docs/unity/part-4.md | 2 +- docs/ws/index.md | 4 +- nav.ts | 119 ++++++++++++-------- package.json | 2 +- 21 files changed, 363 insertions(+), 265 deletions(-) create mode 100644 .prettierrc diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..2921455b --- /dev/null +++ b/.prettierrc @@ -0,0 +1,11 @@ +{ + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": true, + "arrowParens": "avoid", + "jsxSingleQuote": false, + "trailingComma": "es5", + "endOfLine": "auto", + "printWidth": 80 +} diff --git a/README.md b/README.md index 0f9998b0..c31b2c3f 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ git clone ssh://git@github.com//spacetime-docs git add . git commit -m "A specific description of the changes I made and why" ``` + 5. Push your changes to your fork as a branch ```bash diff --git a/docs/getting-started.md b/docs/getting-started.md index 4b0cddae..33265dc2 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -28,6 +28,6 @@ You are ready to start developing SpacetimeDB modules. See below for a quickstar ### Client - [Rust](/docs/sdks/rust/quickstart) -- [C# (Standalone)](/docs/sdks/c-sharp/quickstart) +- [C# (Standalone)](/docs/sdks/c-sharp/quickstart) - [C# (Unity)](/docs/unity/part-1) - [Typescript](/docs/sdks/typescript/quickstart) diff --git a/docs/http/database.md b/docs/http/database.md index 16ee729c..9b6e0488 100644 --- a/docs/http/database.md +++ b/docs/http/database.md @@ -15,7 +15,7 @@ The HTTP endpoints in `/database` allow clients to interact with Spacetime datab | [`/database/confirm_recovery_code GET`](#databaseconfirm_recovery_code-get) | Recover a login token from a recovery code. | | [`/database/publish POST`](#databasepublish-post) | Publish a database given its module code. | | [`/database/delete/:address POST`](#databasedeleteaddress-post) | Delete a database. | -| [`/database/subscribe/:name_or_address GET`](#databasesubscribename_or_address-get) | Begin a [WebSocket connection](/docs/ws). | +| [`/database/subscribe/:name_or_address GET`](#databasesubscribename_or_address-get) | Begin a [WebSocket connection](/docs/ws). | | [`/database/call/:name_or_address/:reducer POST`](#databasecallname_or_addressreducer-post) | Invoke a reducer in a database. | | [`/database/schema/:name_or_address GET`](#databaseschemaname_or_address-get) | Get the schema for a database. | | [`/database/schema/:name_or_address/:entity_type/:entity GET`](#databaseschemaname_or_addressentity_typeentity-get) | Get a schema for a particular table or reducer. | @@ -92,8 +92,8 @@ Accessible through the CLI as `spacetime dns set-name
`. #### Required Headers -| Name | Value | -| --------------- | ------------------------------------------------------------------------------------------- | +| Name | Value | +| --------------- | --------------------------------------------------------------- | | `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http). | #### Returns @@ -145,8 +145,8 @@ Accessible through the CLI as `spacetime dns register-tld `. #### Required Headers -| Name | Value | -| --------------- | ------------------------------------------------------------------------------------------- | +| Name | Value | +| --------------- | --------------------------------------------------------------- | | `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http). | #### Returns @@ -183,11 +183,11 @@ Accessible through the CLI as `spacetime identity recover `. #### Query Parameters -| Name | Value | -| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `identity` | The identity whose token should be recovered. | +| Name | Value | +| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `identity` | The identity whose token should be recovered. | | `email` | The email to send the recovery code or link to. This email must be associated with the identity, either during creation via [`/identity`](/docs/http/identity#identity-post) or afterwards via [`/identity/:identity/set-email`](/docs/http/identity#identityidentityset_email-post). | -| `link` | A boolean; whether to send a clickable link rather than a recovery code. | +| `link` | A boolean; whether to send a clickable link rather than a recovery code. | ## `/database/confirm_recovery_code GET` @@ -229,8 +229,8 @@ Accessible through the CLI as `spacetime publish`. #### Required Headers -| Name | Value | -| --------------- | ------------------------------------------------------------------------------------------- | +| Name | Value | +| --------------- | --------------------------------------------------------------- | | `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http). | #### Data @@ -281,8 +281,8 @@ Accessible through the CLI as `spacetime delete
`. #### Required Headers -| Name | Value | -| --------------- | ------------------------------------------------------------------------------------------- | +| Name | Value | +| --------------- | --------------------------------------------------------------- | | `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http). | ## `/database/subscribe/:name_or_address GET` @@ -299,18 +299,18 @@ Begin a [WebSocket connection](/docs/ws) with a database. For more information about WebSocket headers, see [RFC 6455](https://datatracker.ietf.org/doc/html/rfc6455). -| Name | Value | -| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| Name | Value | +| ------------------------ | ---------------------------------------------------------------------------------------------------- | | `Sec-WebSocket-Protocol` | [`v1.bin.spacetimedb`](/docs/ws#binary-protocol) or [`v1.text.spacetimedb`](/docs/ws#text-protocol). | -| `Connection` | `Updgrade` | -| `Upgrade` | `websocket` | -| `Sec-WebSocket-Version` | `13` | -| `Sec-WebSocket-Key` | A 16-byte value, generated randomly by the client, encoded as Base64. | +| `Connection` | `Updgrade` | +| `Upgrade` | `websocket` | +| `Sec-WebSocket-Version` | `13` | +| `Sec-WebSocket-Key` | A 16-byte value, generated randomly by the client, encoded as Base64. | #### Optional Headers -| Name | Value | -| --------------- | ------------------------------------------------------------------------------------------- | +| Name | Value | +| --------------- | --------------------------------------------------------------- | | `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http). | ## `/database/call/:name_or_address/:reducer POST` @@ -326,8 +326,8 @@ Invoke a reducer in a database. #### Required Headers -| Name | Value | -| --------------- | ------------------------------------------------------------------------------------------- | +| Name | Value | +| --------------- | --------------------------------------------------------------- | | `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http). | #### Data @@ -444,10 +444,10 @@ The `"entities"` will be an object whose keys are table and reducer names, and w } ``` -| Entity field | Value | -| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `arity` | For tables, the number of colums; for reducers, the number of arguments. | -| `type` | For tables, `"table"`; for reducers, `"reducer"`. | +| Entity field | Value | +| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `arity` | For tables, the number of colums; for reducers, the number of arguments. | +| `type` | For tables, `"table"`; for reducers, `"reducer"`. | | `schema` | A [JSON-encoded `ProductType`](/docs/satn); for tables, the table schema; for reducers, the argument schema. Only present if `expand` is supplied and true. | The `"typespace"` will be a JSON array of [`AlgebraicType`s](/docs/satn) referenced by the module. This can be used to resolve `Ref` types within the schema; the type `{ "Ref": n }` refers to `response["typespace"][n]`. @@ -484,10 +484,10 @@ Returns a single entity in the same format as in the `"entities"` returned by [t } ``` -| Field | Value | -| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `arity` | For tables, the number of colums; for reducers, the number of arguments. | -| `type` | For tables, `"table"`; for reducers, `"reducer"`. | +| Field | Value | +| -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `arity` | For tables, the number of colums; for reducers, the number of arguments. | +| `type` | For tables, `"table"`; for reducers, `"reducer"`. | | `schema` | A [JSON-encoded `ProductType`](/docs/satn); for tables, the table schema; for reducers, the argument schema. Only present if `expand` is supplied and true. | ## `/database/info/:name_or_address GET` @@ -514,7 +514,7 @@ Returns JSON in the form: ``` | Field | Type | Meaning | -| --------------------| ------ | ---------------------------------------------------------------- | +| ------------------- | ------ | ---------------------------------------------------------------- | | `"address"` | String | The address of the database. | | `"owner_identity"` | String | The Spacetime identity of the database's owner. | | `"host_type"` | String | The module host type; currently always `"wasm"`. | @@ -541,8 +541,8 @@ Accessible through the CLI as `spacetime logs `. #### Required Headers -| Name | Value | -| --------------- | ------------------------------------------------------------------------------------------- | +| Name | Value | +| --------------- | --------------------------------------------------------------- | | `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http). | #### Returns @@ -563,8 +563,8 @@ Accessible through the CLI as `spacetime sql `. #### Required Headers -| Name | Value | -| --------------- | ------------------------------------------------------------------------------------------- | +| Name | Value | +| --------------- | --------------------------------------------------------------- | | `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http). | #### Data diff --git a/docs/http/energy.md b/docs/http/energy.md index b49a1ee7..6f008314 100644 --- a/docs/http/energy.md +++ b/docs/http/energy.md @@ -57,8 +57,8 @@ Accessible through the CLI as `spacetime energy set-balance #### Required Headers -| Name | Value | -| --------------- | ------------------------------------------------------------------------------------------- | +| Name | Value | +| --------------- | --------------------------------------------------------------- | | `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http). | #### Returns diff --git a/docs/http/identity.md b/docs/http/identity.md index 5fb45867..6f1e22c9 100644 --- a/docs/http/identity.md +++ b/docs/http/identity.md @@ -71,8 +71,8 @@ Generate a short-lived access token which can be used in untrusted contexts, e.g #### Required Headers -| Name | Value | -| --------------- | ------------------------------------------------------------------------------------------- | +| Name | Value | +| --------------- | --------------------------------------------------------------- | | `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http). | #### Returns @@ -107,8 +107,8 @@ Accessible through the CLI as `spacetime identity set-email `. #### Required Headers -| Name | Value | -| --------------- | ------------------------------------------------------------------------------------------- | +| Name | Value | +| --------------- | --------------------------------------------------------------- | | `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http). | ## `/identity/:identity/databases GET` @@ -145,8 +145,8 @@ Verify the validity of an identity/token pair. #### Required Headers -| Name | Value | -| --------------- | ------------------------------------------------------------------------------------------- | +| Name | Value | +| --------------- | --------------------------------------------------------------- | | `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http). | #### Returns diff --git a/docs/modules/c-sharp/index.md b/docs/modules/c-sharp/index.md index 7380467f..f6763fc7 100644 --- a/docs/modules/c-sharp/index.md +++ b/docs/modules/c-sharp/index.md @@ -42,7 +42,7 @@ static partial class Module { // We can skip (or explicitly set to zero) auto-incremented fields when creating new rows. var person = new Person { Name = name, Age = age }; - + // `Insert()` method is auto-generated and will insert the given row into the table. person.Insert(); // After insertion, the auto-incremented fields will be populated with their actual values. @@ -120,7 +120,6 @@ And a couple of special custom types: - `Identity` (`SpacetimeDB.Runtime.Identity`) - a unique identifier for each user; internally a byte blob but can be printed, hashed and compared for equality. - `Address` (`SpacetimeDB.Runtime.Address`) - an identifier which disamgibuates connections by the same `Identity`; internally a byte blob but can be printed, hashed and compared for equality. - #### Custom types `[SpacetimeDB.Type]` attribute can be used on any `struct`, `class` or an `enum` to mark it as a SpacetimeDB type. It will implement serialization and deserialization for values of this type so that they can be stored in the database. @@ -245,10 +244,10 @@ public partial struct Person // Finds a row in the table with the given value in the `Id` column and returns it, or `null` if no such row exists. public static Person? FindById(int id); - + // Deletes a row in the table with the given value in the `Id` column and returns `true` if the row was found and deleted, or `false` if no such row exists. public static bool DeleteById(int id); - + // Updates a row in the table with the given value in the `Id` column and returns `true` if the row was found and updated, or `false` if no such row exists. public static bool UpdateById(int oldId, Person newValue); } @@ -295,14 +294,14 @@ public static void PrintInfo(ReducerContext e) } ``` - ### Scheduler Tables + Tables can be used to schedule a reducer calls either at a specific timestamp or at regular intervals. ```csharp public static partial class Timers { - + // The `Scheduled` attribute links this table to a reducer. [SpacetimeDB.Table(Scheduled = nameof(SendScheduledMessage))] public partial struct SendMessageTimer @@ -310,7 +309,7 @@ public static partial class Timers public string Text; } - + // Define the reducer that will be invoked by the scheduler table. // The first parameter is always `ReducerContext`, and the second parameter is an instance of the linked table struct. [SpacetimeDB.Reducer] @@ -354,10 +353,10 @@ public static partial class Timers public partial struct SendMessageTimer { public string Text; // fields of original struct - + [SpacetimeDB.Column(ColumnAttrs.PrimaryKeyAuto)] public ulong ScheduledId; // unique identifier to be used internally - + public SpacetimeDB.ScheduleAt ScheduleAt; // Scheduling details (Time or Inteval) } } @@ -375,10 +374,9 @@ These are four special kinds of reducers that can be used to respond to module l - `ReducerKind.Connect` - this reducer will be invoked when a client connects to the database. - `ReducerKind.Disconnect` - this reducer will be invoked when a client disconnects from the database. - Example: -```csharp +````csharp [SpacetimeDB.Reducer(ReducerKind.Init)] public static void Init() { @@ -402,3 +400,4 @@ public static void OnDisconnect(DbEventArgs ctx) { Log($"{ctx.Sender} has disconnected."); }``` +```` diff --git a/docs/modules/c-sharp/quickstart.md b/docs/modules/c-sharp/quickstart.md index 21e4fcd0..768602e4 100644 --- a/docs/modules/c-sharp/quickstart.md +++ b/docs/modules/c-sharp/quickstart.md @@ -23,6 +23,7 @@ If you haven't already, start by [installing SpacetimeDB](/install). This will i Next we need to [install .NET 8 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/8.0) so that we can build and publish our module. You may already have .NET 8 and can be checked: + ```bash dotnet --list-sdks ``` @@ -50,7 +51,7 @@ spacetime init --lang csharp server ## Declare imports -`spacetime init` generated a few files: +`spacetime init` generated a few files: 1. Open `server/StdbModule.csproj` to generate a .sln file for intellisense/validation support. 2. Open `server/Lib.cs`, a trivial module. @@ -81,7 +82,6 @@ To get our chat server running, we'll need to store two kinds of data: informati For each `User`, we'll store their `Identity`, an optional name they can set to identify themselves to other users, and whether they're online or not. We'll designate the `Identity` as our primary key, which enforces that it must be unique, indexes it for faster lookup, and allows clients to track updates. - In `server/Lib.cs`, add the definition of the table `User` to the `Module` class: ```csharp diff --git a/docs/modules/rust/index.md b/docs/modules/rust/index.md index c2acf5cb..55ceec18 100644 --- a/docs/modules/rust/index.md +++ b/docs/modules/rust/index.md @@ -190,7 +190,6 @@ struct Item { Note that reducers can call non-reducer functions, including standard library functions. - There are several macros which modify the semantics of a column, which are applied to the members of the table struct. `#[unique]` and `#[autoinc]` are covered below, describing how those attributes affect the semantics of inserting, filtering, and so on. #[SpacetimeType] @@ -198,6 +197,7 @@ There are several macros which modify the semantics of a column, which are appli #[sats] ### Defining Scheduler Tables + Tables can be used to schedule a reducer calls either at a specific timestamp or at regular intervals. ```rust @@ -208,7 +208,8 @@ struct SendMessageTimer { } ``` -The `scheduled` attribute adds a couple of default fields and expands as follows: +The `scheduled` attribute adds a couple of default fields and expands as follows: + ```rust #[spacetimedb(table)] struct SendMessageTimer { @@ -230,10 +231,11 @@ pub enum ScheduleAt { ``` Managing timers with scheduled table is as simple as inserting or deleting rows from table. + ```rust #[spacetimedb(reducer)] -// Reducers linked to the scheduler table should have their first argument as `ReducerContext` +// Reducers linked to the scheduler table should have their first argument as `ReducerContext` // and the second as an instance of the table struct it is linked to. fn send_message(ctx: ReducerContext, arg: SendMessageTimer) -> Result<(), String> { // ... @@ -245,7 +247,7 @@ fn init() { SendMessageTimer::insert(SendMessageTimer { scheduled_id: 1, text:"bot sending a message".to_string(), - //`spacetimedb::Timestamp` implements `From` trait to `ScheduleAt::Time`. + //`spacetimedb::Timestamp` implements `From` trait to `ScheduleAt::Time`. scheduled_at: ctx.timestamp.plus(Duration::from_secs(10)).into() }); @@ -253,13 +255,12 @@ fn init() { SendMessageTimer::insert(SendMessageTimer { scheduled_id: 0, text:"bot sending a message".to_string(), - //`std::time::Duration` implements `From` trait to `ScheduleAt::Duration`. + //`std::time::Duration` implements `From` trait to `ScheduleAt::Duration`. scheduled_at: duration!(100ms).into(), }); } ``` - ## Client API Besides the macros for creating tables and reducers, there's two other parts of the Rust SpacetimeDB library. One is a collection of macros for logging, and the other is all the automatically generated functions for operating on those tables. diff --git a/docs/nav.js b/docs/nav.js index 6949c4f7..5a669500 100644 --- a/docs/nav.js +++ b/docs/nav.js @@ -1,55 +1,75 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); +'use strict'; +Object.defineProperty(exports, '__esModule', { value: true }); function page(title, slug, path, props) { - return { type: "page", path, slug, title, ...props }; + return { type: 'page', path, slug, title, ...props }; } function section(title) { - return { type: "section", title }; + return { type: 'section', title }; } const nav = { - items: [ - section("Intro"), - page("Overview", "index", "index.md"), // TODO(BREAKING): For consistency & clarity, 'index' slug should be renamed 'intro'? - page("Getting Started", "getting-started", "getting-started.md"), - section("Deploying"), - page("Testnet", "deploying/testnet", "deploying/testnet.md"), - section("Unity Tutorial - Basic Multiplayer"), - page("Overview", "unity-tutorial", "unity/index.md"), - page("1 - Setup", "unity/part-1", "unity/part-1.md"), - page("2a - Server (Rust)", "unity/part-2a-rust", "unity/part-2a-rust.md"), - page("2b - Server (C#)", "unity/part-2b-c-sharp", "unity/part-2b-c-sharp.md"), - page("3 - Client", "unity/part-3", "unity/part-3.md"), - section("Unity Tutorial - Advanced"), - page("4 - Resources And Scheduling", "unity/part-4", "unity/part-4.md"), - page("5 - BitCraft Mini", "unity/part-5", "unity/part-5.md"), - section("Server Module Languages"), - page("Overview", "modules", "modules/index.md"), - page("Rust Quickstart", "modules/rust/quickstart", "modules/rust/quickstart.md"), - page("Rust Reference", "modules/rust", "modules/rust/index.md"), - page("C# Quickstart", "modules/c-sharp/quickstart", "modules/c-sharp/quickstart.md"), - page("C# Reference", "modules/c-sharp", "modules/c-sharp/index.md"), - section("Client SDK Languages"), - page("Overview", "sdks", "sdks/index.md"), - page("Typescript Quickstart", "sdks/typescript/quickstart", "sdks/typescript/quickstart.md"), - page("Typescript Reference", "sdks/typescript", "sdks/typescript/index.md"), - page("Rust Quickstart", "sdks/rust/quickstart", "sdks/rust/quickstart.md"), - page("Rust Reference", "sdks/rust", "sdks/rust/index.md"), - page("C# Quickstart", "sdks/c-sharp/quickstart", "sdks/c-sharp/quickstart.md"), - page("C# Reference", "sdks/c-sharp", "sdks/c-sharp/index.md"), - section("WebAssembly ABI"), - page("Module ABI Reference", "webassembly-abi", "webassembly-abi/index.md"), - section("HTTP API"), - page("HTTP", "http", "http/index.md"), - page("`/identity`", "http/identity", "http/identity.md"), - page("`/database`", "http/database", "http/database.md"), - page("`/energy`", "http/energy", "http/energy.md"), - section("WebSocket API Reference"), - page("WebSocket", "ws", "ws/index.md"), - section("Data Format"), - page("SATN", "satn", "satn.md"), - page("BSATN", "bsatn", "bsatn.md"), - section("SQL"), - page("SQL Reference", "sql", "sql/index.md"), - ], + items: [ + section('Intro'), + page('Overview', 'index', 'index.md'), // TODO(BREAKING): For consistency & clarity, 'index' slug should be renamed 'intro'? + page('Getting Started', 'getting-started', 'getting-started.md'), + section('Deploying'), + page('Testnet', 'deploying/testnet', 'deploying/testnet.md'), + section('Unity Tutorial - Basic Multiplayer'), + page('Overview', 'unity-tutorial', 'unity/index.md'), + page('1 - Setup', 'unity/part-1', 'unity/part-1.md'), + page('2a - Server (Rust)', 'unity/part-2a-rust', 'unity/part-2a-rust.md'), + page( + '2b - Server (C#)', + 'unity/part-2b-c-sharp', + 'unity/part-2b-c-sharp.md' + ), + page('3 - Client', 'unity/part-3', 'unity/part-3.md'), + section('Unity Tutorial - Advanced'), + page('4 - Resources And Scheduling', 'unity/part-4', 'unity/part-4.md'), + page('5 - BitCraft Mini', 'unity/part-5', 'unity/part-5.md'), + section('Server Module Languages'), + page('Overview', 'modules', 'modules/index.md'), + page( + 'Rust Quickstart', + 'modules/rust/quickstart', + 'modules/rust/quickstart.md' + ), + page('Rust Reference', 'modules/rust', 'modules/rust/index.md'), + page( + 'C# Quickstart', + 'modules/c-sharp/quickstart', + 'modules/c-sharp/quickstart.md' + ), + page('C# Reference', 'modules/c-sharp', 'modules/c-sharp/index.md'), + section('Client SDK Languages'), + page('Overview', 'sdks', 'sdks/index.md'), + page( + 'Typescript Quickstart', + 'sdks/typescript/quickstart', + 'sdks/typescript/quickstart.md' + ), + page('Typescript Reference', 'sdks/typescript', 'sdks/typescript/index.md'), + page('Rust Quickstart', 'sdks/rust/quickstart', 'sdks/rust/quickstart.md'), + page('Rust Reference', 'sdks/rust', 'sdks/rust/index.md'), + page( + 'C# Quickstart', + 'sdks/c-sharp/quickstart', + 'sdks/c-sharp/quickstart.md' + ), + page('C# Reference', 'sdks/c-sharp', 'sdks/c-sharp/index.md'), + section('WebAssembly ABI'), + page('Module ABI Reference', 'webassembly-abi', 'webassembly-abi/index.md'), + section('HTTP API'), + page('HTTP', 'http', 'http/index.md'), + page('`/identity`', 'http/identity', 'http/identity.md'), + page('`/database`', 'http/database', 'http/database.md'), + page('`/energy`', 'http/energy', 'http/energy.md'), + section('WebSocket API Reference'), + page('WebSocket', 'ws', 'ws/index.md'), + section('Data Format'), + page('SATN', 'satn', 'satn.md'), + page('BSATN', 'bsatn', 'bsatn.md'), + section('SQL'), + page('SQL Reference', 'sql', 'sql/index.md'), + ], }; exports.default = nav; diff --git a/docs/sdks/c-sharp/index.md b/docs/sdks/c-sharp/index.md index e8a3d01a..d85f5702 100644 --- a/docs/sdks/c-sharp/index.md +++ b/docs/sdks/c-sharp/index.md @@ -849,7 +849,7 @@ Save a token to the filesystem. ### Class `Identity` ```cs -namespace SpacetimeDB +namespace SpacetimeDB { public struct Identity : IEquatable { @@ -869,7 +869,7 @@ A unique public identifier for a user of a database. Columns of type `Identity` inside a module will be represented in the C# SDK as properties of type `byte[]`. `Identity` is essentially just a wrapper around `byte[]`, and you can use the `Bytes` property to get a `byte[]` that can be used to filter tables and so on. ```cs -namespace SpacetimeDB +namespace SpacetimeDB { public struct Address : IEquatable
{ @@ -888,7 +888,7 @@ An opaque identifier for a client connection to a database, intended to differen The SpacetimeDB C# SDK performs internal logging. -A default logger is set up automatically for you - a [`ConsoleLogger`](#class-consolelogger) for C# projects and [`UnityDebugLogger`](#class-unitydebuglogger) for Unity projects. +A default logger is set up automatically for you - a [`ConsoleLogger`](#class-consolelogger) for C# projects and [`UnityDebugLogger`](#class-unitydebuglogger) for Unity projects. If you want to redirect SDK logs elsewhere, you can inherit from the [`ISpacetimeDBLogger`](#interface-ispacetimedblogger) and assign an instance of your class to the `SpacetimeDB.Logger.Current` static property. diff --git a/docs/sdks/index.md b/docs/sdks/index.md index 940f06ac..46078cb9 100644 --- a/docs/sdks/index.md +++ b/docs/sdks/index.md @@ -1,4 +1,4 @@ - SpacetimeDB Client SDKs Overview +SpacetimeDB Client SDKs Overview The SpacetimeDB Client SDKs provide a comprehensive interface to interact with the SpacetimeDB server engine from various programming languages. Currently, SDKs are available for diff --git a/docs/sdks/rust/index.md b/docs/sdks/rust/index.md index dbc23112..9c9e6f12 100644 --- a/docs/sdks/rust/index.md +++ b/docs/sdks/rust/index.md @@ -50,15 +50,15 @@ mod module_bindings; | Function [`spacetimedb_sdk::identity::identity`](#function-identity) | Return the current connection's `Identity`. | | Function [`spacetimedb_sdk::identity::token`](#function-token) | Return the current connection's `Token`. | | Function [`spacetimedb_sdk::identity::credentials`](#function-credentials) | Return the current connection's [`Credentials`](#type-credentials). | -| Function [`spacetimedb_sdk::identity::address`](#function-address) | Return the current connection's [`Address`](#type-address). | +| Function [`spacetimedb_sdk::identity::address`](#function-address) | Return the current connection's [`Address`](#type-address). | | Function [`spacetimedb_sdk::identity::on_connect`](#function-on_connect) | Register a `FnMut` callback to run when the connection's [`Credentials`](#type-credentials) are verified with the database. | | Function [`spacetimedb_sdk::identity::once_on_connect`](#function-once_on_connect) | Register a `FnOnce` callback to run when the connection's [`Credentials`](#type-credentials) are verified with the database. | | Function [`spacetimedb_sdk::identity::remove_on_connect`](#function-remove_on_connect) | Cancel an `on_connect` or `once_on_connect` callback. | | Function [`spacetimedb_sdk::identity::load_credentials`](#function-load_credentials) | Load a saved [`Credentials`](#type-credentials) from a file. | | Function [`spacetimedb_sdk::identity::save_credentials`](#function-save_credentials) | Save a [`Credentials`](#type-credentials) to a file. | | Type [`module_bindings::{TABLE}`](#type-table) | Autogenerated `struct` type for a table, holding one row. | -| Method [`module_bindings::{TABLE}::filter_by_{COLUMN}`](#method-filter_by_column) | Autogenerated method to iterate over subscribed rows where a column matches a value. | -| Method [`module_bindings::{TABLE}::find_by_{COLUMN}`](#method-find_by_column) | Autogenerated method to seek a subscribed row where a unique column matches a value. | +| Method [`module_bindings::{TABLE}::filter_by_{COLUMN}`](#method-filter_by_column) | Autogenerated method to iterate over subscribed rows where a column matches a value. | +| Method [`module_bindings::{TABLE}::find_by_{COLUMN}`](#method-find_by_column) | Autogenerated method to seek a subscribed row where a unique column matches a value. | | Trait [`spacetimedb_sdk::table::TableType`](#trait-tabletype) | Automatically implemented for all tables defined by a module. | | Method [`spacetimedb_sdk::table::TableType::count`](#method-count) | Count the number of subscribed rows in a table. | | Method [`spacetimedb_sdk::table::TableType::iter`](#method-iter) | Iterate over all subscribed rows. | @@ -535,9 +535,9 @@ spacetimedb_sdk::identity::on_connect( Register a callback to be invoked upon authentication with the database. | Argument | Type | Meaning | -|------------|----------------------------------------------------|--------------------------------------------------------| +| ---------- | -------------------------------------------------- | ------------------------------------------------------ | | `callback` | `impl FnMut(&Credentials, Address) + Send + 'sync` | Callback to be invoked upon successful authentication. | - + The callback will be invoked with the [`Credentials`](#type-credentials) and [`Address`](#type-address) provided by the database to identify this connection. If [`Credentials`](#type-credentials) were supplied to [`connect`](#function-connect), those passed to the callback will be equivalent to the ones used to connect. If the initial connection was anonymous, a new set of [`Credentials`](#type-credentials) will be generated by the database to identify this user. The [`Credentials`](#type-credentials) passed to the callback can be saved and used to authenticate the same user in future connections. @@ -570,7 +570,7 @@ spacetimedb_sdk::identity::once_on_connect( Register a callback to be invoked once upon authentication with the database. | Argument | Type | Meaning | -|------------|-----------------------------------------------------|------------------------------------------------------------------| +| ---------- | --------------------------------------------------- | ---------------------------------------------------------------- | | `callback` | `impl FnOnce(&Credentials, Address) + Send + 'sync` | Callback to be invoked once upon next successful authentication. | The callback will be invoked with the [`Credentials`](#type-credentials) and [`Address`](#type-address) provided by the database to identify this connection. If [`Credentials`](#type-credentials) were supplied to [`connect`](#function-connect), those passed to the callback will be equivalent to the ones used to connect. If the initial connection was anonymous, a new set of [`Credentials`](#type-credentials) will be generated by the database to identify this user. @@ -1114,8 +1114,8 @@ module_bindings::on_{REDUCER}( For each reducer defined by a module, `spacetime generate` generates a function which registers a `FnMut` callback to run each time the reducer is invoked. The generated functions are named `on_{REDUCER}`, where `{REDUCER}` is the reducer's name converted to `snake_case`. -| Argument | Type | Meaning | -| ---------- | ------------------------------------------------------------- | ------------------------------------------------ | +| Argument | Type | Meaning | +| ---------- | ----------------------------------------------------------------------------- | ------------------------------------------------ | | `callback` | `impl FnMut(&Identity, Option
&Status, {&ARGS...}) + Send + 'static` | Callback to run whenever the reducer is invoked. | The callback always accepts three arguments: @@ -1142,8 +1142,8 @@ module_bindings::once_on_{REDUCER}( For each reducer defined by a module, `spacetime generate` generates a function which registers a `FnOnce` callback to run the next time the reducer is invoked. The generated functions are named `once_on_{REDUCER}`, where `{REDUCER}` is the reducer's name converted to `snake_case`. -| Argument | Type | Meaning | -| ---------- | -------------------------------------------------------------- | ----------------------------------------------------- | +| Argument | Type | Meaning | +| ---------- | ------------------------------------------------------------------------------- | ----------------------------------------------------- | | `callback` | `impl FnOnce(&Identity, Option
, &Status, {&ARGS...}) + Send + 'static` | Callback to run the next time the reducer is invoked. | The callback accepts the same arguments as an [on-reducer callback](#function-on_reducer), but may be a `FnOnce` rather than a `FnMut`. diff --git a/docs/sdks/typescript/index.md b/docs/sdks/typescript/index.md index 00917813..4f4e17da 100644 --- a/docs/sdks/typescript/index.md +++ b/docs/sdks/typescript/index.md @@ -10,11 +10,11 @@ First, create a new client project, and add the following to your `tsconfig.json ```json { - "compilerOptions": { - //You can use any target higher than this one - //https://www.typescriptlang.org/tsconfig#target - "target": "es2015" - } + "compilerOptions": { + //You can use any target higher than this one + //https://www.typescriptlang.org/tsconfig#target + "target": "es2015" + } } ``` @@ -147,7 +147,12 @@ const name_or_address = 'database_name'; const auth_token = undefined; const protocol = 'binary'; -var spacetimeDBClient = new SpacetimeDBClient(host, name_or_address, auth_token, protocol); +var spacetimeDBClient = new SpacetimeDBClient( + host, + name_or_address, + auth_token, + protocol +); ``` ## Class methods @@ -268,7 +273,11 @@ const host = 'ws://localhost:3000'; const name_or_address = 'database_name'; const auth_token = undefined; -var spacetimeDBClient = new SpacetimeDBClient(host, name_or_address, auth_token); +var spacetimeDBClient = new SpacetimeDBClient( + host, + name_or_address, + auth_token +); // Connect with the initial parameters spacetimeDBClient.connect(); //Set the `auth_token` @@ -288,7 +297,10 @@ disconnect(): void #### Example ```ts -var spacetimeDBClient = new SpacetimeDBClient('ws://localhost:3000', 'database_name'); +var spacetimeDBClient = new SpacetimeDBClient( + 'ws://localhost:3000', + 'database_name' +); spacetimeDBClient.disconnect(); ``` @@ -343,10 +355,10 @@ The credentials passed to the callback can be saved and used to authenticate the ```ts spacetimeDBClient.onConnect((token, identity, address) => { - console.log('Connected to SpacetimeDB'); - console.log('Token', token); - console.log('Identity', identity); - console.log('Address', address); + console.log('Connected to SpacetimeDB'); + console.log('Token', token); + console.log('Identity', identity); + console.log('Address', address); }); ``` @@ -370,7 +382,7 @@ onError(callback: (...args: any[]) => void): void ```ts spacetimeDBClient.onError((...args: any[]) => { - console.error('ERROR', args); + console.error('ERROR', args); }); ``` @@ -546,22 +558,22 @@ For each table defined by a module, `spacetime generate` generates a `class` in The generated class has a field for each of the table's columns, whose names are the column names converted to `snake_case`. -| Properties | Description | -| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -| [`Table.name`](#table-name) | The name of the class. | -| [`Table.tableName`](#table-tableName) | The name of the table in the database. | -| Methods | | -| [`Table.isEqual`](#table-isequal) | Method to compare two identities. | -| [`Table.all`](#table-all) | Return all the subscribed rows in the table. | -| [`Table.filterBy{COLUMN}`](#table-filterbycolumn) | Autogenerated; return subscribed rows with a given value in a particular column. `{COLUMN}` is a placeholder for a column name. | -| [`Table.findBy{COLUMN}`](#table-findbycolumn) | Autogenerated; return a subscribed row with a given value in a particular unique column. `{COLUMN}` is a placeholder for a column name. | -| Events | | -| [`Table.onInsert`](#table-oninsert) | Register an `onInsert` callback for when a subscribed row is newly inserted into the database. | -| [`Table.removeOnInsert`](#table-removeoninsert) | Unregister a previously-registered [`onInsert`](#table-oninsert) callback. | -| [`Table.onUpdate`](#table-onupdate) | Register an `onUpdate` callback for when an existing row is modified. | -| [`Table.removeOnUpdate`](#table-removeonupdate) | Unregister a previously-registered [`onUpdate`](#table-onupdate) callback. | -| [`Table.onDelete`](#table-ondelete) | Register an `onDelete` callback for when a subscribed row is removed from the database. | -| [`Table.removeOnDelete`](#table-removeondelete) | Unregister a previously-registered [`onDelete`](#table-removeondelete) callback. | +| Properties | Description | +| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| [`Table.name`](#table-name) | The name of the class. | +| [`Table.tableName`](#table-tableName) | The name of the table in the database. | +| Methods | | +| [`Table.isEqual`](#table-isequal) | Method to compare two identities. | +| [`Table.all`](#table-all) | Return all the subscribed rows in the table. | +| [`Table.filterBy{COLUMN}`](#table-filterbycolumn) | Autogenerated; return subscribed rows with a given value in a particular column. `{COLUMN}` is a placeholder for a column name. | +| [`Table.findBy{COLUMN}`](#table-findbycolumn) | Autogenerated; return a subscribed row with a given value in a particular unique column. `{COLUMN}` is a placeholder for a column name. | +| Events | | +| [`Table.onInsert`](#table-oninsert) | Register an `onInsert` callback for when a subscribed row is newly inserted into the database. | +| [`Table.removeOnInsert`](#table-removeoninsert) | Unregister a previously-registered [`onInsert`](#table-oninsert) callback. | +| [`Table.onUpdate`](#table-onupdate) | Register an `onUpdate` callback for when an existing row is modified. | +| [`Table.removeOnUpdate`](#table-removeonupdate) | Unregister a previously-registered [`onUpdate`](#table-onupdate) callback. | +| [`Table.onDelete`](#table-ondelete) | Register an `onDelete` callback for when a subscribed row is removed from the database. | +| [`Table.removeOnDelete`](#table-removeondelete) | Unregister a previously-registered [`onDelete`](#table-removeondelete) callback. | ## Properties @@ -596,14 +608,17 @@ Return all the subscribed rows in the table. #### Example ```ts -var spacetimeDBClient = new SpacetimeDBClient('ws://localhost:3000', 'database_name'); +var spacetimeDBClient = new SpacetimeDBClient( + 'ws://localhost:3000', + 'database_name' +); spacetimeDBClient.onConnect((token, identity, address) => { - spacetimeDBClient.subscribe(['SELECT * FROM Person']); + spacetimeDBClient.subscribe(['SELECT * FROM Person']); - setTimeout(() => { - console.log(Person.all()); // Prints all the `Person` rows in the database. - }, 5000); + setTimeout(() => { + console.log(Person.all()); // Prints all the `Person` rows in the database. + }, 5000); }); ``` @@ -624,14 +639,17 @@ Return the number of subscribed rows in the table, or 0 if there is no active co #### Example ```ts -var spacetimeDBClient = new SpacetimeDBClient('ws://localhost:3000', 'database_name'); +var spacetimeDBClient = new SpacetimeDBClient( + 'ws://localhost:3000', + 'database_name' +); spacetimeDBClient.onConnect((token, identity, address) => { - spacetimeDBClient.subscribe(['SELECT * FROM Person']); + spacetimeDBClient.subscribe(['SELECT * FROM Person']); - setTimeout(() => { - console.log(Person.count()); - }, 5000); + setTimeout(() => { + console.log(Person.count()); + }, 5000); }); ``` @@ -660,14 +678,17 @@ These methods are named `filterBy{COLUMN}`, where `{COLUMN}` is the column name #### Example ```ts -var spacetimeDBClient = new SpacetimeDBClient('ws://localhost:3000', 'database_name'); +var spacetimeDBClient = new SpacetimeDBClient( + 'ws://localhost:3000', + 'database_name' +); spacetimeDBClient.onConnect((token, identity, address) => { - spacetimeDBClient.subscribe(['SELECT * FROM Person']); + spacetimeDBClient.subscribe(['SELECT * FROM Person']); - setTimeout(() => { - console.log(...Person.filterByName('John')); // prints all the `Person` rows named John. - }, 5000); + setTimeout(() => { + console.log(...Person.filterByName('John')); // prints all the `Person` rows named John. + }, 5000); }); ``` @@ -696,14 +717,17 @@ These methods are named `findBy{COLUMN}`, where `{COLUMN}` is the column name co #### Example ```ts -var spacetimeDBClient = new SpacetimeDBClient('ws://localhost:3000', 'database_name'); +var spacetimeDBClient = new SpacetimeDBClient( + 'ws://localhost:3000', + 'database_name' +); spacetimeDBClient.onConnect((token, identity, address) => { - spacetimeDBClient.subscribe(['SELECT * FROM Person']); + spacetimeDBClient.subscribe(['SELECT * FROM Person']); - setTimeout(() => { - console.log(Person.findById(0)); // prints a `Person` row with id 0. - }, 5000); + setTimeout(() => { + console.log(Person.findById(0)); // prints a `Person` row with id 0. + }, 5000); }); ``` @@ -762,17 +786,20 @@ Register an `onInsert` callback for when a subscribed row is newly inserted into #### Example ```ts -var spacetimeDBClient = new SpacetimeDBClient('ws://localhost:3000', 'database_name'); +var spacetimeDBClient = new SpacetimeDBClient( + 'ws://localhost:3000', + 'database_name' +); spacetimeDBClient.onConnect((token, identity, address) => { - spacetimeDBClient.subscribe(['SELECT * FROM Person']); + spacetimeDBClient.subscribe(['SELECT * FROM Person']); }); Person.onInsert((person, reducerEvent) => { - if (reducerEvent) { - console.log('New person inserted by reducer', reducerEvent, person); - } else { - console.log('New person received during subscription update', person); - } + if (reducerEvent) { + console.log('New person inserted by reducer', reducerEvent, person); + } else { + console.log('New person received during subscription update', person); + } }); ``` @@ -813,13 +840,16 @@ Register an `onUpdate` callback to run when an existing row is modified by prima #### Example ```ts -var spacetimeDBClient = new SpacetimeDBClient('ws://localhost:3000', 'database_name'); +var spacetimeDBClient = new SpacetimeDBClient( + 'ws://localhost:3000', + 'database_name' +); spacetimeDBClient.onConnect((token, identity, address) => { - spacetimeDBClient.subscribe(['SELECT * FROM Person']); + spacetimeDBClient.subscribe(['SELECT * FROM Person']); }); Person.onUpdate((oldPerson, newPerson, reducerEvent) => { - console.log('Person updated by reducer', reducerEvent, oldPerson, newPerson); + console.log('Person updated by reducer', reducerEvent, oldPerson, newPerson); }); ``` @@ -858,17 +888,23 @@ Register an `onDelete` callback for when a subscribed row is removed from the da #### Example ```ts -var spacetimeDBClient = new SpacetimeDBClient('ws://localhost:3000', 'database_name'); +var spacetimeDBClient = new SpacetimeDBClient( + 'ws://localhost:3000', + 'database_name' +); spacetimeDBClient.onConnect((token, identity, address) => { - spacetimeDBClient.subscribe(['SELECT * FROM Person']); + spacetimeDBClient.subscribe(['SELECT * FROM Person']); }); Person.onDelete((person, reducerEvent) => { - if (reducerEvent) { - console.log('Person deleted by reducer', reducerEvent, person); - } else { - console.log('Person no longer subscribed during subscription update', person); - } + if (reducerEvent) { + console.log('Person deleted by reducer', reducerEvent, person); + } else { + console.log( + 'Person no longer subscribed during subscription update', + person + ); + } }); ``` @@ -941,6 +977,6 @@ Clients will only be notified of reducer runs if either of two criteria is met: ```ts SayHelloReducer.on((reducerEvent, ...reducerArgs) => { - console.log('SayHelloReducer called', reducerEvent, reducerArgs); + console.log('SayHelloReducer called', reducerEvent, reducerArgs); }); ``` diff --git a/docs/sdks/typescript/quickstart.md b/docs/sdks/typescript/quickstart.md index 46b758ea..96725cbd 100644 --- a/docs/sdks/typescript/quickstart.md +++ b/docs/sdks/typescript/quickstart.md @@ -168,12 +168,16 @@ module_bindings We need to import these types into our `client/src/App.tsx`. While we are at it, we will also import the SpacetimeDBClient class from our SDK. In order to let the SDK know what tables and reducers we will be using we need to also register them. ```typescript -import { SpacetimeDBClient, Identity, Address } from "@clockworklabs/spacetimedb-sdk"; +import { + SpacetimeDBClient, + Identity, + Address, +} from '@clockworklabs/spacetimedb-sdk'; -import Message from "./module_bindings/message"; -import User from "./module_bindings/user"; -import SendMessageReducer from "./module_bindings/send_message_reducer"; -import SetNameReducer from "./module_bindings/set_name_reducer"; +import Message from './module_bindings/message'; +import User from './module_bindings/user'; +import SendMessageReducer from './module_bindings/send_message_reducer'; +import SetNameReducer from './module_bindings/set_name_reducer'; SpacetimeDBClient.registerReducers(SendMessageReducer, SetNameReducer); SpacetimeDBClient.registerTables(Message, User); @@ -190,10 +194,10 @@ Replace `` with the name you chose when publishing your module duri Add this before the `App` function declaration: ```typescript -let token = localStorage.getItem("auth_token") || undefined; +let token = localStorage.getItem('auth_token') || undefined; var spacetimeDBClient = new SpacetimeDBClient( - "ws://localhost:3000", - "chat", + 'ws://localhost:3000', + 'chat', token ); ``` @@ -241,13 +245,13 @@ To the body of `App`, add: ```typescript client.current.onConnect((token, identity, address) => { - console.log("Connected to SpacetimeDB"); + console.log('Connected to SpacetimeDB'); local_identity.current = identity; - localStorage.setItem("auth_token", token); + localStorage.setItem('auth_token', token); - client.current.subscribe(["SELECT * FROM User", "SELECT * FROM Message"]); + client.current.subscribe(['SELECT * FROM User', 'SELECT * FROM Message']); }); ``` @@ -269,7 +273,7 @@ To the body of `App`, add: function userNameOrIdentity(user: User): string { console.log(`Name: ${user.name} `); if (user.name !== null) { - return user.name || ""; + return user.name || ''; } else { var identityStr = new Identity(user.identity).toHexString(); console.log(`Name: ${identityStr} `); @@ -281,11 +285,11 @@ function setAllMessagesInOrder() { let messages = Array.from(Message.all()); messages.sort((a, b) => (a.sent > b.sent ? 1 : a.sent < b.sent ? -1 : 0)); - let messagesType: MessageType[] = messages.map((message) => { + let messagesType: MessageType[] = messages.map(message => { let sender_identity = User.findByIdentity(message.sender); let display_name = sender_identity ? userNameOrIdentity(sender_identity) - : "unknown"; + : 'unknown'; return { name: display_name, @@ -296,7 +300,7 @@ function setAllMessagesInOrder() { setMessages(messagesType); } -client.current.on("initialStateSync", () => { +client.current.on('initialStateSync', () => { setAllMessagesInOrder(); var user = User.findByIdentity(local_identity?.current?.toUint8Array()!); setName(userNameOrIdentity(user!)); @@ -337,7 +341,7 @@ To the body of `App`, add: ```typescript // Helper function to append a line to the systemMessage state function appendToSystemMessage(line: String) { - setSystemMessage((prevMessage) => prevMessage + "\n" + line); + setSystemMessage(prevMessage => prevMessage + '\n' + line); } User.onInsert((user, reducerEvent) => { @@ -416,9 +420,9 @@ SetNameReducer.on((reducerEvent, newName) => { local_identity.current && reducerEvent.callerIdentity.isEqual(local_identity.current) ) { - if (reducerEvent.status === "failed") { + if (reducerEvent.status === 'failed') { appendToSystemMessage(`Error setting name: ${reducerEvent.message} `); - } else if (reducerEvent.status === "committed") { + } else if (reducerEvent.status === 'committed') { setName(newName); } } @@ -437,7 +441,7 @@ SendMessageReducer.on((reducerEvent, newMessage) => { local_identity.current && reducerEvent.callerIdentity.isEqual(local_identity.current) ) { - if (reducerEvent.status === "failed") { + if (reducerEvent.status === 'failed') { appendToSystemMessage(`Error sending message: ${reducerEvent.message} `); } } diff --git a/docs/unity/part-1.md b/docs/unity/part-1.md index 14eb2405..8e0a49e3 100644 --- a/docs/unity/part-1.md +++ b/docs/unity/part-1.md @@ -119,5 +119,5 @@ We chose ECS for this example project because it promotes scalability, modularit From here, the tutorial continues with your favorite server module language of choice: - - [Rust](part-2a-rust.md) - - [C#](part-2b-csharp.md) +- [Rust](part-2a-rust.md) +- [C#](part-2b-csharp.md) diff --git a/docs/unity/part-2b-c-sharp.md b/docs/unity/part-2b-c-sharp.md index 5be1c7cb..fa02d866 100644 --- a/docs/unity/part-2b-c-sharp.md +++ b/docs/unity/part-2b-c-sharp.md @@ -327,6 +327,7 @@ public static void SendChatMessage(ReducerContext ctx, string text) ## Wrapping Up ### Publishing a Module to SpacetimeDB + 💡View the [entire lib.cs file](https://gist.github.com/dylanh724/68067b4e843ea6e99fbd297fe1a87c49) Now that we've written the code for our server module and reached a clean checkpoint, we need to publish it to SpacetimeDB. This will create the database and call the init reducer. In your terminal or command window, run the following commands. diff --git a/docs/unity/part-4.md b/docs/unity/part-4.md index d7c22280..029fbe13 100644 --- a/docs/unity/part-4.md +++ b/docs/unity/part-4.md @@ -162,7 +162,6 @@ pub fn resource_spawner_agent(_ctx: ReducerContext, _arg: ResourceSpawnAgentSche } ``` - 2. Since this reducer uses `rand::Rng` we need add include it. Add this `use` statement to the top of lib.rs. ```rust @@ -179,6 +178,7 @@ use rand::Rng; scheduled_at: duration!(1000ms).into() }).expect(); ``` + struct ResouceSpawnAgentSchedueler { 4. Next we need to generate our client code and publish the module. Since we changed the schema we need to make sure we include the `--clear-database` flag. Run the following commands from your Server directory: diff --git a/docs/ws/index.md b/docs/ws/index.md index b00bfa56..587fbad0 100644 --- a/docs/ws/index.md +++ b/docs/ws/index.md @@ -188,7 +188,7 @@ Each `SubscriptionUpdate` contains a `TableUpdate` for each table with subscribe | `tableRowOperations` | A `TableRowOperation` for each inserted or deleted row. | | `TableRowOperation` field | Value | -|---------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `op` | `INSERT` for inserted rows during a [`TransactionUpdate`](#transactionupdate) or rows resident upon applying a subscription; `DELETE` for deleted rows during a [`TransactionUpdate`](#transactionupdate). | | `row` | The altered row, encoded as a BSATN `ProductValue`. | @@ -225,7 +225,7 @@ Each `SubscriptionUpdate` contains a `TableUpdate` for each table with subscribe | `table_row_operations` | A `TableRowOperation` for each inserted or deleted row. | | `TableRowOperation` field | Value | -|---------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `op` | `"insert"` for inserted rows during a [`TransactionUpdate`](#transactionupdate) or rows resident upon applying a subscription; `"delete"` for deleted rows during a [`TransactionUpdate`](#transactionupdate). | | `row` | The altered row, encoded as a JSON array. | diff --git a/nav.ts b/nav.ts index 8b21cc91..19e69c76 100644 --- a/nav.ts +++ b/nav.ts @@ -3,7 +3,7 @@ type Nav = { }; type NavItem = NavPage | NavSection; type NavPage = { - type: "page"; + type: 'page'; path: string; slug: string; title: string; @@ -11,71 +11,96 @@ type NavPage = { href?: string; }; type NavSection = { - type: "section"; + type: 'section'; title: string; }; -function page(title: string, slug: string, path: string, props?: { disabled?: boolean; href?: string; description?: string }): NavPage { - return { type: "page", path, slug, title, ...props }; +function page( + title: string, + slug: string, + path: string, + props?: { disabled?: boolean; href?: string; description?: string } +): NavPage { + return { type: 'page', path, slug, title, ...props }; } function section(title: string): NavSection { - return { type: "section", title }; + return { type: 'section', title }; } const nav: Nav = { items: [ - section("Intro"), - page("Overview", "index", "index.md"), // TODO(BREAKING): For consistency & clarity, 'index' slug should be renamed 'intro'? - page("Getting Started", "getting-started", "getting-started.md"), + section('Intro'), + page('Overview', 'index', 'index.md'), // TODO(BREAKING): For consistency & clarity, 'index' slug should be renamed 'intro'? + page('Getting Started', 'getting-started', 'getting-started.md'), - section("Deploying"), - page("Testnet", "deploying/testnet", "deploying/testnet.md"), + section('Deploying'), + page('Testnet', 'deploying/testnet', 'deploying/testnet.md'), - section("Unity Tutorial - Basic Multiplayer"), - page("Overview", "unity-tutorial", "unity/index.md"), - page("1 - Setup", "unity/part-1", "unity/part-1.md"), - page("2a - Server (Rust)", "unity/part-2a-rust", "unity/part-2a-rust.md"), - page("2b - Server (C#)", "unity/part-2b-c-sharp", "unity/part-2b-c-sharp.md"), - page("3 - Client", "unity/part-3", "unity/part-3.md"), + section('Unity Tutorial - Basic Multiplayer'), + page('Overview', 'unity-tutorial', 'unity/index.md'), + page('1 - Setup', 'unity/part-1', 'unity/part-1.md'), + page('2a - Server (Rust)', 'unity/part-2a-rust', 'unity/part-2a-rust.md'), + page( + '2b - Server (C#)', + 'unity/part-2b-c-sharp', + 'unity/part-2b-c-sharp.md' + ), + page('3 - Client', 'unity/part-3', 'unity/part-3.md'), - section("Unity Tutorial - Advanced"), - page("4 - Resources And Scheduling", "unity/part-4", "unity/part-4.md"), - page("5 - BitCraft Mini", "unity/part-5", "unity/part-5.md"), + section('Unity Tutorial - Advanced'), + page('4 - Resources And Scheduling', 'unity/part-4', 'unity/part-4.md'), + page('5 - BitCraft Mini', 'unity/part-5', 'unity/part-5.md'), - section("Server Module Languages"), - page("Overview", "modules", "modules/index.md"), - page("Rust Quickstart", "modules/rust/quickstart", "modules/rust/quickstart.md"), - page("Rust Reference", "modules/rust", "modules/rust/index.md"), - page("C# Quickstart", "modules/c-sharp/quickstart", "modules/c-sharp/quickstart.md"), - page("C# Reference", "modules/c-sharp", "modules/c-sharp/index.md"), + section('Server Module Languages'), + page('Overview', 'modules', 'modules/index.md'), + page( + 'Rust Quickstart', + 'modules/rust/quickstart', + 'modules/rust/quickstart.md' + ), + page('Rust Reference', 'modules/rust', 'modules/rust/index.md'), + page( + 'C# Quickstart', + 'modules/c-sharp/quickstart', + 'modules/c-sharp/quickstart.md' + ), + page('C# Reference', 'modules/c-sharp', 'modules/c-sharp/index.md'), - section("Client SDK Languages"), - page("Overview", "sdks", "sdks/index.md"), - page("Typescript Quickstart", "sdks/typescript/quickstart", "sdks/typescript/quickstart.md"), - page("Typescript Reference", "sdks/typescript", "sdks/typescript/index.md"), - page("Rust Quickstart", "sdks/rust/quickstart", "sdks/rust/quickstart.md"), - page("Rust Reference", "sdks/rust", "sdks/rust/index.md"), - page("C# Quickstart", "sdks/c-sharp/quickstart", "sdks/c-sharp/quickstart.md"), - page("C# Reference", "sdks/c-sharp", "sdks/c-sharp/index.md"), + section('Client SDK Languages'), + page('Overview', 'sdks', 'sdks/index.md'), + page( + 'Typescript Quickstart', + 'sdks/typescript/quickstart', + 'sdks/typescript/quickstart.md' + ), + page('Typescript Reference', 'sdks/typescript', 'sdks/typescript/index.md'), + page('Rust Quickstart', 'sdks/rust/quickstart', 'sdks/rust/quickstart.md'), + page('Rust Reference', 'sdks/rust', 'sdks/rust/index.md'), + page( + 'C# Quickstart', + 'sdks/c-sharp/quickstart', + 'sdks/c-sharp/quickstart.md' + ), + page('C# Reference', 'sdks/c-sharp', 'sdks/c-sharp/index.md'), - section("WebAssembly ABI"), - page("Module ABI Reference", "webassembly-abi", "webassembly-abi/index.md"), + section('WebAssembly ABI'), + page('Module ABI Reference', 'webassembly-abi', 'webassembly-abi/index.md'), - section("HTTP API"), - page("HTTP", "http", "http/index.md"), - page("`/identity`", "http/identity", "http/identity.md"), - page("`/database`", "http/database", "http/database.md"), - page("`/energy`", "http/energy", "http/energy.md"), + section('HTTP API'), + page('HTTP', 'http', 'http/index.md'), + page('`/identity`', 'http/identity', 'http/identity.md'), + page('`/database`', 'http/database', 'http/database.md'), + page('`/energy`', 'http/energy', 'http/energy.md'), - section("WebSocket API Reference"), - page("WebSocket", "ws", "ws/index.md"), + section('WebSocket API Reference'), + page('WebSocket', 'ws', 'ws/index.md'), - section("Data Format"), - page("SATN", "satn", "satn.md"), - page("BSATN", "bsatn", "bsatn.md"), + section('Data Format'), + page('SATN', 'satn', 'satn.md'), + page('BSATN', 'bsatn', 'bsatn.md'), - section("SQL"), - page("SQL Reference", "sql", "sql/index.md"), + section('SQL'), + page('SQL Reference', 'sql', 'sql/index.md'), ], }; diff --git a/package.json b/package.json index a56ea4e8..2c2b9445 100644 --- a/package.json +++ b/package.json @@ -12,4 +12,4 @@ }, "author": "Clockwork Labs", "license": "ISC" -} \ No newline at end of file +} From 5dce9de00656aae13581062656d50d2dbf286ac8 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Tue, 24 Sep 2024 11:33:31 -0400 Subject: [PATCH 063/164] Update quickstart.md (#84) --- docs/modules/c-sharp/quickstart.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/modules/c-sharp/quickstart.md b/docs/modules/c-sharp/quickstart.md index 768602e4..5d8c873d 100644 --- a/docs/modules/c-sharp/quickstart.md +++ b/docs/modules/c-sharp/quickstart.md @@ -281,10 +281,10 @@ npm i wasm-opt -g You can use the CLI (command line interface) to run reducers. The arguments to the reducer are passed in JSON format. ```bash -spacetime call send_message "Hello, World!" +spacetime call SendMessage "Hello, World!" ``` -Once we've called our `send_message` reducer, we can check to make sure it ran by running the `logs` command. +Once we've called our `SendMessage` reducer, we can check to make sure it ran by running the `logs` command. ```bash spacetime logs From 0c0aa6dff110bc327165493b30ae335fcf7eea29 Mon Sep 17 00:00:00 2001 From: Egor Gavrilov Date: Tue, 24 Sep 2024 23:34:20 +0800 Subject: [PATCH 064/164] Fix typo in modules/rust/index.md (#83) Person -> Unique (because that belongs to `Unique` table) --- docs/modules/rust/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/modules/rust/index.md b/docs/modules/rust/index.md index 55ceec18..28be1c83 100644 --- a/docs/modules/rust/index.md +++ b/docs/modules/rust/index.md @@ -314,7 +314,7 @@ struct Ordinary { } ``` -This table has a unique column. Every row in the `Person` table must have distinct values of the `unique_field` column. Attempting to insert a row with a duplicate value will fail. +This table has a unique column. Every row in the `Unique` table must have distinct values of the `unique_field` column. Attempting to insert a row with a duplicate value will fail. ```rust #[spacetimedb(table(public))] From 3385ec789a09b953d25827e14754c3624562f18d Mon Sep 17 00:00:00 2001 From: Arrel Neumiller Date: Tue, 24 Sep 2024 16:35:13 +0100 Subject: [PATCH 065/164] Update part-2b-c-sharp.md (#75) The intent is to throw an exception if the player already exists, not the other way 'round. --- docs/unity/part-2b-c-sharp.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/docs/unity/part-2b-c-sharp.md b/docs/unity/part-2b-c-sharp.md index fa02d866..b1d50e8b 100644 --- a/docs/unity/part-2b-c-sharp.md +++ b/docs/unity/part-2b-c-sharp.md @@ -113,12 +113,11 @@ public static void CreatePlayer(ReducerContext ctx, string username) // Get the Identity of the client who called this reducer Identity sender = ctx.Sender; - // Make sure we don't already have a player with this identity - PlayerComponent? user = PlayerComponent.FindByIdentity(sender); - if (user is null) - { - throw new ArgumentException("Player already exists"); - } + PlayerComponent? existingPlayer = PlayerComponent.FindByIdentity(sender); + if (existingPlayer != null) + { + throw new InvalidOperationException($"Player already exists for identity: {sender}"); + } // Create a new entity for this player try From 082bfcb66eba4e3c0d6201e4ee2f80064ccd9fef Mon Sep 17 00:00:00 2001 From: Muthsera Date: Tue, 24 Sep 2024 17:54:25 +0200 Subject: [PATCH 066/164] Fixed code examples in rust reference regarding insertion (#42) --- docs/modules/rust/index.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/modules/rust/index.md b/docs/modules/rust/index.md index 28be1c83..443f8171 100644 --- a/docs/modules/rust/index.md +++ b/docs/modules/rust/index.md @@ -372,10 +372,10 @@ If we insert two rows which have the same value of a unique column, the second w ```rust #[spacetimedb(reducer)] fn insert_unique(value: u64) { - let result = Ordinary::insert(Unique { unique_field: value }); + let result = Unique::insert(Unique { unique_field: value }); assert!(result.is_ok()); - let result = Ordinary::insert(Unique { unique_field: value }); + let result = Unique::insert(Unique { unique_field: value }); assert!(result.is_err()); } ``` @@ -404,7 +404,7 @@ fn insert_id() { // There's no collision and silent failure to insert, // because the value of the field is ignored and overwritten // with the automatically incremented value. - Identity::insert(Identity { autoinc_field: 23 }) + Identity::insert(Identity { id_field: 23 }) } } ``` From 2533e566fc8fb35e31eacdcde403046c172cbc02 Mon Sep 17 00:00:00 2001 From: John Detter <4099508+jdetter@users.noreply.github.com> Date: Wed, 2 Oct 2024 10:59:09 -0500 Subject: [PATCH 067/164] Rust client quickstart updated for 0.12 (#92) * Rust client updated for 0.12 * Small update * More updates * Final pass --------- Co-authored-by: John Detter --- docs/sdks/rust/quickstart.md | 299 +++++++++++++++++------------------ 1 file changed, 148 insertions(+), 151 deletions(-) diff --git a/docs/sdks/rust/quickstart.md b/docs/sdks/rust/quickstart.md index af07e403..9cea42c3 100644 --- a/docs/sdks/rust/quickstart.md +++ b/docs/sdks/rust/quickstart.md @@ -28,7 +28,7 @@ cargo new client Below the `[dependencies]` line in `client/Cargo.toml`, add: ```toml -spacetimedb-sdk = "0.7" +spacetimedb-sdk = "0.12" hex = "0.4" ``` @@ -56,18 +56,20 @@ mkdir -p client/src/module_bindings spacetime generate --lang rust --out-dir client/src/module_bindings --project-path server ``` -Take a look inside `client/src/module_bindings`. The CLI should have generated five files: +Take a look inside `client/src/module_bindings`. The CLI should have generated a few files: ``` module_bindings -├── message.rs +├── message_table.rs +├── message_type.rs ├── mod.rs ├── send_message_reducer.rs ├── set_name_reducer.rs -└── user.rs +├── user_table.rs +└── user_type.rs ``` -We need to declare the module in our client crate, and we'll want to import its definitions. +To use these, we'll declare the module in our client crate and import its definitions. To `client/src/main.rs`, add: @@ -78,123 +80,133 @@ use module_bindings::*; ## Add more imports -We'll need a whole boatload of imports from `spacetimedb_sdk`, which we'll describe when we use them. +We'll need additional imports from `spacetimedb_sdk` for interacting with the database, handling credentials, and managing events. To `client/src/main.rs`, add: ```rust -use spacetimedb_sdk::{ - Address, - disconnect, - identity::{load_credentials, once_on_connect, save_credentials, Credentials, Identity}, - on_disconnect, on_subscription_applied, - reducer::Status, - subscribe, - table::{TableType, TableWithPrimaryKey}, -}; +use spacetimedb_sdk::{anyhow, DbContext, Event, Identity, Status, Table, TableWithPrimaryKey}; +use spacetimedb_sdk::credentials::File; ``` -## Define main function +## Define the main function -We'll work outside-in, first defining our `main` function at a high level, then implementing each behavior it needs. We need `main` to do five things: +Our `main` function will do the following: +1. Connect to the database. This will also start a new thread for handling network messages. +2. Handle user input from the command line. -1. Register callbacks on any events we want to handle. These will print to standard output messages received from the database and updates about users' names and online statuses. -2. Establish a connection to the database. This will involve authenticating with our credentials, if we're a returning user. -3. Subscribe to receive updates on tables. -4. Loop, processing user input from standard input. This will be how we enable users to set their names and send messages. -5. Close our connection. This one is easy; we just call `spacetimedb_sdk::disconnect`. - -To `client/src/main.rs`, add: +We'll see the implementation of these functions a bit later, but for now add to `client/src/main.rs`: ```rust fn main() { - register_callbacks(); - connect_to_db(); - subscribe_to_tables(); - user_input_loop(); + // Connect to the database + let conn = connect_to_db(); + // Handle CLI input + user_input_loop(&conn); } ``` + ## Register callbacks We need to handle several sorts of events: 1. When we connect and receive our credentials, we'll save them to a file so that the next time we connect, we can re-authenticate as the same user. -2. When a new user joins, we'll print a message introducing them. -3. When a user is updated, we'll print their new name, or declare their new online status. -4. When we receive a new message, we'll print it. -5. When we're informed of the backlog of past messages, we'll sort them and print them in order. -6. If the server rejects our attempt to set our name, we'll print an error. -7. If the server rejects a message we send, we'll print an error. +2. When a new user joins, we'll print a message introducing them. +3. When a user is updated, we'll print their new name, or declare their new online status. +4. When we receive a new message, we'll print it. +5. When we're informed of the backlog of past messages, we'll sort them and print them in order. +6. If the server rejects our attempt to set our name, we'll print an error. +7. If the server rejects a message we send, we'll print an error. 8. When our connection ends, we'll print a note, then exit the process. To `client/src/main.rs`, add: ```rust /// 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); - +fn register_callbacks(conn: &DbConnection) { // When a new user joins, print a notification. - User::on_insert(on_user_inserted); + conn.db.user().on_insert(on_user_inserted); // When a user's status changes, print a notification. - User::on_update(on_user_updated); + conn.db.user().on_update(on_user_updated); // When a new message is received, print it. - Message::on_insert(on_message_inserted); + conn.db.message().on_insert(on_message_inserted); // When we receive the message backlog, print it in timestamp order. - on_subscription_applied(on_sub_applied); + conn.subscription_builder().on_applied(on_sub_applied); // When we fail to set our name, print a warning. - on_set_name(on_name_set); + conn.reducers.on_set_name(on_name_set); // When we fail to send a message, print a warning. - on_send_message(on_message_sent); - - // When our connection closes, inform the user and exit. - on_disconnect(on_disconnected); + conn.reducers.on_send_message(on_message_sent); } ``` -### Save credentials +## Save credentials -Each user has a `Credentials`, which consists of two parts: +Each user has a `Credentials`, which consists of two parts: - An `Identity`, a unique public identifier. We're using these to identify `User` rows. -- A `Token`, a private key which SpacetimeDB uses to authenticate the client. - -`Credentials` are generated by SpacetimeDB each time a new client connects, and sent to the client so they can be saved, in order to re-connect with the same identity. The Rust SDK provides a pair of functions, `save_credentials` and `load_credentials`, for storing these credentials in a file. We'll save our credentials into a file in the directory `~/.spacetime_chat`, which should be unintrusive. If saving our credentials fails, we'll print a message to standard error, but otherwise continue normally; even though the user won't be able to reconnect with the same identity, they can still chat normally. +- A `Token`, a private key which SpacetimeDB uses to authenticate the client. -Each client also has an `Address`, which modules can use to distinguish multiple concurrent connections by the same `Identity`. We don't need to know our `Address`, so we'll ignore that argument. +`Credentials` are generated by SpacetimeDB each time a new client connects, and sent to the client so they can be saved, in order to re-connect with the same identity. The Rust SDK provides a pair of functions in `File`, `save` and `load`, for saving and storing these credentials in a file. By default the `save` and `load` will look for credentials in the `$HOME/.spacetimedb_client_credentials/` directory, which should be unintrusive. If saving our credentials fails, we'll print a message to standard error, but otherwise continue normally; even though the user won't be able to reconnect with the same identity, they can still chat normally. To `client/src/main.rs`, add: ```rust /// Our `on_connect` callback: save our credentials to a file. -fn on_connected(creds: &Credentials, _client_address: Address) { - if let Err(e) = save_credentials(CREDS_DIR, creds) { +fn on_connected(conn: &DbConnection, ident: Identity, token: &str) { + let file = File::new(CREDS_NAME); + if let Err(e) = file.save(ident, token) { eprintln!("Failed to save credentials: {:?}", e); } + + println!("Connected to SpacetimeDB."); + println!("Use /name to set your username, otherwise enter your message!"); + + // Subscribe to the data we care about + subscribe_to_tables(&conn); + // Register callbacks for reducers + register_callbacks(&conn); +} +``` + +You can see here that when we connect we're going to register our callbacks, which we defined above. + +## Handle errors and disconnections + +We need to handle connection errors and disconnections by printing appropriate messages and exiting the program. + +To `client/src/main.rs`, add: + +```rust +/// Our `on_connect_error` callback: print the error, then exit the process. +fn on_connect_error(err: &anyhow::Error) { + eprintln!("Connection error: {:?}", err); } -const CREDS_DIR: &str = ".spacetime_chat"; +/// Our `on_disconnect` callback: print a note, then exit the process. +fn on_disconnected(_conn: &DbConnection, _err: Option<&anyhow::Error>) { + eprintln!("Disconnected!"); + std::process::exit(0) +} ``` -### Notify about new users +## Notify about new users -For each table, we can register on-insert and on-delete callbacks to be run whenever a subscribed row is inserted or deleted. We register these callbacks using the `on_insert` and `on_delete` methods of the trait `TableType`, which is automatically implemented for each table by `spacetime generate`. +For each table, we can register on-insert and on-delete callbacks to be run whenever a subscribed row is inserted or deleted. We register these callbacks using the `on_insert` and `on_delete`, which is automatically implemented for each table by `spacetime generate`. These callbacks can fire in two contexts: - After a reducer runs, when the client's cache is updated about changes to subscribed rows. - After calling `subscribe`, when the client's cache is initialized with all existing matching rows. -This second case means that, even though the module only ever inserts online users, the client's `User::on_insert` callbacks may be invoked with users who are offline. We'll only notify about online users. +This second case means that, even though the module only ever inserts online users, the client's `conn.db.user().on_insert(..)` callbacks may be invoked with users who are offline. We'll only notify about online users. -`on_insert` and `on_delete` callbacks take two arguments: the altered row, and an `Option<&ReducerEvent>`. This will be `Some` for rows altered by a reducer run, and `None` for rows inserted when initializing the cache for a subscription. `ReducerEvent` is an enum autogenerated by `spacetime generate` with a variant for each reducer defined by the module. For now, we can ignore this argument. +`on_insert` and `on_delete` callbacks take two arguments: `&EventContext` and the row data (in the case of insert it's a new row and in the case of delete it's the row that was deleted). You can determine whether the insert/delete operation was caused by a reducer or subscription update by checking the type of `ctx.event`. If `ctx.event` is a `Event::Reducer` then the row was changed by a reducer call, otherwise it was modified by a subscription update. `Reducer` is an enum autogenerated by `spacetime generate` with a variant for each reducer defined by the module. For now, we can ignore this argument. Whenever we want to print a user, if they have set a name, we'll use that. If they haven't set a name, we'll instead print the first 8 bytes of their identity, encoded as hexadecimal. We'll define functions `user_name_or_identity` and `identity_leading_hex` to handle this. @@ -203,7 +215,7 @@ To `client/src/main.rs`, add: ```rust /// Our `User::on_insert` callback: /// if the user is online, print a notification. -fn on_user_inserted(user: &User, _: Option<&ReducerEvent>) { +fn on_user_inserted(_ctx: &EventContext, user: &User) { if user.online { println!("User {} connected.", user_name_or_identity(user)); } @@ -212,17 +224,13 @@ fn on_user_inserted(user: &User, _: Option<&ReducerEvent>) { 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]) + .unwrap_or_else(|| user.identity.to_hex().to_string()) } ``` ### Notify about updated users -Because we declared a `#[primarykey]` column in our `User` table, we can also register on-update callbacks. These run whenever a row is replaced by a row with the same primary key, like our module's `User::update_by_identity` calls. We register these callbacks using the `on_update` method of the trait `TableWithPrimaryKey`, which is automatically implemented by `spacetime generate` for any table with a `#[primarykey]` column. +Because we declared a `#[primary_key]` column in our `User` table, we can also register on-update callbacks. These run whenever a row is replaced by a row with the same primary key, like our module's `ctx.db.user().identity().update(..) calls. We register these callbacks using the `on_update` method of the trait `TableWithPrimaryKey`, which is automatically implemented by `spacetime generate` for any table with a `#[primary_key]` column. `on_update` callbacks take three arguments: the old row, the new row, and an `Option<&ReducerEvent>`. @@ -256,119 +264,96 @@ fn on_user_updated(old: &User, new: &User, _: Option<&ReducerEvent>) { } ``` -### Print messages +## Print messages -When we receive a new message, we'll print it to standard output, along with the name of the user who sent it. Keep in mind that we only want to do this for new messages, i.e. those inserted by a `send_message` reducer invocation. We have to handle the backlog we receive when our subscription is initialized separately, to ensure they're printed in the correct order. To that effect, our `print_new_message` callback will check if its `reducer_event` argument is `Some`, and only print in that case. +When we receive a new message, we'll print it to standard output, along with the name of the user who sent it. Keep in mind that we only want to do this for new messages, i.e. those inserted by a `send_message` reducer invocation. We have to handle the backlog we receive when our subscription is initialized separately, to ensure they're printed in the correct order. To that effect, our `on_message_inserted` callback will check if the ctx.event type is an `Event::Reducer`, and only print in that case. -To find the `User` based on the message's `sender` identity, we'll use `User::find_by_identity`, which behaves like the same function on the server. The key difference is that, unlike on the module side, the client's `find_by_identity` accepts an owned `Identity`, rather than a reference. We can `clone` the identity held in `message.sender`. +To find the `User` based on the message's `sender` identity, we'll use `ctx.db.user().identity().find(..)`, which behaves like the same function on the server. We'll print the user's name or identity in the same way as we did when notifying about `User` table events, but here we have to handle the case where we don't find a matching `User` row. This can happen when the module owner sends a message using the CLI's `spacetime call`. In this case, we'll print `unknown`. +We'll handle message-related events, such as receiving new messages or loading past messages. + To `client/src/main.rs`, add: ```rust /// 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 on_message_inserted(ctx: &EventContext, message: &Message) { + if let Event::Reducer(_) = ctx.event { + print_message(ctx, message) } } -fn print_message(message: &Message) { - let sender = User::find_by_identity(message.sender.clone()) +fn print_message(ctx: &EventContext, message: &Message) { + let sender = ctx.db.user().identity().find(&message.sender.clone()) .map(|u| user_name_or_identity(&u)) .unwrap_or_else(|| "unknown".to_string()); println!("{}: {}", sender, message.text); } -``` -### Print past messages in order +### Print past messages in order + +Messages we receive live will come in order, but when we connect, we'll receive all the past messages at once. We can't just print these in the order we receive them; the logs would be all shuffled around, and would make no sense. Instead, when we receive the log of past messages, we'll sort them by their sent timestamps and print them in order. -Messages we receive live will come in order, but when we connect, we'll receive all the past messages at once. We can't just print these in the order we receive them; the logs would be all shuffled around, and would make no sense. Instead, when we receive the log of past messages, we'll sort them by their sent timestamps and print them in order. -We'll handle this in our function `print_messages_in_order`, which we registered as an `on_subscription_applied` callback. `print_messages_in_order` iterates over all the `Message`s we've received, sorts them, and then prints them. `Message::iter()` is defined on the trait `TableType`, and returns an iterator over all the messages in the client's cache. Rust iterators can't be sorted in-place, so we'll collect it to a `Vec`, then use the `sort_by_key` method to sort by timestamp. +We'll handle this in our function `print_messages_in_order`, which we registered as an `on_subscription_applied` callback. `print_messages_in_order` iterates over all the `Message`s we've received, sorts them, and then prints them. `Message::iter()` is defined on the trait `TableType`, and returns an iterator over all the messages in the client's cache. Rust iterators can't be sorted in-place, so we'll collect it to a `Vec`, then use the `sort_by_key` method to sort by timestamp. To `client/src/main.rs`, add: ```rust /// 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::>(); +fn on_sub_applied(ctx: &EventContext) { + let mut messages = ctx.db.message().iter().collect::>(); messages.sort_by_key(|m| m.sent); for message in messages { - print_message(&message); + print_message(ctx, &message); } } ``` -### Warn if our name was rejected +## Handle reducer failures -We can also register callbacks to run each time a reducer is invoked. We register these callbacks using the `on_reducer` method of the `Reducer` trait, which is automatically implemented for each reducer by `spacetime generate`. +We can also register callbacks to run each time a reducer is invoked. We register these callbacks using the `on_reducer` method of the `Reducer` trait, which is automatically implemented for each reducer by `spacetime generate`. -Each reducer callback takes at least three arguments: - -1. The `Identity` of the client who requested the reducer invocation. -2. The `Address` of the client who requested the reducer invocation, which may be `None` for scheduled reducers. -3. The `Status` of the reducer run, one of `Committed`, `Failed` or `OutOfEnergy`. `Status::Failed` holds the error which caused the reducer to fail, as a `String`. - -In addition, it takes a reference to each of the arguments passed to the reducer itself. +Each reducer callback first takes an `&EventContext` which contains all of the information from the reducer call including the reducer arguments, the identity of the caller, and whether or not the reducer call suceeded. These callbacks will be invoked in one of two cases: 1. If the reducer was successful and altered any of our subscribed rows. -2. If we requested an invocation which failed. +2. If we requested an invocation which failed. -Note that a status of `Failed` or `OutOfEnergy` implies that the caller identity is our own identity. +Note that a status of `Failed` or `OutOfEnergy` implies that the caller identity is our own identity. + +We already handle successful `set_name` invocations using our `ctx.db.user().on_update(..)` callback, but if the module rejects a user's chosen name, we'd like that user's client to let them know. We define a function `on_set_name` as a `conn.reducers.on_set_name(..)` callback which checks if the reducer failed, and if it did, prints a message including the rejected name and the error. -We already handle successful `set_name` invocations using our `User::on_update` callback, but if the module rejects a user's chosen name, we'd like that user's client to let them know. We define a function `warn_if_name_rejected` as a `SetNameArgs::on_reducer` callback which checks if the reducer failed, and if it did, prints a message including the rejected name and the error. To `client/src/main.rs`, add: ```rust /// Our `on_set_name` callback: print a warning if the reducer failed. -fn on_name_set(_sender_id: &Identity, _sender_address: Option
, status: &Status, name: &String) { - if let Status::Failed(err) = status { - eprintln!("Failed to change name to {:?}: {}", name, err); +fn on_name_set(ctx: &EventContext, name: &String) { + if let Event::Reducer(reducer) = &ctx.event { + if let Status::Failed(err) = reducer.status.clone() { + eprintln!("Failed to change name to {:?}: {}", name, err); + } } } -``` - -### Warn if our message was rejected - -We handle warnings on rejected messages the same way as rejected names, though the types and the error message are different. -To `client/src/main.rs`, add: - -```rust /// Our `on_send_message` callback: print a warning if the reducer failed. -fn on_message_sent(_sender_id: &Identity, _sender_address: Option
, status: &Status, text: &String) { - if let Status::Failed(err) = status { - eprintln!("Failed to send message {:?}: {}", text, err); +fn on_message_sent(ctx: &EventContext, text: &String) { + if let Event::Reducer(reducer) = &ctx.event { + if let Status::Failed(err) = reducer.status.clone() { + eprintln!("Failed to send message {:?}: {}", text, err); + } } } ``` -### Exit on disconnect - -We can register callbacks to run when our connection ends using `on_disconnect`. These callbacks will run either when the client disconnects by calling `disconnect`, or when the server closes our connection. More involved apps might attempt to reconnect in this case, or do some sort of client-side cleanup, but we'll just print a note to the user and then exit the process. - -To `client/src/main.rs`, add: - -```rust -/// Our `on_disconnect` callback: print a note, then exit the process. -fn on_disconnected() { - eprintln!("Disconnected!"); - std::process::exit(0) -} -``` - ## Connect to the database Now that our callbacks are all set up, we can connect to the database. We'll store the URI of the SpacetimeDB instance and our module name in constants `SPACETIMEDB_URI` and `DB_NAME`. Replace `` with the name you chose when publishing your module during the module quickstart. -`connect` takes an `Option`, which is `None` for a new connection, or `Some` for a returning user. The Rust SDK defines `load_credentials`, the counterpart to the `save_credentials` we used in our `save_credentials_or_log_error`, to load `Credentials` from a file. `load_credentials` returns `Result>`, with `Ok(None)` meaning the credentials haven't been saved yet, and an `Err` meaning reading from disk failed. We can `expect` to handle the `Result`, and pass the `Option` directly to `connect`. - To `client/src/main.rs`, add: ```rust @@ -378,14 +363,22 @@ const SPACETIMEDB_URI: &str = "http://localhost:3000"; /// The module name we chose when we published our module. const DB_NAME: &str = ""; +/// You should change this value to a unique name based on your application. +const CREDS_NAME: &str = "rust-sdk-quickstart"; + /// Load credentials from a file and connect to the database. -fn connect_to_db() { - connect( - SPACETIMEDB_URI, - DB_NAME, - load_credentials(CREDS_DIR).expect("Error reading stored credentials"), - ) - .expect("Failed to connect"); +fn connect_to_db() -> DbConnection { + let credentials = File::new(CREDS_NAME); + let conn = DbConnection::builder() + .on_connect(on_connected) + .on_connect_error(on_connect_error) + .on_disconnect(on_disconnected) + .with_uri(SPACETIMEDB_URI) + .with_module_name(DB_NAME) + .with_credentials(credentials.load().unwrap()) + .build().expect("Failed to connect"); + conn.run_threaded(); + conn } ``` @@ -397,30 +390,33 @@ To `client/src/main.rs`, add: ```rust /// Register subscriptions for all rows of both tables. -fn subscribe_to_tables() { - subscribe(&["SELECT * FROM User;", "SELECT * FROM Message;"]).unwrap(); +fn subscribe_to_tables(conn: &DbConnection) { + conn.subscription_builder().subscribe([ + "SELECT * FROM user;", + "SELECT * FROM message;", + ]); } ``` ## Handle user input -A user should interact with our client by typing lines into their terminal. A line that starts with `/name ` will set the user's name to the rest of the line. Any other line will send a message. +Our app should allow the user to interact by typing lines into their terminal. If the line starts with `/name `, we'll change the user's name. Any other line will send a message. -`spacetime generate` defined two functions for us, `set_name` and `send_message`, which send a message to the database to invoke the corresponding reducer. The first argument, the `ReducerContext`, is supplied by the server, but we pass all other arguments ourselves. In our case, that means that both `set_name` and `send_message` take one argument, a `String`. +The functions `set_name` and `send_message` are generated from the server module via `spacetime generate`. We pass them a `String`, which gets sent to the server to execute the corresponding reducer. To `client/src/main.rs`, add: ```rust /// Read each line of standard input, and either set our name or send a message as appropriate. -fn user_input_loop() { +fn user_input_loop(conn: &DbConnection) { 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()); + conn.reducers.set_name(name.to_string()).unwrap(); } else { - send_message(line); + conn.reducers.send_message(line).unwrap(); } } } @@ -428,7 +424,7 @@ fn user_input_loop() { ## Run it -Change your directory to the client app, then compile and run it. From the `quickstart-chat` directory, run: +After setting everything up, change your directory to the client app, then compile and run it. From the `quickstart-chat` directory, run: ```bash cd client @@ -441,25 +437,25 @@ You should see something like: User d9e25c51996dea2f connected. ``` -Now try sending a message. Type `Hello, world!` and press enter. You should see something like: +Now try sending a message by typing `Hello, world!` and pressing enter. You should see: ``` d9e25c51996dea2f: Hello, world! ``` -Next, set your name. Type `/name `, replacing `` with your name. You should see something like: +Next, set your name by typing `/name `, replacing `` with your desired username. You should see: ``` User d9e25c51996dea2f renamed to . ``` -Then send another message. Type `Hello after naming myself.` and press enter. You should see: +Then, send another message: ``` : Hello after naming myself. ``` -Now, close the app by hitting control-c, and start it again with `cargo run`. You should see yourself connecting, and your past messages in order: +Now, close the app by hitting `Ctrl+C`, and start it again with `cargo run`. You'll see yourself connecting, and your past messages will load in order: ``` User connected. @@ -473,15 +469,16 @@ You can find the full code for this client [in the Rust SDK's examples](https:// Check out the [Rust SDK Reference](/docs/sdks/rust) for a more comprehensive view of the SpacetimeDB Rust SDK. -Our bare-bones terminal interface has some quirks. Incoming messages can appear while the user is typing and be spliced into the middle of user input, which is less than ideal. Also, the user's input is interspersed with the program's output, so messages the user sends will seem to appear twice. Why not try building a better interface using [Rustyline](https://crates.io/crates/rustyline), [Cursive](https://crates.io/crates/cursive), or even a full-fledged GUI? We went for the Cursive route, and you can check out what we came up with [in the Rust SDK's examples](https://github.com/clockworklabs/SpacetimeDB/tree/master/crates/sdk/examples/cursive-chat). +Our basic terminal interface has some limitations. Incoming messages can appear while the user is typing, which is less than ideal. Additionally, the user's input gets mixed with the program's output, making messages the user sends appear twice. You might want to try improving the interface by using [Rustyline](https://crates.io/crates/rustyline), [Cursive](https://crates.io/crates/cursive), or even creating a full-fledged GUI. + +We've tried using Cursive for the interface, and you can check out our implementation in the [Rust SDK's examples](https://github.com/clockworklabs/SpacetimeDB/tree/master/crates/sdk/examples/cursive-chat). -Once our chat server runs for a while, messages will accumulate, and it will get frustrating to see the entire backlog each time you connect. Instead, you could refine your `Message` subscription query, subscribing only to messages newer than, say, half an hour before the user connected. +Once your chat server runs for a while, you might want to limit the messages your client loads by refining your `Message` subscription query, only subscribing to messages sent within the last half-hour. -You could also add support for styling messages, perhaps by interpreting HTML tags in the messages and printing appropriate [ANSI escapes](https://en.wikipedia.org/wiki/ANSI_escape_code). +You could also add features like: -Or, you could extend the module and the client together, perhaps: +- Styling messages by interpreting HTML tags and printing appropriate [ANSI escapes](https://en.wikipedia.org/wiki/ANSI_escape_code). +- Adding a `moderator` flag to the `User` table, allowing moderators to manage users (e.g., time-out, ban). +- Adding rooms or channels that users can join or leave. +- Supporting direct messages or displaying user statuses next to their usernames. -- Adding a `moderator: bool` flag to `User` and allowing moderators to time-out or ban naughty chatters. -- Adding a message of the day which gets shown to users whenever they connect, or some rules which get shown only to new users. -- Supporting separate rooms or channels which users can join or leave, and maybe even direct messages. -- Allowing users to set their status, which could be displayed alongside their username. From c3b7065a326ea3aa98cf2b0ffdf479561830b851 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Wed, 2 Oct 2024 12:09:32 -0400 Subject: [PATCH 068/164] I didn't notice that auto-merge was enabled, so here's my review (#94) --- docs/sdks/rust/quickstart.md | 37 +++++++++++++++++------------------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/docs/sdks/rust/quickstart.md b/docs/sdks/rust/quickstart.md index 9cea42c3..38d9dee7 100644 --- a/docs/sdks/rust/quickstart.md +++ b/docs/sdks/rust/quickstart.md @@ -112,12 +112,12 @@ fn main() { We need to handle several sorts of events: 1. When we connect and receive our credentials, we'll save them to a file so that the next time we connect, we can re-authenticate as the same user. -2. When a new user joins, we'll print a message introducing them. -3. When a user is updated, we'll print their new name, or declare their new online status. -4. When we receive a new message, we'll print it. -5. When we're informed of the backlog of past messages, we'll sort them and print them in order. -6. If the server rejects our attempt to set our name, we'll print an error. -7. If the server rejects a message we send, we'll print an error. +2. When a new user joins, we'll print a message introducing them. +3. When a user is updated, we'll print their new name, or declare their new online status. +4. When we receive a new message, we'll print it. +5. When we're informed of the backlog of past messages, we'll sort them and print them in order. +6. If the server rejects our attempt to set our name, we'll print an error. +7. If the server rejects a message we send, we'll print an error. 8. When our connection ends, we'll print a note, then exit the process. To `client/src/main.rs`, add: @@ -147,12 +147,12 @@ fn register_callbacks(conn: &DbConnection) { ## Save credentials -Each user has a `Credentials`, which consists of two parts: +Each user has a `Credentials`, which consists of two parts: - An `Identity`, a unique public identifier. We're using these to identify `User` rows. -- A `Token`, a private key which SpacetimeDB uses to authenticate the client. +- A `Token`, a private key which SpacetimeDB uses to authenticate the client. -`Credentials` are generated by SpacetimeDB each time a new client connects, and sent to the client so they can be saved, in order to re-connect with the same identity. The Rust SDK provides a pair of functions in `File`, `save` and `load`, for saving and storing these credentials in a file. By default the `save` and `load` will look for credentials in the `$HOME/.spacetimedb_client_credentials/` directory, which should be unintrusive. If saving our credentials fails, we'll print a message to standard error, but otherwise continue normally; even though the user won't be able to reconnect with the same identity, they can still chat normally. +`Credentials` are generated by SpacetimeDB each time a new client connects, and sent to the client so they can be saved, in order to re-connect with the same identity. The Rust SDK provides a pair of functions in `File`, `save` and `load`, for saving and storing these credentials in a file. By default the `save` and `load` will look for credentials in the `$HOME/.spacetimedb_client_credentials/` directory, which should be unintrusive. If saving our credentials fails, we'll print a message to standard error, but otherwise continue normally; even though the user won't be able to reconnect with the same identity, they can still chat normally. To `client/src/main.rs`, add: @@ -266,9 +266,9 @@ fn on_user_updated(old: &User, new: &User, _: Option<&ReducerEvent>) { ## Print messages -When we receive a new message, we'll print it to standard output, along with the name of the user who sent it. Keep in mind that we only want to do this for new messages, i.e. those inserted by a `send_message` reducer invocation. We have to handle the backlog we receive when our subscription is initialized separately, to ensure they're printed in the correct order. To that effect, our `on_message_inserted` callback will check if the ctx.event type is an `Event::Reducer`, and only print in that case. +When we receive a new message, we'll print it to standard output, along with the name of the user who sent it. Keep in mind that we only want to do this for new messages, i.e. those inserted by a `send_message` reducer invocation. We have to handle the backlog we receive when our subscription is initialized separately, to ensure they're printed in the correct order. To that effect, our `on_message_inserted` callback will check if the ctx.event type is an `Event::Reducer`, and only print in that case. -To find the `User` based on the message's `sender` identity, we'll use `ctx.db.user().identity().find(..)`, which behaves like the same function on the server. +To find the `User` based on the message's `sender` identity, we'll use `ctx.db.user().identity().find(..)`, which behaves like the same function on the server. We'll print the user's name or identity in the same way as we did when notifying about `User` table events, but here we have to handle the case where we don't find a matching `User` row. This can happen when the module owner sends a message using the CLI's `spacetime call`. In this case, we'll print `unknown`. @@ -291,12 +291,12 @@ fn print_message(ctx: &EventContext, message: &Message) { println!("{}: {}", sender, message.text); } -### Print past messages in order +### Print past messages in order -Messages we receive live will come in order, but when we connect, we'll receive all the past messages at once. We can't just print these in the order we receive them; the logs would be all shuffled around, and would make no sense. Instead, when we receive the log of past messages, we'll sort them by their sent timestamps and print them in order. +Messages we receive live will come in order, but when we connect, we'll receive all the past messages at once. We can't just print these in the order we receive them; the logs would be all shuffled around, and would make no sense. Instead, when we receive the log of past messages, we'll sort them by their sent timestamps and print them in order. -We'll handle this in our function `print_messages_in_order`, which we registered as an `on_subscription_applied` callback. `print_messages_in_order` iterates over all the `Message`s we've received, sorts them, and then prints them. `Message::iter()` is defined on the trait `TableType`, and returns an iterator over all the messages in the client's cache. Rust iterators can't be sorted in-place, so we'll collect it to a `Vec`, then use the `sort_by_key` method to sort by timestamp. +We'll handle this in our function `print_messages_in_order`, which we registered as an `on_subscription_applied` callback. `print_messages_in_order` iterates over all the `Message`s we've received, sorts them, and then prints them. `Message::iter()` is defined on the trait `TableType`, and returns an iterator over all the messages in the client's cache. Rust iterators can't be sorted in-place, so we'll collect it to a `Vec`, then use the `sort_by_key` method to sort by timestamp. To `client/src/main.rs`, add: @@ -314,16 +314,16 @@ fn on_sub_applied(ctx: &EventContext) { ## Handle reducer failures -We can also register callbacks to run each time a reducer is invoked. We register these callbacks using the `on_reducer` method of the `Reducer` trait, which is automatically implemented for each reducer by `spacetime generate`. +We can also register callbacks to run each time a reducer is invoked. We register these callbacks using the `on_reducer` method of the `Reducer` trait, which is automatically implemented for each reducer by `spacetime generate`. Each reducer callback first takes an `&EventContext` which contains all of the information from the reducer call including the reducer arguments, the identity of the caller, and whether or not the reducer call suceeded. These callbacks will be invoked in one of two cases: 1. If the reducer was successful and altered any of our subscribed rows. -2. If we requested an invocation which failed. +2. If we requested an invocation which failed. -Note that a status of `Failed` or `OutOfEnergy` implies that the caller identity is our own identity. +Note that a status of `Failed` or `OutOfEnergy` implies that the caller identity is our own identity. We already handle successful `set_name` invocations using our `ctx.db.user().on_update(..)` callback, but if the module rejects a user's chosen name, we'd like that user's client to let them know. We define a function `on_set_name` as a `conn.reducers.on_set_name(..)` callback which checks if the reducer failed, and if it did, prints a message including the rejected name and the error. @@ -471,8 +471,6 @@ Check out the [Rust SDK Reference](/docs/sdks/rust) for a more comprehensive vie Our basic terminal interface has some limitations. Incoming messages can appear while the user is typing, which is less than ideal. Additionally, the user's input gets mixed with the program's output, making messages the user sends appear twice. You might want to try improving the interface by using [Rustyline](https://crates.io/crates/rustyline), [Cursive](https://crates.io/crates/cursive), or even creating a full-fledged GUI. -We've tried using Cursive for the interface, and you can check out our implementation in the [Rust SDK's examples](https://github.com/clockworklabs/SpacetimeDB/tree/master/crates/sdk/examples/cursive-chat). - Once your chat server runs for a while, you might want to limit the messages your client loads by refining your `Message` subscription query, only subscribing to messages sent within the last half-hour. You could also add features like: @@ -481,4 +479,3 @@ You could also add features like: - Adding a `moderator` flag to the `User` table, allowing moderators to manage users (e.g., time-out, ban). - Adding rooms or channels that users can join or leave. - Supporting direct messages or displaying user statuses next to their usernames. - From cb90180aa9cbd6ff676c2e97d4b1e6b195055b22 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Wed, 2 Oct 2024 12:35:23 -0400 Subject: [PATCH 069/164] Update Rust SDK ref for the new SDK (#93) --- docs/sdks/rust/index.md | 1153 ++++++++------------------------------- 1 file changed, 221 insertions(+), 932 deletions(-) diff --git a/docs/sdks/rust/index.md b/docs/sdks/rust/index.md index 9c9e6f12..50e8aa9b 100644 --- a/docs/sdks/rust/index.md +++ b/docs/sdks/rust/index.md @@ -7,7 +7,7 @@ The SpacetimeDB client SDK for Rust contains all the tools you need to build nat First, create a new project using `cargo new` and add the SpacetimeDB SDK to your dependencies: ```bash -cargo add spacetimedb +cargo add spacetimedb_sdk ``` ## Generate module bindings @@ -29,1165 +29,454 @@ Declare a `mod` for the bindings in your client's `src/main.rs`: mod module_bindings; ``` -## API at a glance - -| Definition | Description | -| ------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | -| Function [`module_bindings::connect`](#function-connect) | Autogenerated function to connect to a database. | -| Function [`spacetimedb_sdk::disconnect`](#function-disconnect) | Close the active connection. | -| Function [`spacetimedb_sdk::on_disconnect`](#function-on_disconnect) | Register a `FnMut` callback to run when a connection ends. | -| Function [`spacetimedb_sdk::once_on_disconnect`](#function-once_on_disconnect) | Register a `FnOnce` callback to run the next time a connection ends. | -| Function [`spacetimedb_sdk::remove_on_disconnect`](#function-remove_on_disconnect) | Cancel an `on_disconnect` or `once_on_disconnect` callback. | -| Function [`spacetimedb_sdk::subscribe`](#function-subscribe) | Subscribe to queries with a `&[&str]`. | -| Function [`spacetimedb_sdk::subscribe_owned`](#function-subscribe_owned) | Subscribe to queries with a `Vec`. | -| Function [`spacetimedb_sdk::on_subscription_applied`](#function-on_subscription_applied) | Register a `FnMut` callback to run when a subscription's initial rows become available. | -| Function [`spacetimedb_sdk::once_on_subscription_applied`](#function-once_on_subscription_applied) | Register a `FnOnce` callback to run the next time a subscription's initial rows become available. | -| Function [`spacetimedb_sdk::remove_on_subscription_applied`](#function-remove_on_subscription_applied) | Cancel an `on_subscription_applied` or `once_on_subscription_applied` callback. | -| Type [`spacetimedb_sdk::identity::Identity`](#type-identity) | A unique public identifier for a client. | -| Type [`spacetimedb_sdk::identity::Token`](#type-token) | A private authentication token corresponding to an `Identity`. | -| Type [`spacetimedb_sdk::identity::Credentials`](#type-credentials) | An `Identity` paired with its `Token`. | -| Type [`spacetimedb_sdk::Address`](#type-address) | An opaque identifier for differentiating connections by the same `Identity`. | -| Function [`spacetimedb_sdk::identity::identity`](#function-identity) | Return the current connection's `Identity`. | -| Function [`spacetimedb_sdk::identity::token`](#function-token) | Return the current connection's `Token`. | -| Function [`spacetimedb_sdk::identity::credentials`](#function-credentials) | Return the current connection's [`Credentials`](#type-credentials). | -| Function [`spacetimedb_sdk::identity::address`](#function-address) | Return the current connection's [`Address`](#type-address). | -| Function [`spacetimedb_sdk::identity::on_connect`](#function-on_connect) | Register a `FnMut` callback to run when the connection's [`Credentials`](#type-credentials) are verified with the database. | -| Function [`spacetimedb_sdk::identity::once_on_connect`](#function-once_on_connect) | Register a `FnOnce` callback to run when the connection's [`Credentials`](#type-credentials) are verified with the database. | -| Function [`spacetimedb_sdk::identity::remove_on_connect`](#function-remove_on_connect) | Cancel an `on_connect` or `once_on_connect` callback. | -| Function [`spacetimedb_sdk::identity::load_credentials`](#function-load_credentials) | Load a saved [`Credentials`](#type-credentials) from a file. | -| Function [`spacetimedb_sdk::identity::save_credentials`](#function-save_credentials) | Save a [`Credentials`](#type-credentials) to a file. | -| Type [`module_bindings::{TABLE}`](#type-table) | Autogenerated `struct` type for a table, holding one row. | -| Method [`module_bindings::{TABLE}::filter_by_{COLUMN}`](#method-filter_by_column) | Autogenerated method to iterate over subscribed rows where a column matches a value. | -| Method [`module_bindings::{TABLE}::find_by_{COLUMN}`](#method-find_by_column) | Autogenerated method to seek a subscribed row where a unique column matches a value. | -| Trait [`spacetimedb_sdk::table::TableType`](#trait-tabletype) | Automatically implemented for all tables defined by a module. | -| Method [`spacetimedb_sdk::table::TableType::count`](#method-count) | Count the number of subscribed rows in a table. | -| Method [`spacetimedb_sdk::table::TableType::iter`](#method-iter) | Iterate over all subscribed rows. | -| Method [`spacetimedb_sdk::table::TableType::filter`](#method-filter) | Iterate over a subset of subscribed rows matching a predicate. | -| Method [`spacetimedb_sdk::table::TableType::find`](#method-find) | Return one subscribed row matching a predicate. | -| Method [`spacetimedb_sdk::table::TableType::on_insert`](#method-on_insert) | Register a `FnMut` callback to run whenever a new subscribed row is inserted. | -| Method [`spacetimedb_sdk::table::TableType::remove_on_insert`](#method-remove_on_insert) | Cancel an `on_insert` callback. | -| Method [`spacetimedb_sdk::table::TableType::on_delete`](#method-on_delete) | Register a `FnMut` callback to run whenever a subscribed row is deleted. | -| Method [`spacetimedb_sdk::table::TableType::remove_on_delete`](#method-remove_on_delete) | Cancel an `on_delete` callback. | -| Trait [`spacetimedb_sdk::table::TableWithPrimaryKey`](#trait-tablewithprimarykey) | Automatically implemented for tables with a column designated `#[primarykey]`. | -| Method [`spacetimedb_sdk::table::TableWithPrimaryKey::on_update`](#method-on_update) | Register a `FnMut` callback to run whenever an existing subscribed row is updated. | -| Method [`spacetimedb_sdk::table::TableWithPrimaryKey::remove_on_update`](#method-remove_on_update) | Cancel an `on_update` callback. | -| Type [`module_bindings::ReducerEvent`](#type-reducerevent) | Autogenerated enum with a variant for each reducer defined by the module. | -| Type [`module_bindings::{REDUCER}Args`](#type-reducerargs) | Autogenerated `struct` type for a reducer, holding its arguments. | -| Function [`module_bindings::{REDUCER}`](#function-reducer) | Autogenerated function to invoke a reducer. | -| Function [`module_bindings::on_{REDUCER}`](#function-on_reducer) | Autogenerated function to register a `FnMut` callback to run whenever the reducer is invoked. | -| Function [`module_bindings::once_on_{REDUCER}`](#function-once_on_reducer) | Autogenerated function to register a `FnOnce` callback to run the next time the reducer is invoked. | -| Function [`module_bindings::remove_on_{REDUCER}`](#function-remove_on_reducer) | Autogenerated function to cancel an `on_{REDUCER}` or `once_on_{REDUCER}` callback. | -| Type [`spacetimedb_sdk::reducer::Status`](#type-status) | Enum representing reducer completion statuses. | - -## Connect to a database - -### Function `connect` +## Type `DbConnection` ```rust -module_bindings::connect( - spacetimedb_uri: impl TryInto, - db_name: &str, - credentials: Option, -) -> anyhow::Result<()> +module_bindings::DbConnection ``` -Connect to a database named `db_name` accessible over the internet at the URI `spacetimedb_uri`. +A connection to a remote database is represented by the `module_bindings::DbConnection` type. This type is generated per-module, and contains information about the types, tables and reducers defined by your module. -| Argument | Type | Meaning | -| ----------------- | --------------------- | ------------------------------------------------------------ | -| `spacetimedb_uri` | `impl TryInto` | URI of the SpacetimeDB instance running the module. | -| `db_name` | `&str` | Name of the module. | -| `credentials` | `Option` | [`Credentials`](#type-credentials) to authenticate the user. | - -If `credentials` are supplied, they will be passed to the new connection to identify and authenticate the user. Otherwise, a set of [`Credentials`](#type-credentials) will be generated by the server. - -```rust -const MODULE_NAME: &str = "my-module-name"; - -// Connect to a local DB with a fresh identity -connect("http://localhost:3000", MODULE_NAME, None) - .expect("Connection failed"); - -// Connect to cloud with a fresh identity. -connect("https://testnet.spacetimedb.com", MODULE_NAME, None) - .expect("Connection failed"); - -// Connect with a saved identity -const CREDENTIALS_DIR: &str = ".my-module"; -connect( - "https://testnet.spacetimedb.com", - MODULE_NAME, - load_credentials(CREDENTIALS_DIR) - .expect("Error while loading credentials"), -).expect("Connection failed"); -``` - -### Function `disconnect` +### Connect to a module - `DbConnection::builder()` and `.build()` ```rust -spacetimedb_sdk::disconnect() +impl DbConnection { + fn builder() -> DbConnectionBuilder; +} ``` -Gracefully close the current WebSocket connection. +Construct a `DbConnection` by calling `DbConnection::builder()` and chaining configuration methods, then calling `.build()`. You must at least specify `with_uri`, to supply the URI of the SpacetimeDB to which you published your module, and `with_module_name`, to supply the human-readable SpacetimeDB domain name or the raw address which identifies the module. -If there is no active connection, this operation does nothing. +#### Method `with_uri` ```rust -connect(SPACETIMEDB_URI, MODULE_NAME, credentials) - .expect("Connection failed"); - -run_app(); - -disconnect(); +impl DbConnectionBuilder { + fn with_uri(self, uri: impl TryInto) -> Self; +} ``` -### Function `on_disconnect` - -```rust -spacetimedb_sdk::on_disconnect( - callback: impl FnMut() + Send + 'static, -) -> DisconnectCallbackId -``` - -Register a callback to be invoked when a connection ends. - -| Argument | Type | Meaning | -| ---------- | ------------------------------- | ------------------------------------------------------ | -| `callback` | `impl FnMut() + Send + 'static` | Callback to be invoked when subscriptions are applied. | - -The callback will be invoked after calling [`disconnect`](#function-disconnect), or when a connection is closed by the server. - -The returned `DisconnectCallbackId` can be passed to [`remove_on_disconnect`](#function-remove_on_disconnect) to unregister the callback. - -```rust -on_disconnect(|| println!("Disconnected!")); - -connect(SPACETIMEDB_URI, MODULE_NAME, credentials) - .expect("Connection failed"); - -disconnect(); - -// Will print "Disconnected!" -``` +Configure the URI of the SpacetimeDB instance or cluster which hosts the remote module. -### Function `once_on_disconnect` +#### Method `with_module_name` ```rust -spacetimedb_sdk::once_on_disconnect( - callback: impl FnOnce() + Send + 'static, -) -> DisconnectCallbackId +impl DbConnectionBuilder { + fn with_module_name(self, name_or_address: impl ToString) -> Self; +} ``` -Register a callback to be invoked the next time a connection ends. - -| Argument | Type | Meaning | -| ---------- | ------------------------------- | ------------------------------------------------------ | -| `callback` | `impl FnMut() + Send + 'static` | Callback to be invoked when subscriptions are applied. | - -The callback will be invoked after calling [`disconnect`](#function-disconnect), or when a connection is closed by the server. +Configure the SpacetimeDB domain name or address of the remote module which identifies it within the SpacetimeDB instance or cluster. -The callback will be unregistered after running. - -The returned `DisconnectCallbackId` can be passed to [`remove_on_disconnect`](#function-remove_on_disconnect) to unregister the callback. +#### Callback `on_connect` ```rust -once_on_disconnect(|| println!("Disconnected!")); - -connect(SPACETIMEDB_URI, MODULE_NAME, credentials) - .expect("Connection failed"); - -disconnect(); - -// Will print "Disconnected!" - -connect(SPACETIMEDB_URI, MODULE_NAME, credentials) - .expect("Connection failed"); - -disconnect(); - -// Nothing printed this time. +impl DbConnectionBuilder { + fn on_connect(self, callback: impl FnOnce(&DbConnection, Identity, &str)) -> DbConnectionBuilder; +} ``` -### Function `remove_on_disconnect` +Chain a call to `.on_connect(callback)` to your builder to register a callback to run when your new `DbConnection` successfully initiates its connection to the remote module. The callback accepts three arguments: a reference to the `DbConnection`, the `Identity` by which SpacetimeDB identifies this connection, and a private access token which can be saved and later passed to [`with_credentials`](#method-with_credentials) to authenticate the same user in future connections. -```rust -spacetimedb_sdk::remove_on_disconnect( - id: DisconnectCallbackId, -) -``` +This interface may change in an upcoming release as we rework SpacetimeDB's authentication model. -Unregister a previously-registered [`on_disconnect`](#function-on_disconnect) callback. +#### Callback `on_connect_error` -| Argument | Type | Meaning | -| -------- | ---------------------- | ------------------------------------------ | -| `id` | `DisconnectCallbackId` | Identifier for the callback to be removed. | +Currently unused. -If `id` does not refer to a currently-registered callback, this operation does nothing. +#### Callback `on_disconnect` ```rust -let id = on_disconnect(|| unreachable!()); - -remove_on_disconnect(id); - -disconnect(); - -// No `unreachable` panic. +impl DbConnectionBuilder { + fn on_disconnect(self, callback: impl FnOnce(&DbConnection, Option<&anyhow::Error>)) -> DbConnectionBuilder; +} ``` -## Subscribe to queries +Chain a call to `.on_connect(callback)` to your builder to register a callback to run when your `DbConnection` disconnects from the remote module, either as a result of a call to [`disconnect`](#method-disconnect) or due to an error. -### Function `subscribe` +#### Method `with_credentials` ```rust -spacetimedb_sdk::subscribe(queries: &[&str]) -> anyhow::Result<()> +impl DbConnectionBuilder { + fn with_credentials(self, credentials: Option<(Identity, String)>) -> Self; +} ``` -Subscribe to a set of queries, to be notified when rows which match those queries are altered. - -| Argument | Type | Meaning | -| --------- | --------- | ---------------------------- | -| `queries` | `&[&str]` | SQL queries to subscribe to. | - -The `queries` should be a slice of strings representing SQL queries. - -`subscribe` will return an error if called before establishing a connection with the autogenerated [`connect`](#function-connect) function. In that case, the queries are not registered. - -`subscribe` does not return data directly. The SDK will generate types [`module_bindings::{TABLE}`](#type-table) corresponding to each of the tables in your module. These types implement the trait [`spacetimedb_sdk::table_type::TableType`](#trait-tabletype), which contains methods such as [`TableType::on_insert`](#method-on_insert). Use these methods to receive data from the queries you subscribe to. +Chain a call to `.with_credentials(credentials)` to your builder to provide an `Identity` and private access token to authenticate with, or to explicitly select an anonymous connection. If this method is not called or `None` is passed, SpacetimeDB will generate a new `Identity` and sign a new private access token for the connection. -A new call to `subscribe` (or [`subscribe_owned`](#function-subscribe_owned)) will remove all previous subscriptions and replace them with the new `queries`. If any rows matched the previous subscribed queries but do not match the new queries, those rows will be removed from the client cache, and [`TableType::on_delete`](#method-on_delete) callbacks will be invoked for them. +This interface may change in an upcoming release as we rework SpacetimeDB's authentication model. -```rust -subscribe(&["SELECT * FROM User;", "SELECT * FROM Message;"]) - .expect("Called `subscribe` before `connect`"); -``` - -### Function `subscribe_owned` +#### Method `build` ```rust -spacetimedb_sdk::subscribe_owned(queries: Vec) -> anyhow::Result<()> +impl DbConnectionBuilder { + fn build(self) -> anyhow::Result; +} ``` -Subscribe to a set of queries, to be notified when rows which match those queries are altered. - -| Argument | Type | Meaning | -| --------- | ------------- | ---------------------------- | -| `queries` | `Vec` | SQL queries to subscribe to. | - -The `queries` should be a `Vec` of `String`s representing SQL queries. +After configuring the connection and registering callbacks, attempt to open the connection. -A new call to `subscribe_owned` (or [`subscribe`](#function-subscribe)) will remove all previous subscriptions and replace them with the new `queries`. -If any rows matched the previous subscribed queries but do not match the new queries, those rows will be removed from the client cache, and [`TableType::on_delete`](#method-on_delete) callbacks will be invoked for them. +### Advance the connection and process messages -`subscribe_owned` will return an error if called before establishing a connection with the autogenerated [`connect`](#function-connect) function. In that case, the queries are not registered. +In the interest of supporting a wide variety of client applications with different execution strategies, the SpacetimeDB SDK allows you to choose when the `DbConnection` spends compute time and processes messages. If you do not arrange for the connection to advance by calling one of these methods, the `DbConnection` will never advance, and no callbacks will ever be invoked. -```rust -let query = format!("SELECT * FROM User WHERE name = '{}';", compute_my_name()); - -subscribe_owned(vec![query]) - .expect("Called `subscribe_owned` before `connect`"); -``` - -### Function `on_subscription_applied` +#### Run in the background - method `run_threaded` ```rust -spacetimedb_sdk::on_subscription_applied( - callback: impl FnMut() + Send + 'static, -) -> SubscriptionCallbackId +impl DbConnection { + fn run_threaded(&self) -> std::thread::JoinHandle<()>; +} ``` -Register a callback to be invoked the first time a subscription's matching rows becoming available. - -| Argument | Type | Meaning | -| ---------- | ------------------------------- | ------------------------------------------------------ | -| `callback` | `impl FnMut() + Send + 'static` | Callback to be invoked when subscriptions are applied. | - -The callback will be invoked after a successful [`subscribe`](#function-subscribe) or [`subscribe_owned`](#function-subscribe_owned) call when the initial set of matching rows becomes available. - -The returned `SubscriptionCallbackId` can be passed to [`remove_on_subscription_applied`](#function-remove_on_subscription_applied) to unregister the callback. - -```rust -on_subscription_applied(|| println!("Subscription applied!")); - -subscribe(&["SELECT * FROM User;"]) - .expect("Called `subscribe` before `connect`"); - -sleep(Duration::from_secs(1)); - -// Will print "Subscription applied!" - -subscribe(&["SELECT * FROM User;", "SELECT * FROM Message;"]) - .expect("Called `subscribe` before `connect`"); - -// Will print again. -``` +`run_threaded` spawns a thread which will continuously advance the connection, sleeping when there is no work to do. The thread will panic if the connection disconnects erroneously, or return if it disconnects as a result of a call to [`disconnect`](#method-disconnect). -### Function `once_on_subscription_applied` +#### Run asynchronously - method `run_async` ```rust -spacetimedb_sdk::once_on_subscription_applied( - callback: impl FnOnce() + Send + 'static, -) -> SubscriptionCallbackId +impl DbConnection { + async fn run_async(&self) -> anyhow::Result<()>; +} ``` -Register a callback to be invoked the next time a subscription's matching rows become available. - -| Argument | Type | Meaning | -| ---------- | ------------------------------- | ------------------------------------------------------ | -| `callback` | `impl FnMut() + Send + 'static` | Callback to be invoked when subscriptions are applied. | - -The callback will be invoked after a successful [`subscribe`](#function-subscribe) or [`subscribe_owned`](#function-subscribe_owned) call when the initial set of matching rows becomes available. +`run_async` will continuously advance the connection, `await`-ing when there is no work to do. The task will return an `Err` if the connection disconnects erroneously, or return `Ok(())` if it disconnects as a result of a call to [`disconnect`](#method-disconnect). -The callback will be unregistered after running. - -The returned `SubscriptionCallbackId` can be passed to [`remove_on_subscription_applied`](#function-remove_on_subscription_applied) to unregister the callback. +#### Run on the main thread without blocking - method `frame_tick` ```rust -once_on_subscription_applied(|| println!("Subscription applied!")); - -subscribe(&["SELECT * FROM User;"]) - .expect("Called `subscribe` before `connect`"); - -sleep(Duration::from_secs(1)); - -// Will print "Subscription applied!" - -subscribe(&["SELECT * FROM User;", "SELECT * FROM Message;"]) - .expect("Called `subscribe` before `connect`"); - -// Nothing printed this time. +impl DbConnection { + fn frame_tick(&self) -> anyhow::Result<()>; +} ``` -### Function `remove_on_subscription_applied` - -```rust -spacetimedb_sdk::remove_on_subscription_applied( - id: SubscriptionCallbackId, -) -``` - -Unregister a previously-registered [`on_subscription_applied`](#function-on_subscription_applied) callback. - -| Argument | Type | Meaning | -| -------- | ------------------------ | ------------------------------------------ | -| `id` | `SubscriptionCallbackId` | Identifier for the callback to be removed. | - -If `id` does not refer to a currently-registered callback, this operation does nothing. - -```rust -let id = on_subscription_applied(|| println!("Subscription applied!")); - -subscribe(&["SELECT * FROM User;"]) - .expect("Called `subscribe` before `connect`"); - -sleep(Duration::from_secs(1)); +`frame_tick` will advance the connection until no work remains, then return rather than blocking or `await`-ing. Games might arrange for this message to be called every frame. `frame_tick` returns `Ok` if the connection remains active afterwards, or `Err` if the connection disconnected before or during the call. -// Will print "Subscription applied!" +## Trait `spacetimedb_sdk::DbContext` -remove_on_subscription_applied(id); +[`DbConnection`](#type-dbconnection) and [`EventContext`](#type-eventcontext) both implement `DbContext`, which allows -subscribe(&["SELECT * FROM User;", "SELECT * FROM Message;"]) - .expect("Called `subscribe` before `connect`"); - -// Nothing printed this time. -``` - -## Identify a client - -### Type `Identity` +### Method `disconnect` ```rust -spacetimedb_sdk::identity::Identity +trait DbContext { + fn disconnect(&self) -> anyhow::Result<()>; +} ``` -A unique public identifier for a client connected to a database. - -### Type `Token` +Gracefully close the `DbConnection`. Returns an `Err` if the connection is already disconnected. -```rust -spacetimedb_sdk::identity::Token -``` +### Subscribe to queries - `DbContext::subscription_builder` and `.subscribe()` -A private access token for a client connected to a database. +This interface is subject to change in an upcoming SpacetimeDB release. -### Type `Credentials` +A known issue in the SpacetimeDB Rust SDK causes inconsistent behaviors after re-subscribing. This will be fixed in an upcoming SpacetimeDB release. For now, Rust clients should issue only one subscription per `DbConnection`. ```rust -spacetimedb_sdk::identity::Credentials +trait DbContext { + fn subscription_builder(&self) -> SubscriptionBuilder; +} ``` -Credentials, including a private access token, sufficient to authenticate a client connected to a database. +Subscribe to queries by calling `ctx.subscription_builder()` and chaining configuration methods, then calling `.subscribe(queries)`. -| Field | Type | -| ---------- | ---------------------------- | -| `identity` | [`Identity`](#type-identity) | -| `token` | [`Token`](#type-token) | - -### Type `Address` +#### Callback `on_applied` ```rust -spacetimedb_sdk::Address +impl SubscriptionBuilder { + fn on_applied(self, callback: impl FnOnce(&EventContext)) -> Self; +} ``` -An opaque identifier for a client connection to a database, intended to differentiate between connections from the same [`Identity`](#type-identity). +Register a callback to run when the subscription is applied and the matching rows are inserted into the client cache. The [`EventContext`](#type-module_bindings-eventcontext) passed to the callback will have `Event::SubscribeApplied` as its `event`. -### Function `identity` +#### Method `subscribe` ```rust -spacetimedb_sdk::identity::identity() -> Result +impl SubscriptionBuilder { + fn subscribe(self, queries: impl IntoQueries) -> SubscriptionHandle; +} ``` -Read the current connection's public [`Identity`](#type-identity). +Subscribe to a set of queries. `queries` should be an array or slice of strings. -Returns an error if: +The returned `SubscriptionHandle` is currently not useful, but will become significant in a future version of SpacetimeDB. -- [`connect`](#function-connect) has not yet been called. -- We connected anonymously, and we have not yet received our credentials. - -```rust -connect(SPACETIMEDB_URI, DB_NAME, None) - .expect("Failed to connect"); - -sleep(Duration::from_secs(1)); - -println!("My identity is {:?}", identity()); - -// Prints "My identity is Ok(Identity { bytes: [...several u8s...] })" -``` +### Identity a client -### Function `token` +#### Method `identity` ```rust -spacetimedb_sdk::identity::token() -> Result +trait DbContext { + fn identity(&self) -> Identity; +} ``` -Read the current connection's private [`Token`](#type-token). +Get the `Identity` with which SpacetimeDB identifies the connection. This method may panic if the connection was initiated anonymously and the newly-generated `Identity` has not yet been received, i.e. if called before the [`on_connect` callback](#callback-on_connect) is invoked. -Returns an error if: - -- [`connect`](#function-connect) has not yet been called. -- We connected anonymously, and we have not yet received our credentials. - -```rust -connect(SPACETIMEDB_URI, DB_NAME, None) - .expect("Failed to connect"); - -sleep(Duration::from_secs(1)); - -println!("My token is {:?}", token()); - -// Prints "My token is Ok(Token {string: "...several Base64 digits..." })" -``` - -### Function `credentials` +#### Method `try_identity` ```rust -spacetimedb_sdk::identity::credentials() -> Result +trait DbContext { + fn try_identity(&self) -> Option; +} ``` -Read the current connection's [`Credentials`](#type-credentials), including a public [`Identity`](#type-identity) and a private [`Token`](#type-token). +Like [`DbContext::identity`](#method-identity), but returns `None` instead of panicking if the `Identity` is not yet available. -Returns an error if: - -- [`connect`](#function-connect) has not yet been called. -- We connected anonymously, and we have not yet received our credentials. +#### Method `is_active` ```rust -connect(SPACETIMEDB_URI, DB_NAME, None) - .expect("Failed to connect"); - -sleep(Duration::from_secs(1)); - -println!("My credentials are {:?}", credentials()); - -// Prints "My credentials are Ok(Credentials { -// identity: Identity { bytes: [...several u8s...] }, -// token: Token { string: "...several Base64 digits..."}, -// })" +trait DbContext { + fn is_active(&self) -> bool; +} ``` -### Function `address` - -```rust -spacetimedb_sdk::identity::address() -> Result
-``` - -Read the current connection's [`Address`](#type-address). - -Returns an error if [`connect`](#function-connect) has not yet been called. - -```rust -connect(SPACETIMEDB_URI, DB_NAME, None) - .expect("Failed to connect"); - -sleep(Duration::from_secs(1)); - -println!("My address is {:?}", address()); -``` +`true` if the connection has not yet disconnected. Note that a connection `is_active` when it is constructed, before its [`on_connect` callback](#callback-on_connect) is invoked. -### Function `on_connect` +## Type `EventContext` ```rust -spacetimedb_sdk::identity::on_connect( - callback: impl FnMut(&Credentials, Address) + Send + 'static, -) -> ConnectCallbackId +module_bindings::EventContext ``` -Register a callback to be invoked upon authentication with the database. - -| Argument | Type | Meaning | -| ---------- | -------------------------------------------------- | ------------------------------------------------------ | -| `callback` | `impl FnMut(&Credentials, Address) + Send + 'sync` | Callback to be invoked upon successful authentication. | - -The callback will be invoked with the [`Credentials`](#type-credentials) and [`Address`](#type-address) provided by the database to identify this connection. If [`Credentials`](#type-credentials) were supplied to [`connect`](#function-connect), those passed to the callback will be equivalent to the ones used to connect. If the initial connection was anonymous, a new set of [`Credentials`](#type-credentials) will be generated by the database to identify this user. +An `EventContext` is a [`DbContext`](#trait-dbcontext) augmented with a field `event: Event`. -The [`Credentials`](#type-credentials) passed to the callback can be saved and used to authenticate the same user in future connections. - -The returned `ConnectCallbackId` can be passed to [`remove_on_connect`](#function-remove_on_connect) to unregister the callback. +### Enum `Event` ```rust -on_connect( - |creds, addr| - println!("Successfully connected! My credentials are: {:?} and my address is: {:?}", creds, addr) -); - -connect(SPACETIMEDB_URI, DB_NAME, None) - .expect("Failed to connect"); - -sleep(Duration::from_secs(1)); - -// Will print "Successfully connected! My credentials are: " -// followed by a printed representation of the client's `Credentials`. +spacetimedb_sdk::Event ``` -### Function `once_on_connect` - -```rust -spacetimedb_sdk::identity::once_on_connect( - callback: impl FnOnce(&Credentials, Address) + Send + 'static, -) -> ConnectCallbackId -``` - -Register a callback to be invoked once upon authentication with the database. - -| Argument | Type | Meaning | -| ---------- | --------------------------------------------------- | ---------------------------------------------------------------- | -| `callback` | `impl FnOnce(&Credentials, Address) + Send + 'sync` | Callback to be invoked once upon next successful authentication. | - -The callback will be invoked with the [`Credentials`](#type-credentials) and [`Address`](#type-address) provided by the database to identify this connection. If [`Credentials`](#type-credentials) were supplied to [`connect`](#function-connect), those passed to the callback will be equivalent to the ones used to connect. If the initial connection was anonymous, a new set of [`Credentials`](#type-credentials) will be generated by the database to identify this user. - -The [`Credentials`](#type-credentials) passed to the callback can be saved and used to authenticate the same user in future connections. - -The callback will be unregistered after running. - -The returned `ConnectCallbackId` can be passed to [`remove_on_connect`](#function-remove_on_connect) to unregister the callback. - -### Function `remove_on_connect` +#### Variant `Reducer` ```rust -spacetimedb_sdk::identity::remove_on_connect(id: ConnectCallbackId) +spacetimedb_sdk::Event::Reducer(spacetimedb_sdk::ReducerEvent) ``` -Unregister a previously-registered [`on_connect`](#function-on_connect) or [`once_on_connect`](#function-once_on_connect) callback. +Event when we are notified that a reducer ran in the remote module. The [`ReducerEvent`](#struct-reducerevent) contains metadata about the reducer run, including its arguments and termination [`Status`](#enum-status). -| Argument | Type | Meaning | -| -------- | ------------------- | ------------------------------------------ | -| `id` | `ConnectCallbackId` | Identifier for the callback to be removed. | +This event is passed to reducer callbacks, and to row callbacks resulting from modifications by the reducer. -If `id` does not refer to a currently-registered callback, this operation does nothing. +#### Variant `SubscribeApplied` ```rust -let id = on_connect(|_creds, _addr| unreachable!()); - -remove_on_connect(id); - -connect(SPACETIMEDB_URI, DB_NAME, None) - .expect("Failed to connect"); - -sleep(Duration::from_secs(1)); - -// No `unreachable` panic. +spacetimedb_sdk::Event::SubscribeApplied ``` -### Function `load_credentials` +Event when our subscription is applied and its rows are inserted into the client cache. -```rust -spacetimedb_sdk::identity::load_credentials( - dirname: &str, -) -> Result> -``` +This event is passed to [subscription `on_applied` callbacks](#callback-on_applied), and to [row `on_insert` callbacks](#callback-on_insert) resulting from the new subscription. -Load a saved [`Credentials`](#type-credentials) from a file within `~/dirname`, if one exists. +#### Variant `UnsubscribeApplied` -| Argument | Type | Meaning | -| --------- | ------ | ----------------------------------------------------- | -| `dirname` | `&str` | Name of a sub-directory in the user's home directory. | +Currently unused. -`dirname` is treated as a directory in the user's home directory. If it contains a file named `credentials`, that file is treated as a BSATN-encoded [`Credentials`](#type-credentials), deserialized and returned. These files are created by [`save_credentials`](#function-save_credentials) with the same `dirname` argument. +#### Variant `SubscribeError` -Returns `Ok(None)` if the directory or the credentials file does not exist. Returns `Err` when IO or deserialization fails. The returned `Result` may be unwrapped, and the contained `Option` passed to [`connect`](#function-connect). +Currently unused. -```rust -const CREDENTIALS_DIR = ".my-module"; +#### Variant `UnknownTransaction` -let creds = load_credentials(CREDENTIALS_DIR) - .expect("Error while loading credentials"); +Event when we are notified of a transaction in the remote module which we cannot associate with a known reducer. This may be an ad-hoc SQL query or a reducer for which we do not have bindings. -connect(SPACETIMEDB_URI, DB_NAME, creds) - .expect("Failed to connect"); -``` +This event is passed to row callbacks resulting from modifications by the transaction. -### Function `save_credentials` +### Struct `ReducerEvent` ```rust -spacetimedb_sdk::identity::save_credentials( - dirname: &str, - credentials: &Credentials, -) -> Result<()> +spacetimedb_sdk::ReducerEvent ``` -Store a [`Credentials`](#type-credentials) to a file within `~/dirname`, to be later loaded with [`load_credentials`](#function-load_credentials). - -| Argument | Type | Meaning | -| ------------- | -------------- | ----------------------------------------------------- | -| `dirname` | `&str` | Name of a sub-directory in the user's home directory. | -| `credentials` | `&Credentials` | [`Credentials`](#type-credentials) to store. | - -`dirname` is treated as a directory in the user's home directory. The directory is created if it does not already exists. A file within it named `credentials` is created or replaced, containing `creds` encoded as BSATN. The saved credentials can be retrieved by [`load_credentials`](#function-load_credentials) with the same `dirname` argument. - -Returns `Err` when IO or serialization fails. +A `ReducerEvent` contains metadata about a reducer run. ```rust -const CREDENTIALS_DIR = ".my-module"; +struct spacetimedb_sdk::ReducerEvent { + /// The time at which the reducer was invoked. + timestamp: SystemTime, -let creds = load_credentials(CREDENTIALS_DIRectory) - .expect("Error while loading credentials"); + /// Whether the reducer committed, was aborted due to insufficient energy, or failed with an error message. + status: Status, -on_connect(|creds, _addr| { - if let Err(e) = save_credentials(CREDENTIALS_DIR, creds) { - eprintln!("Error while saving credentials: {:?}", e); - } -}); + /// The `Identity` of the SpacetimeDB actor which invoked the reducer. + caller_identity: Identity, -connect(SPACETIMEDB_URI, DB_NAME, creds) - .expect("Failed to connect"); -``` + /// The `Address` of the SpacetimeDB actor which invoked the reducer, + /// or `None` if the actor did not supply an address. + caller_address: Option
, -## View subscribed rows of tables + /// The amount of energy consumed by the reducer run, in eV. + /// (Not literal eV, but our SpacetimeDB energy unit eV.) + /// + /// May be `None` if the module is configured not to broadcast energy consumed. + energy_consumed: Option, -### Type `{TABLE}` + /// The `Reducer` enum defined by the `module_bindings`, which encodes which reducer ran and its arguments. + reducer: R, -```rust -module_bindings::{TABLE} + // ...private fields +} ``` -For each table defined by a module, `spacetime generate` generates a struct in the `module_bindings` mod whose name is that table's name converted to `PascalCase`. The generated struct has a field for each of the table's columns, whose names are the column names converted to `snake_case`. - -### Method `filter_by_{COLUMN}` - -```rust -module_bindings::{TABLE}::filter_by_{COLUMN}( - value: {COLUMN_TYPE}, -) -> impl Iterator -``` - -For each column of a table, `spacetime generate` generates a static method on the [table struct](#type-table) to filter subscribed rows where that column matches a requested value. - -These methods are named `filter_by_{COLUMN}`, where `{COLUMN}` is the column name converted to `snake_case`. The method's return type is an `Iterator` over the `{TABLE}` rows which match the requested value. - -### Method `find_by_{COLUMN}` +### Enum `Status` ```rust -module_bindings::{TABLE}::find_by_{COLUMN}( - value: {COLUMN_TYPE}, -) -> {FILTER_RESULT}<{TABLE}> +spacetimedb_sdk::Status ``` -For each unique column of a table (those annotated `#[unique]` and `#[primarykey]`), `spacetime generate` generates a static method on the [table struct](#type-table) to seek a subscribed row where that column matches a requested value. - -These methods are named `find_by_{COLUMN}`, where `{COLUMN}` is the column name converted to `snake_case`. The method's return type is `Option<{TABLE}>`. - -### Trait `TableType` +#### Variant `Committed` ```rust -spacetimedb_sdk::table::TableType +spacetimedb_sdk::Status::Committed ``` -Every [generated table struct](#type-table) implements the trait `TableType`. +The reducer returned successfully and its changes were committed into the database state. An [`Event::Reducer`](#variant-reducer) passed to a row callback must have this status in its [`ReducerEvent`](#struct-reducerevent). -#### Method `count` +#### Variant `Failed` ```rust -TableType::count() -> usize +spacetimedb_sdk::Status::Failed(Box) ``` -Return the number of subscribed rows in the table, or 0 if there is no active connection. - -This method acquires a global lock. +The reducer returned an error, panicked, or threw an exception. The enum payload is the stringified error message. Formatting of the error message is unstable and subject to change, so clients should use it only as a human-readable diagnostic, and in particular should not attempt to parse the message. -```rust -connect(SPACETIMEDB_URI, DB_NAME, None) - .expect("Failed to connect"); - -on_subscription_applied(|| println!("There are {} users", User::count())); +#### Variant `OutOfEnergy` -subscribe(&["SELECT * FROM User;"]) - .unwrap(); +The reducer was aborted due to insufficient energy balance of the module owner. -sleep(Duration::from_secs(1)); - -// Will the number of `User` rows in the database. -``` - -#### Method `iter` +### Enum `Reducer` ```rust -TableType::iter() -> impl Iterator +module_bindings::Reducer ``` -Iterate over all the subscribed rows in the table. - -This method acquires a global lock, but the iterator does not hold it. +The module bindings contains an enum `Reducer` with a variant for each reducer defined by the module. Each variant has a payload containing the arguments to the reducer. -This method must heap-allocate enough memory to hold all of the rows being iterated over. [`TableType::filter`](#method-filter) allocates significantly less, so prefer it when possible. +## Access the client cache -```rust -connect(SPACETIMEDB_URI, DB_NAME, None) - .expect("Failed to connect"); +Both [`DbConnection`](#type-dbconnection) and [`EventContext`](#type-eventcontext) have fields `.db`, which in turn has methods for accessing tables in the client cache. The trait method `DbContext::db(&self)` can also be used in contexts with an `impl DbContext` rather than a concrete-typed `EventContext` or `DbConnection`. -on_subscription_applied(|| for user in User::iter() { - println!("{:?}", user); -}); +Each table defined by a module has an accessor method, whose name is the table name converted to `snake_case`, on this `.db` field. The methods are defined via extension traits, which `rustc` or your IDE should help you identify and import where necessary. The table accessor methods return table handles, which implement [`Table`](#trait-table), may implement [`TableWithPrimaryKey`](#trait-tablewithprimarykey), and have methods for searching by unique index. -subscribe(&["SELECT * FROM User;"]) - .unwrap(); - -sleep(Duration::from_secs(1)); - -// Will print a line for each `User` row in the database. -``` - -#### Method `filter` +### Trait `Table` ```rust -TableType::filter( - predicate: impl FnMut(&Self) -> bool, -) -> impl Iterator +spacetimedb_sdk::Table ``` -Iterate over the subscribed rows in the table for which `predicate` returns `true`. - -| Argument | Type | Meaning | -| ----------- | --------------------------- | ------------------------------------------------------------------------------- | -| `predicate` | `impl FnMut(&Self) -> bool` | Test which returns `true` if a row should be included in the filtered iterator. | - -This method acquires a global lock, and the `predicate` runs while the lock is held. The returned iterator does not hold the lock. +Implemented by all table handles. -The `predicate` is called eagerly for each subscribed row in the table, even if the returned iterator is never consumed. - -This method must heap-allocate enough memory to hold all of the matching rows, but does not allocate space for subscribed rows which do not match the `predicate`. - -Client authors should prefer calling [tables' generated `filter_by_{COLUMN}` methods](#method-filter_by_column) when possible rather than calling `TableType::filter`. - -```rust -connect(SPACETIMEDB_URI, DB_NAME, None) - .expect("Failed to connect"); - -on_subscription_applied(|| { - for user in User::filter(|user| user.age >= 30 - && user.country == Country::USA) { - println!("{:?}", user); - } -}); - -subscribe(&["SELECT * FROM User;"]) - .unwrap(); - -sleep(Duration::from_secs(1)); - -// Will print a line for each `User` row in the database -// who is at least 30 years old and who lives in the United States. -``` - -#### Method `find` +#### Associated type `Row` ```rust -TableType::find( - predicate: impl FnMut(&Self) -> bool, -) -> Option +trait spacetimedb_sdk::Table { + type Table::Row; +} ``` -Locate a subscribed row for which `predicate` returns `true`, if one exists. - -| Argument | Type | Meaning | -| ----------- | --------------------------- | ------------------------------------------------------ | -| `predicate` | `impl FnMut(&Self) -> bool` | Test which returns `true` if a row should be returned. | - -This method acquires a global lock. - -If multiple subscribed rows match `predicate`, one is chosen arbitrarily. The choice may not be stable across different calls to `find` with the same `predicate`. - -Client authors should prefer calling [tables' generated `find_by_{COLUMN}` methods](#method-find_by_column) when possible rather than calling `TableType::find`. - -```rust -connect(SPACETIMEDB_URI, DB_NAME, None) - .expect("Failed to connect"); - -on_subscription_applied(|| { - if let Some(tyler) = User::find(|user| user.first_name == "Tyler" - && user.surname == "Cloutier") { - println!("Found Tyler: {:?}", tyler); - } else { - println!("Tyler isn't registered :("); - } -}); - -subscribe(&["SELECT * FROM User;"]) - .unwrap(); - -sleep(Duration::from_secs(1)); +The type of rows in the table. -// Will tell us whether Tyler Cloutier is registered in the database. -``` - -#### Method `on_insert` +#### Method `count` ```rust -TableType::on_insert( - callback: impl FnMut(&Self, Option<&ReducerEvent>) + Send + 'static, -) -> InsertCallbackId +trait spacetimedb_sdk::Table { + fn count(&self) -> u64; +} ``` -Register an `on_insert` callback for when a subscribed row is newly inserted into the database. - -| Argument | Type | Meaning | -| ---------- | ----------------------------------------------------------- | ------------------------------------------------------ | -| `callback` | `impl FnMut(&Self, Option<&ReducerEvent>) + Send + 'static` | Callback to run whenever a subscribed row is inserted. | - -The callback takes two arguments: - -- `row: &Self`, the newly-inserted row value. -- `reducer_event: Option<&ReducerEvent>`, the [`ReducerEvent`](#type-reducerevent) which caused this row to be inserted, or `None` if this row is being inserted while initializing a subscription. - -The returned `InsertCallbackId` can be passed to [`remove_on_insert`](#method-remove_on_insert) to remove the callback. - -```rust -connect(SPACETIMEDB_URI, DB_NAME, None) - .expect("Failed to connect"); - -User::on_insert(|user, reducer_event| { - if let Some(reducer_event) = reducer_event { - println!("New user inserted by reducer {:?}: {:?}", reducer_event, user); - } else { - println!("New user received during subscription update: {:?}", user); - } -}); +Returns the number of rows of this table resident in the client cache, i.e. the total number which match any subscribed query. -subscribe(&["SELECT * FROM User;"]) - .unwrap(); - -sleep(Duration::from_secs(1)); - -// Will print a note whenever a new `User` row is inserted. -``` - -#### Method `remove_on_insert` +#### Method `iter` ```rust -TableType::remove_on_insert(id: InsertCallbackId) +trait spacetimedb_sdk::Table { + fn iter(&self) -> impl Iterator; +} ``` -Unregister a previously-registered [`on_insert`](#method-on_insert) callback. +An iterator over all the subscribed rows in the client cache, i.e. those which match any subscribed query. -| Argument | Type | Meaning | -| -------- | ------------------------ | ----------------------------------------------------------------------- | -| `id` | `InsertCallbackId` | Identifier for the [`on_insert`](#method-on_insert) callback to remove. | - -If `id` does not refer to a currently-registered callback, this operation does nothing. +#### Callback `on_insert` ```rust -connect(SPACETIMEDB_URI, DB_NAME, None) - .expect("Failed to connect"); - -let id = User::on_insert(|_, _| unreachable!()); - -User::remove_on_insert(id); - -subscribe(&["SELECT * FROM User;"]) - .unwrap(); +trait spacetimedb_sdk::Table { + type InsertCallbackId; + + fn on_insert(&self, callback: impl FnMut(&EventContext, &Self::Row)) -> Self::InsertCallbackId; -sleep(Duration::from_secs(1)); - -// No `unreachable` panic. -``` - -#### Method `on_delete` - -```rust -TableType::on_delete( - callback: impl FnMut(&Self, Option<&ReducerEvent>) + Send + 'static, -) -> DeleteCallbackId + fn remove_on_insert(&self, callback: Self::InsertCallbackId); +} ``` -Register an `on_delete` callback for when a subscribed row is removed from the database. +The `on_insert` callback runs whenever a new row is inserted into the client cache, either when applying a subscription or being notified of a transaction. The passed [`EventContext`](#type-eventcontext) contains an [`Event`](#enum-event) which can identify the change which caused the insertion, and also allows the callback to interact with the connection, inspect the client cache and invoke reducers. -| Argument | Type | Meaning | -| ---------- | ----------------------------------------------------------- | ----------------------------------------------------- | -| `callback` | `impl FnMut(&Self, Option<&ReducerEvent>) + Send + 'static` | Callback to run whenever a subscribed row is deleted. | +Registering an `on_insert` callback returns a callback id, which can later be passed to `remove_on_insert` to cancel the callback. Newly registered or canceled callbacks do not take effect until the following event. -The callback takes two arguments: - -- `row: &Self`, the previously-present row which is no longer resident in the database. -- `reducer_event: Option<&ReducerEvent>`, the [`ReducerEvent`](#type-reducerevent) which caused this row to be deleted, or `None` if this row was previously subscribed but no longer matches the new queries while initializing a subscription. - -The returned `DeleteCallbackId` can be passed to [`remove_on_delete`](#method-remove_on_delete) to remove the callback. +#### Callback `on_delete` ```rust -connect(SPACETIMEDB_URI, DB_NAME, None) - .expect("Failed to connect"); - -User::on_delete(|user, reducer_event| { - if let Some(reducer_event) = reducer_event { - println!("User deleted by reducer {:?}: {:?}", reducer_event, user); - } else { - println!("User no longer subscribed during subscription update: {:?}", user); - } -}); - -subscribe(&["SELECT * FROM User;"]) - .unwrap(); - -// Invoke a reducer which will delete a `User` row. -delete_user_by_name("Tyler Cloutier".to_string()); +trait spacetimedb_sdk::Table { + type DeleteCallbackId; + + fn on_delete(&self, callback: impl FnMut(&EventContext, &Self::Row)) -> Self::DeleteCallbackId; -sleep(Duration::from_secs(1)); - -// Will print a note whenever a `User` row is inserted, -// including "User deleted by reducer ReducerEvent::DeleteUserByName( -// DeleteUserByNameArgs { name: "Tyler Cloutier" } -// ): User { first_name: "Tyler", surname: "Cloutier" }" -``` - -#### Method `remove_on_delete` - -```rust -TableType::remove_on_delete(id: DeleteCallbackId) + fn remove_on_delete(&self, callback: Self::DeleteCallbackId); +} ``` -Unregister a previously-registered [`on_delete`](#method-on_delete) callback. - -| Argument | Type | Meaning | -| -------- | ------------------------ | ----------------------------------------------------------------------- | -| `id` | `DeleteCallbackId` | Identifier for the [`on_delete`](#method-on_delete) callback to remove. | - -If `id` does not refer to a currently-registered callback, this operation does nothing. - -```rust -connect(SPACETIMEDB_URI, DB_NAME, None) - .expect("Failed to connect"); - -let id = User::on_delete(|_, _| unreachable!()); - -User::remove_on_delete(id); - -subscribe(&["SELECT * FROM User;"]) - .unwrap(); - -// Invoke a reducer which will delete a `User` row. -delete_user_by_name("Tyler Cloutier".to_string()); - -sleep(Duration::from_secs(1)); - -// No `unreachable` panic. -``` +The `on_delete` callback runs whenever a previously-resident row is deleted from the client cache. Registering an `on_delete` callback returns a callback id, which can later be passed to `remove_on_delete` to cancel the callback. Newly registered or canceled callbacks do not take effect until the following event. ### Trait `TableWithPrimaryKey` ```rust -spacetimedb_sdk::table::TableWithPrimaryKey -``` - -[Generated table structs](#type-table) with a column designated `#[primarykey]` implement the trait `TableWithPrimaryKey`. - -#### Method `on_update` - -```rust -TableWithPrimaryKey::on_update( - callback: impl FnMut(&Self, &Self, Option<&Self::ReducerEvent>) + Send + 'static, -) -> UpdateCallbackId -``` - -Register an `on_update` callback for when an existing row is modified. - -| Argument | Type | Meaning | -| ---------- | ------------------------------------------------------------------ | ----------------------------------------------------- | -| `callback` | `impl FnMut(&Self, &Self, Option<&ReducerEvent>) + Send + 'static` | Callback to run whenever a subscribed row is updated. | - -The callback takes three arguments: - -- `old: &Self`, the previous row value which has been replaced in the database. -- `new: &Self`, the updated row value which is now resident in the database. -- `reducer_event: Option<&ReducerEvent>`, the [`ReducerEvent`](#type-reducerevent) which caused this row to be inserted. - -The returned `UpdateCallbackId` can be passed to [`remove_on_update`](#method-remove_on_update) to remove the callback. - -```rust -connect(SPACETIMEDB_URI, DB_NAME, None) - .expect("Failed to connect"); - -User::on_update(|old, new, reducer_event| { - println!("User updated by reducer {:?}: from {:?} to {:?}", reducer_event, old, new); -}); - -subscribe(&["SELECT * FROM User;"]) - .unwrap(); - -// Prints a line whenever a `User` row is updated by primary key. -``` - -#### Method `remove_on_update` - -```rust -TableWithPrimaryKey::remove_on_update(id: UpdateCallbackId) -``` - -| Argument | Type | Meaning | -| -------- | ------------------------ | ----------------------------------------------------------------------- | -| `id` | `UpdateCallbackId` | Identifier for the [`on_update`](#method-on_update) callback to remove. | - -Unregister a previously-registered [`on_update`](#method-on_update) callback. - -If `id` does not refer to a currently-registered callback, this operation does nothing. - -```rust -connect(SPACETIMEDB_URI, DB_NAME, None) - .expect("Failed to connect"); - -let id = User::on_update(|_, _, _| unreachable!); - -User::remove_on_update(id); - -subscribe(&["SELECT * FROM User;"]) - .unwrap(); - -// No `unreachable` panic. -``` - -## Observe and request reducer invocations - -### Type `ReducerEvent` - -```rust -module_bindings::ReducerEvent +spacetimedb_sdk::TableWithPrimaryKey ``` -`spacetime generate` defines an enum `ReducerEvent` with a variant for each reducer defined by a module. The variant's name will be the reducer's name converted to `PascalCase`, and the variant will hold an instance of [the autogenerated reducer arguments struct for that reducer](#type-reducerargs). - -[`on_insert`](#method-on_insert), [`on_delete`](#method-on_delete) and [`on_update`](#method-on_update) callbacks accept an `Option<&ReducerEvent>` which identifies the reducer which caused the row to be inserted, deleted or updated. +Implemented for table handles whose tables have a primary key. -### Type `{REDUCER}Args` +#### Callback `on_delete` ```rust -module_bindings::{REDUCER}Args -``` - -For each reducer defined by a module, `spacetime generate` generates a struct whose name is that reducer's name converted to `PascalCase`, suffixed with `Args`. The generated struct has a field for each of the reducer's arguments, whose names are the argument names converted to `snake_case`. - -For reducers which accept a `ReducerContext` as their first argument, the `ReducerContext` is not included in the arguments struct. +trait spacetimedb_sdk::TableWithPrimaryKey { + type UpdateCallbackId; + + fn on_update(&self, callback: impl FnMut(&EventContext, &Self::Row, &Self::Row)) -> Self::UpdateCallbackId; -### Function `{REDUCER}` - -```rust -module_bindings::{REDUCER}({ARGS...}) + fn remove_on_update(&self, callback: Self::UpdateCallbackId); +} ``` -For each reducer defined by a module, `spacetime generate` generates a function which sends a request to the database to invoke that reducer. The generated function's name is the reducer's name converted to `snake_case`. - -For reducers which accept a `ReducerContext` as their first argument, the `ReducerContext` is not included in the generated function's argument list. - -### Function `on_{REDUCER}` - -```rust -module_bindings::on_{REDUCER}( - callback: impl FnMut(&Identity, Option
, Status, {&ARGS...}) + Send + 'static, -) -> ReducerCallbackId<{REDUCER}Args> -``` - -For each reducer defined by a module, `spacetime generate` generates a function which registers a `FnMut` callback to run each time the reducer is invoked. The generated functions are named `on_{REDUCER}`, where `{REDUCER}` is the reducer's name converted to `snake_case`. - -| Argument | Type | Meaning | -| ---------- | ----------------------------------------------------------------------------- | ------------------------------------------------ | -| `callback` | `impl FnMut(&Identity, Option
&Status, {&ARGS...}) + Send + 'static` | Callback to run whenever the reducer is invoked. | - -The callback always accepts three arguments: - -- `caller_id: &Identity`, the [`Identity`](#type-identity) of the client which invoked the reducer. -- `caller_address: Option
`, the [`Address`](#type-address) of the client which invoked the reducer. This may be `None` for scheduled reducers. +The `on_update` callback runs whenever an already-resident row in the client cache is updated, i.e. replaced with a new row that has the same primary key. Registering an `on_update` callback returns a callback id, which can later be passed to `remove_on_update` to cancel the callback. Newly registered or canceled callbacks do not take effect until the following event. -In addition, the callback accepts a reference to each of the reducer's arguments. +### Unique constraint index access -Clients will only be notified of reducer runs if either of two criteria is met: +For each unique constraint on a table, its table handle has a method whose name is the unique column name which returns a unique index handle. The unique index handle has a method `.find(desired_val: &Col) -> Option`, where `Col` is the type of the column, and `Row` the type of rows. If a row with `desired_val` in the unique column is resident in the client cache, `.find` returns it. -- The reducer inserted, deleted or updated at least one row to which the client is subscribed. -- The reducer invocation was requested by this client, and the run failed. +### BTree index access -The `on_{REDUCER}` function returns a `ReducerCallbackId<{REDUCER}Args>`, where `{REDUCER}Args` is the [generated reducer arguments struct](#type-reducerargs). This `ReducerCallbackId` can be passed to the [generated `remove_on_{REDUCER}` function](#function-remove_on_reducer) to cancel the callback. +Not currently implemented in the Rust SDK. Coming soon! -### Function `once_on_{REDUCER}` +## Observe and invoke reducers -```rust -module_bindings::once_on_{REDUCER}( - callback: impl FnOnce(&Identity, Option
, &Status, {&ARGS...}) + Send + 'static, -) -> ReducerCallbackId<{REDUCER}Args> -``` - -For each reducer defined by a module, `spacetime generate` generates a function which registers a `FnOnce` callback to run the next time the reducer is invoked. The generated functions are named `once_on_{REDUCER}`, where `{REDUCER}` is the reducer's name converted to `snake_case`. +Both [`DbConnection`](#type-dbconnection) and [`EventContext`](#type-eventcontext) have fields `.reducers`, which in turn has methods for invoking reducers defined by the module and registering callbacks on it. The trait method `DbContext::reducers(&self)` can also be used in contexts with an `impl DbContext` rather than a concrete-typed `EventContext` or `DbConnection`. -| Argument | Type | Meaning | -| ---------- | ------------------------------------------------------------------------------- | ----------------------------------------------------- | -| `callback` | `impl FnOnce(&Identity, Option
, &Status, {&ARGS...}) + Send + 'static` | Callback to run the next time the reducer is invoked. | +Each reducer defined by the module has three methods on the `.reducers`: -The callback accepts the same arguments as an [on-reducer callback](#function-on_reducer), but may be a `FnOnce` rather than a `FnMut`. +- An invoke method, whose name is the reducer's name converted to snake case. This requests that the module run the reducer. +- A callback registation method, whose name is prefixed with `on_`. This registers a callback to run whenever we are notified that the reducer ran, including successfully committed runs and runs we requested which failed. This method returns a callback id, which can be passed to the callback remove method. +- A callback remove method, whose name is prefixed with `remove_`. This cancels a callback previously registered via the callback registration method. -The callback will be invoked in the same circumstances as an on-reducer callback. - -The `once_on_{REDUCER}` function returns a `ReducerCallbackId<{REDUCER}Args>`, where `{REDUCER}Args` is the [generated reducer arguments struct](#type-reducerargs). This `ReducerCallbackId` can be passed to the [generated `remove_on_{REDUCER}` function](#function-remove_on_reducer) to cancel the callback. +## Identify a client -### Function `remove_on_{REDUCER}` +### Type `Identity` ```rust -module_bindings::remove_on_{REDUCER}(id: ReducerCallbackId<{REDUCER}Args>) +spacetimedb_sdk::Identity ``` -For each reducer defined by a module, `spacetime generate` generates a function which unregisters a previously-registered [on-reducer](#function-on_reducer) or [once-on-reducer](#function-once_on_reducer) callback. - -| Argument | Type | Meaning | -| -------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | -| `id` | `UpdateCallbackId` | Identifier for the [`on_{REDUCER}`](#function-on_reducer) or [`once_on_{REDUCER}`](#function-once_on_reducer) callback to remove. | - -If `id` does not refer to a currently-registered callback, this operation does nothing. +A unique public identifier for a client connected to a database. -### Type `Status` +### Type `Address` ```rust -spacetimedb_sdk::reducer::Status +spacetimedb_sdk::Address ``` -An enum whose variants represent possible reducer completion statuses. - -A `Status` is passed as the second argument to [`on_{REDUCER}`](#function-on_reducer) and [`once_on_{REDUCER}`](#function-once_on_reducer) callbacks. - -#### Variant `Status::Committed` - -The reducer finished successfully, and its row changes were committed to the database. - -#### Variant `Status::Failed(String)` - -The reducer failed, either by panicking or returning an `Err`. - -| Field | Type | Meaning | -| ----- | -------- | --------------------------------------------------- | -| 0 | `String` | The error message which caused the reducer to fail. | - -#### Variant `Status::OutOfEnergy` - -The reducer was canceled because the module owner had insufficient energy to allow it to run to completion. +An opaque identifier for a client connection to a database, intended to differentiate between connections from the same [`Identity`](#type-identity). This will be removed in a future SpacetimeDB version in favor of a connection or session ID. From e6b3f3d621148c0ae738d31bd0fb3f921d5a38a4 Mon Sep 17 00:00:00 2001 From: John Detter <4099508+jdetter@users.noreply.github.com> Date: Thu, 3 Oct 2024 02:32:27 -0500 Subject: [PATCH 070/164] Updated rust quickstart for 0.12 (#88) * Updated rust quickstart for 0.12 * Suggested tweaks --------- Co-authored-by: John Detter --- docs/modules/rust/quickstart.md | 91 +++++++++++++++++---------------- 1 file changed, 48 insertions(+), 43 deletions(-) diff --git a/docs/modules/rust/quickstart.md b/docs/modules/rust/quickstart.md index d3544f19..9fcfe30d 100644 --- a/docs/modules/rust/quickstart.md +++ b/docs/modules/rust/quickstart.md @@ -2,27 +2,28 @@ In this tutorial, we'll implement a simple chat server as a SpacetimeDB module. -A SpacetimeDB module is code that gets compiled to WebAssembly and is uploaded to SpacetimeDB. This code becomes server-side logic that interfaces directly with the Spacetime relational database. +A SpacetimeDB module is code that gets compiled to a WebAssembly binary and is uploaded to SpacetimeDB. This code becomes server-side logic that interfaces directly with the SpacetimeDB relational database. Each SpacetimeDB module defines a set of tables and a set of reducers. -Each table is defined as a Rust `struct` annotated with `#[spacetimedb(table)]`, where an instance represents a row, and each field represents a column. +Each table is defined as a Rust struct annotated with `#[table(name = table_name)]`. An instance of the struct represents a row, and each field represents a column. + By default, tables are **private**. This means that they are only readable by the table owner, and by server module code. -The `#[spacetimedb(table(public))]` macro makes a table public. **Public** tables are readable by all users, but can still only be modified by your server module code. +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. _Coming soon: We plan to add much more robust access controls than just public or private. Stay tuned!_ -A reducer is a function which 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. In Rust, reducers are defined as functions annotated with `#[spacetimedb(reducer)]`, and may return a `Result<()>`, with an `Err` return aborting the transaction. +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. In Rust, reducers are defined as functions annotated with `#[reducer]`, and may return a `Result<()>`, with an `Err` return aborting the transaction. ## Install SpacetimeDB -If you haven't already, start by [installing SpacetimeDB](/install). This will install the `spacetime` command line interface (CLI), which contains all the functionality for interacting with SpacetimeDB. +If you haven't already, start by [installing SpacetimeDB](/install). This will install the `spacetime` command line interface (CLI), which provides all the functionality needed to interact with SpacetimeDB. ## Install Rust Next we need to [install Rust](https://www.rust-lang.org/tools/install) so that we can create our database module. -On MacOS and Linux run this command to install the Rust compiler: +On macOS and Linux run this command to install the Rust compiler: ```bash curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh @@ -47,17 +48,19 @@ spacetime init --lang rust server ## Declare imports -`spacetime init` should have pre-populated `server/src/lib.rs` with a trivial module. Clear it out, so we can write a module that's still pretty simple: a bare-bones chat server. +`spacetime init` should have pre-populated `server/src/lib.rs` with a trivial module. Clear it out so we can write a new, simple module: a bare-bones chat server. To the top of `server/src/lib.rs`, add some imports we'll be using: ```rust -use spacetimedb::{spacetimedb, ReducerContext, Identity, Timestamp}; +use spacetimedb::{table, reducer, Table, ReducerContext, Identity, Timestamp}; ``` From `spacetimedb`, we import: -- `spacetimedb`, an attribute macro we'll use to define tables and reducers. +- `table`, a macro used to define SpacetimeDB tables. +- `reducer`, a macro used to define SpacetimeDB reducers. +- `Table`, a rust trait which allows us to interact with tables. - `ReducerContext`, a special argument passed to each reducer. - `Identity`, a unique identifier for each user. - `Timestamp`, a point in time. Specifically, an unsigned 64-bit count of milliseconds since the UNIX epoch. @@ -71,9 +74,9 @@ For each `User`, we'll store their `Identity`, an optional name they can set to To `server/src/lib.rs`, add the definition of the table `User`: ```rust -#[spacetimedb(table(public))] +#[table(name = user, public)] pub struct User { - #[primarykey] + #[primary_key] identity: Identity, name: Option, online: bool, @@ -85,7 +88,7 @@ For each `Message`, we'll store the `Identity` of the user who sent it, the `Tim To `server/src/lib.rs`, add the definition of the table `Message`: ```rust -#[spacetimedb(table(public))] +#[table(name = message, public)] pub struct Message { sender: Identity, sent: Timestamp, @@ -97,19 +100,19 @@ pub struct Message { We want to allow users to set their names, because `Identity` is not a terribly user-friendly identifier. To that effect, we define a reducer `set_name` which clients can invoke to set their `User.name`. It will validate the caller's chosen name, using a function `validate_name` which we'll define next, then look up the `User` record for the caller and update it to store the validated name. If the name fails the validation, the reducer will fail. -Each reducer may accept as its first argument a `ReducerContext`, which includes the `Identity` and `Address` of the client that called the reducer, and the `Timestamp` when it was invoked. For now, we only need the `Identity`, `ctx.sender`. +Each reducer may accept as its first argument a `ReducerContext`, which includes the `Identity` and `Address` of the client that called the reducer, and the `Timestamp` when it was invoked. It also allows us access to the `db`, which is used to read and manipulate rows in our tables. For now, we only need the `db`, `Identity`, and `ctx.sender`. It's also possible to call `set_name` via the SpacetimeDB CLI's `spacetime call` command without a connection, in which case no `User` record will exist for the caller. We'll return an error in this case, but you could alter the reducer to insert a `User` row for the module owner. You'll have to decide whether the module owner is always online or always offline, though. To `server/src/lib.rs`, add: ```rust -#[spacetimedb(reducer)] -/// Clientss invoke this reducer to set their user names. -pub fn set_name(ctx: ReducerContext, name: String) -> Result<(), String> { +#[reducer] +/// Clients invoke this reducer to set their user names. +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 }); + if let Some(user) = ctx.db.user().identity().find(ctx.sender) { + ctx.db.user().identity().update(User { name: Some(name), ..user }) Ok(()) } else { Err("Cannot set name for unknown user".to_string()) @@ -140,17 +143,17 @@ fn validate_name(name: String) -> Result { ## Send messages -We define a reducer `send_message`, which clients will call to send messages. It will validate the message's text, then insert a new `Message` record using `Message::insert`, with the `sender` identity and `sent` timestamp taken from the `ReducerContext`. Because `Message` does not have any columns with unique constraints, `Message::insert` is infallible; it does not return a `Result`. +We define a reducer `send_message`, which clients will call to send messages. It will validate the message's text, then insert a new `Message` record using `ctx.db.message().insert(..)`, with the `sender` identity and `sent` timestamp taken from the `ReducerContext`. Because the `Message` table does not have any columns with a unique constraint, `ctx.db.message().insert()` is infallible and does not return a `Result`. To `server/src/lib.rs`, add: ```rust -#[spacetimedb(reducer)] +#[reducer] /// Clients invoke this reducer to send messages. -pub fn send_message(ctx: ReducerContext, text: String) -> Result<(), String> { +pub fn send_message(ctx: &ReducerContext, text: String) -> Result<(), String> { let text = validate_message(text)?; log::info!("{}", text); - Message::insert(Message { + ctx.db.message().insert(Message { sender: ctx.sender, text, sent: ctx.timestamp, @@ -181,40 +184,39 @@ You could extend the validation in `validate_message` in similar ways to `valida ## Set users' online status -Whenever a client connects, the module will run a special reducer, annotated with `#[spacetimedb(connect)]`, if it's defined. By convention, it's named `identity_connected`. We'll use it to create a `User` record for the client if it doesn't yet exist, and to set its online status. +Whenever a client connects, the module will run a special reducer, annotated with `#[reducer(client_connected)]`, if it's defined. By convention, it's named `client_connected`. We'll use it to create a `User` record for the client if it doesn't yet exist, and to set its online status. -We'll use `User::filter_by_identity` to look up a `User` row for `ctx.sender`, if one exists. If we find one, we'll use `User::update_by_identity` to overwrite it with a row that has `online: true`. If not, we'll use `User::insert` to insert a new row for our new user. All three of these methods are generated by the `#[spacetimedb(table)]` macro, with rows and behavior based on the row attributes. `filter_by_identity` returns an `Option`, because the unique constraint from the `#[primarykey]` attribute means there will be either zero or one matching rows. `insert` returns a `Result<(), UniqueConstraintViolation>` because of the same unique constraint; if we want to overwrite a `User` row, we need to do so explicitly using `update_by_identity`. +We'll use `ctx.db.user().identity().find(ctx.sender)` to look up a `User` row for `ctx.sender`, if one exists. If we find one, we'll use `ctx.db.user().identity().update(..)` to overwrite it with a row that has `online: true`. If not, we'll use `ctx.db.user().insert(..)` to insert a new row for our new user. All three of these methods are generated by the `#[table(..)]` macro, with rows and behavior based on the row attributes. `ctx.db.user().find(..)` returns an `Option`, because of the unique constraint from the `#[primary_key]` attribute. This means there will be either zero or one matching rows. If we used `try_insert` here it would return a `Result<(), UniqueConstraintViolation>` because of the same unique constraint. However, because we're already checking if there is a user with the given sender identity we know that inserting into this table will not fail. Therefore, we use `insert`, which automatically unwraps the result, simplifying the code. If we want to overwrite a `User` row, we need to do so explicitly using `ctx.db.user().identity().update(..)`. To `server/src/lib.rs`, add the definition of the connect reducer: ```rust -#[spacetimedb(connect)] +#[reducer(client_connected)] // Called when a client connects to the SpacetimeDB -pub fn identity_connected(ctx: ReducerContext) { - if let Some(user) = User::filter_by_identity(&ctx.sender) { +pub fn client_connected(ctx: &ReducerContext) { + if let Some(user) = ctx.db.user().identity().find(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 }); + ctx.db.user().identity().update(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 { + ctx.db.user().insert(User { name: None, identity: ctx.sender, online: true, - }).unwrap(); + }); } -} -``` +}``` -Similarly, whenever a client disconnects, the module will run the `#[spacetimedb(disconnect)]` reducer if it's defined. By convention, it's named `identity_disconnect`. We'll use it to un-set the `online` status of the `User` for the disconnected client. +Similarly, whenever a client disconnects, the module will run the `#[reducer(client_disconnected)]` reducer if it's defined. By convention, it's named `client_disconnected`. We'll use it to un-set the `online` status of the `User` for the disconnected client. ```rust -#[spacetimedb(disconnect)] +#[reducer(client_disconnected)] // Called when a client disconnects from SpacetimeDB -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 }); +pub fn identity_disconnected(ctx: &ReducerContext) { + if let Some(user) = ctx.db.user().identity().find(ctx.sender) { + ctx.db.user().identity().update(User { online: false, ..user }); } else { // This branch should be unreachable, // as it doesn't make sense for a client to disconnect without connecting first. @@ -225,7 +227,7 @@ pub fn identity_disconnected(ctx: ReducerContext) { ## Publish the module -And that's all of our module code! We'll run `spacetime publish` to compile our module and publish it on SpacetimeDB. `spacetime publish` takes an optional name which will map to the database's unique address. Clients can connect either by name or by address, but names are much more pleasant. Come up with a unique name that contains only URL-safe characters (letters, numbers, hyphens and underscores), and fill it in where we've written ``. +And that's all of our module code! We'll run `spacetime publish` to compile our module and publish it on SpacetimeDB. `spacetime publish` takes an optional name which will map to the database's unique address. Clients can connect either by name or by address, but names are much more user-friendly. Come up with a unique name that contains only URL-safe characters (letters, numbers, hyphens and underscores), and fill it in where we've written ``. From the `quickstart-chat` directory, run: @@ -250,7 +252,10 @@ spacetime logs You should now see the output that your module printed in the database. ```bash -info: Hello, World! + INFO: spacetimedb: Creating table `message` + INFO: spacetimedb: Creating table `user` + INFO: spacetimedb: Database initialized + INFO: src/lib.rs:43: Hello, world! ``` ## SQL Queries @@ -258,13 +263,13 @@ info: Hello, World! SpacetimeDB supports a subset of the SQL syntax so that you can easily query the data of your database. We can run a query using the `sql` command. ```bash -spacetime sql "SELECT * FROM Message" +spacetime sql "SELECT * FROM message" ``` ```bash - text ---------- - "Hello, World!" + sender | sent | text +--------------------------------------------------------------------+------------------+----------------- + 0x93dda09db9a56d8fa6c024d843e805d8262191db3b4ba84c5efcd1ad451fed4e | 1727858455560802 | "Hello, world!" ``` ## What's next? From ed651d71958c516ce1b60b65f922f039a2e911b3 Mon Sep 17 00:00:00 2001 From: John Detter <4099508+jdetter@users.noreply.github.com> Date: Thu, 3 Oct 2024 02:32:41 -0500 Subject: [PATCH 071/164] Update rust index page for 0.12 (#89) * Updated rust quickstart for 0.12 * Suggested tweaks * Initial updates to the index file * More updates to index, rolled back changes from another PR I'm working on * Small improvements --------- Co-authored-by: John Detter --- docs/modules/rust/index.md | 150 ++++++++++++++++++------------------- 1 file changed, 75 insertions(+), 75 deletions(-) diff --git a/docs/modules/rust/index.md b/docs/modules/rust/index.md index 443f8171..83a751be 100644 --- a/docs/modules/rust/index.md +++ b/docs/modules/rust/index.md @@ -23,7 +23,7 @@ struct Location { Let's start with a highly commented example, straight from the [demo]. This Rust package defines a SpacetimeDB module, with types we can operate on and functions we can run. ```rust -// In this small example, we have two rust imports: +// In this small example, we have two Rust imports: // |spacetimedb::spacetimedb| is the most important attribute we'll be using. // |spacetimedb::println| is like regular old |println|, but outputting to the module's logs. use spacetimedb::{spacetimedb, println}; @@ -31,7 +31,7 @@ use spacetimedb::{spacetimedb, println}; // This macro lets us interact with a SpacetimeDB table of Person rows. // We can insert and delete into, and query, this table by the collection // of functions generated by the macro. -#[spacetimedb(table(public))] +#[table(name = person, public)] pub struct Person { name: String, } @@ -39,26 +39,26 @@ pub struct Person { // This is the other key macro we will be using. A reducer is a // stored procedure that lives in the database, and which can // be invoked remotely. -#[spacetimedb(reducer)] -pub fn add(name: String) { +#[reducer] +pub fn add(ctx: &ReducerContext, name: String) { // |Person| is a totally ordinary Rust struct. We can construct // one from the given name as we typically would. let person = Person { name }; // Here's our first generated function! Given a |Person| object, // we can insert it into the table: - Person::insert(person) + ctx.db.person().insert(person); } // Here's another reducer. Notice that this one doesn't take any arguments, while // |add| did take one. Reducers can take any number of arguments, as long as -// SpacetimeDB knows about all their types. Reducers also have to be top level +// SpacetimeDB recognizes their types. Reducers also have to be top level // functions, not methods. -#[spacetimedb(reducer)] -pub fn say_hello() { +#[reducer] +pub fn say_hello(ctx: &ReducerContext) { // Here's the next of our generated functions: |iter()|. This // iterates over all the columns in the |Person| table in SpacetimeDB. - for person in Person::iter() { + for person in ctx.db.person().iter() { // Reducers run in a very constrained and sandboxed environment, // and in particular, can't do most I/O from the Rust standard library. // We provide an alternative |spacetimedb::println| which is just like @@ -72,13 +72,13 @@ pub fn say_hello() { // the reducer must have a return type of `Result<(), T>`, for any `T` that // implements `Debug`. Such errors returned from reducers will be formatted and // printed out to logs. -#[spacetimedb(reducer)] -pub fn add_person(name: String) -> Result<(), String> { +#[reducer] +pub fn add_person(ctx: &ReducerContext, name: String) -> Result<(), String> { if name.is_empty() { return Err("Name cannot be empty"); } - Person::insert(Person { name }) + ctx.db.person().insert(Person { name }) } ``` @@ -88,15 +88,15 @@ Now we'll get into details on all the macro APIs SpacetimeDB provides, starting ### Defining tables -The `#[spacetimedb(table)]` is applied to a Rust struct with named fields. +The `#[table(name = table_name)]` macro is applied to a Rust struct with named fields. By default, tables are considered **private**. This means that they are only readable by the table owner, and by server module code. -The `#[spacetimedb(table(public))]` macro makes a table public. **Public** tables are readable by all users, but can still only be modified by your server module code. +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. _Coming soon: We plan to add much more robust access controls than just public or private. Stay tuned!_ ```rust -#[spacetimedb(table(public))] -struct Table { +#[table(name = my_table, public)] +struct MyTable { field1: String, field2: u32, } @@ -104,7 +104,7 @@ struct Table { This attribute is applied to Rust structs in order to create corresponding tables in SpacetimeDB. Fields of the Rust struct correspond to columns of the database table. -The fields of the struct have to be types that spacetimedb knows how to encode into the database. This is captured in Rust by the `SpacetimeType` trait. +The fields of the struct have to be types that SpacetimeDB knows how to encode into the database. This is captured in Rust by the `SpacetimeType` trait. This is automatically defined for built in numeric types: @@ -120,10 +120,10 @@ And common data structures: - `Option where T: SpacetimeType` - `Vec where T: SpacetimeType` -All `#[spacetimedb(table)]` types are `SpacetimeType`s, and accordingly, all of their fields have to be. +All `#[table(..)]` types are `SpacetimeType`s, and accordingly, all of their fields have to be. ```rust -#[spacetimedb(table(public))] +#[table(name = another_table, public)] struct AnotherTable { // Fine, some builtin types. id: u64, @@ -155,7 +155,7 @@ enum Serial { Once the table is created via the macro, other attributes described below can control more aspects of the table. For instance, a particular column can be indexed, or take on values of an automatically incremented counter. These are described in detail below. ```rust -#[spacetimedb(table(public))] +#[table(name = person, public)] struct Person { #[unique] id: u64, @@ -167,30 +167,30 @@ struct Person { ### Defining reducers -`#[spacetimedb(reducer)]` is always applied to top level Rust functions. They can take arguments of types known to SpacetimeDB (just like fields of structs must be known to SpacetimeDB), and either return nothing, or return a `Result<(), E: Debug>`. +`#[reducer]` is always applied to top level Rust functions. They can take arguments of types known to SpacetimeDB (just like fields of structs must be known to SpacetimeDB), and either return nothing, or return a `Result<(), E: Debug>`. ```rust -#[spacetimedb(reducer)] -fn give_player_item(player_id: u64, item_id: u64) -> Result<(), GameErr> { +#[reducer] +fn give_player_item(ctx: &ReducerContext, player_id: u64, item_id: u64) -> Result<(), GameErr> { // Notice how the exact name of the filter function derives from // the name of the field of the struct. - let mut item = Item::find_by_item_id(id).ok_or(GameErr::InvalidId)?; + let mut item = ctx.db.item().item_id().find(id).ok_or(GameErr::InvalidId)?; item.owner = Some(player_id); - Item::update_by_id(id, item); + ctx.db.item().item_id().update(item); Ok(()) } +#[table(name = item, public)] struct Item { - #[unique] + #[primary_key] item_id: u64, - owner: Option, } ``` Note that reducers can call non-reducer functions, including standard library functions. -There are several macros which modify the semantics of a column, which are applied to the members of the table struct. `#[unique]` and `#[autoinc]` are covered below, describing how those attributes affect the semantics of inserting, filtering, and so on. +There are several macros which modify the semantics of a column, which are applied to the members of the table struct. `#[primary_key]`, `#[unique]` and `#[autoinc]` are covered below, describing how those attributes affect the semantics of inserting, filtering, and so on. #[SpacetimeType] @@ -202,7 +202,7 @@ Tables can be used to schedule a reducer calls either at a specific timestamp or ```rust // The `scheduled` attribute links this table to a reducer. -#[spacetimedb(table, scheduled(send_message))] +#[table(name = send_message_timer, scheduled(send_message)] struct SendMessageTimer { text: String, } @@ -211,10 +211,10 @@ struct SendMessageTimer { The `scheduled` attribute adds a couple of default fields and expands as follows: ```rust -#[spacetimedb(table)] +#[table(name = send_message_timer, scheduled(send_message)] struct SendMessageTimer { text: String, // original field - #[primary] + #[primary_key] #[autoinc] scheduled_id: u64, // identifier for internal purpose scheduled_at: ScheduleAt, //schedule details @@ -230,21 +230,21 @@ pub enum ScheduleAt { } ``` -Managing timers with scheduled table is as simple as inserting or deleting rows from table. +Managing timers with a scheduled table is as simple as inserting or deleting rows from the table. ```rust -#[spacetimedb(reducer)] - -// Reducers linked to the scheduler table should have their first argument as `ReducerContext` +#[reducer] +// Reducers linked to the scheduler table should have their first argument as `&ReducerContext` // and the second as an instance of the table struct it is linked to. -fn send_message(ctx: ReducerContext, arg: SendMessageTimer) -> Result<(), String> { +fn send_message(ctx: &ReducerContext, arg: SendMessageTimer) -> Result<(), String> { // ... } // Scheduling reducers inside `init` reducer -fn init() { +#[reducer(init)] +fn init(ctx: &ReducerContext) { // Scheduling a reducer for a specific Timestamp - SendMessageTimer::insert(SendMessageTimer { + ctx.db.send_message_timer().insert(SendMessageTimer { scheduled_id: 1, text:"bot sending a message".to_string(), //`spacetimedb::Timestamp` implements `From` trait to `ScheduleAt::Time`. @@ -252,7 +252,7 @@ fn init() { }); // Scheduling a reducer to be called at fixed interval of 100 milliseconds. - SendMessageTimer::insert(SendMessageTimer { + ctx.db.send_message_timer().insert(SendMessageTimer { scheduled_id: 0, text:"bot sending a message".to_string(), //`std::time::Duration` implements `From` trait to `ScheduleAt::Duration`. @@ -282,8 +282,8 @@ use spacetimedb::{ dbg, }; -#[spacetimedb(reducer)] -fn output(i: i32) { +#[reducer] +fn output(ctx: &ReducerContext, i: i32) { // These will be logged at log::Level::Info. println!("an int with a trailing newline: {i}"); print!("some more text...\n"); @@ -297,7 +297,7 @@ fn output(i: i32) { // before passing the value of |i| along to the calling function. // // The output is logged log::Level::Debug. - OutputtedNumbers::insert(dbg!(i)); + ctx.db.outputted_number().insert(dbg!(i)); } ``` @@ -308,7 +308,7 @@ We'll work off these structs to see what functions SpacetimeDB generates: This table has a plain old column. ```rust -#[spacetimedb(table(public))] +#[table(name = ordinary, public)] struct Ordinary { ordinary_field: u64, } @@ -317,7 +317,7 @@ struct Ordinary { This table has a unique column. Every row in the `Unique` table must have distinct values of the `unique_field` column. Attempting to insert a row with a duplicate value will fail. ```rust -#[spacetimedb(table(public))] +#[table(name = unique, public)] struct Unique { // A unique column: #[unique] @@ -330,7 +330,7 @@ This table has an automatically incrementing column. SpacetimeDB automatically p Only integer types can be `#[unique]`: `u8`, `u16`, `u32`, `u64`, `u128`, `i8`, `i16`, `i32`, `i64` and `i128`. ```rust -#[spacetimedb(table(public))] +#[table(name = autoinc, public)] struct Autoinc { #[autoinc] autoinc_field: u64, @@ -340,7 +340,7 @@ struct Autoinc { These attributes can be combined, to create an automatically assigned ID usable for filtering. ```rust -#[spacetimedb(table(public))] +#[table(name = identity, public)] struct Identity { #[autoinc] #[unique] @@ -352,15 +352,15 @@ struct Identity { We'll talk about insertion first, as there a couple of special semantics to know about. -When we define |Ordinary| as a spacetimedb table, we get the ability to insert into it with the generated `Ordinary::insert` method. +When we define |Ordinary| as a SpacetimeDB table, we get the ability to insert into it with the generated `ctx.db.ordinary().insert(..)` method. Inserting takes a single argument, the row to insert. When there are no unique fields in the row, the return value is the inserted row. ```rust -#[spacetimedb(reducer)] -fn insert_ordinary(value: u64) { +#[reducer] +fn insert_ordinary(ctx: &ReducerContext, value: u64) { let ordinary = Ordinary { ordinary_field: value }; - let result = Ordinary::insert(ordinary); + let result = ctx.db.ordinary().insert(ordinary); assert_eq!(ordinary.ordinary_field, result.ordinary_field); } ``` @@ -370,12 +370,12 @@ When there is a unique column constraint on the table, insertion can fail if a u If we insert two rows which have the same value of a unique column, the second will fail. ```rust -#[spacetimedb(reducer)] -fn insert_unique(value: u64) { - let result = Unique::insert(Unique { unique_field: value }); +#[reducer] +fn insert_unique(ctx: &ReducerContext, value: u64) { + let result = ctx.db.unique().insert(Unique { unique_field: value }); assert!(result.is_ok()); - let result = Unique::insert(Unique { unique_field: value }); + let result = ctx.db.unique().insert(Unique { unique_field: value }); assert!(result.is_err()); } ``` @@ -385,26 +385,26 @@ When inserting a table with an `#[autoinc]` column, the database will automatica The returned row has the `autoinc` column set to the value that was actually written into the database. ```rust -#[spacetimedb(reducer)] -fn insert_autoinc() { +#[reducer] +fn insert_autoinc(ctx: &ReducerContext) { for i in 1..=10 { // These will have values of 1, 2, ..., 10 // at rest in the database, regardless of // what value is actually present in the // insert call. - let actual = Autoinc::insert(Autoinc { autoinc_field: 23 }) + let actual = ctx.db.autoinc().insert(Autoinc { autoinc_field: 23 }) assert_eq!(actual.autoinc_field, i); } } -#[spacetimedb(reducer)] -fn insert_id() { +#[reducer] +fn insert_id(ctx: &ReducerContext) { for _ in 0..10 { // These also will have values of 1, 2, ..., 10. // There's no collision and silent failure to insert, // because the value of the field is ignored and overwritten // with the automatically incremented value. - Identity::insert(Identity { id_field: 23 }) + ctx.db.identity().insert(Identity { id_field: 23 }) } } ``` @@ -414,7 +414,7 @@ fn insert_id() { Given a table, we can iterate over all the rows in it. ```rust -#[spacetimedb(table(public))] +#[table(name = person, public)] struct Person { #[unique] id: u64, @@ -425,20 +425,20 @@ struct Person { } ``` -// Every table structure an iter function, like: +// Every table structure has a generated iter function, like: ```rust -fn MyTable::iter() -> TableIter +ctx.db.my_table().iter() ``` `iter()` returns a regular old Rust iterator, giving us a sequence of `Person`. The database sends us over rows, one at a time, for each time through the loop. This means we get them by value, and own the contents of `String` fields and so on. ``` -#[spacetimedb(reducer)] -fn iteration() { +#[reducer] +fn iteration(ctx: &ReducerContext) { let mut addresses = HashSet::new(); - for person in Person::iter() { + for person in ctx.db.person().iter() { addresses.insert(person.address); } @@ -457,9 +457,9 @@ Our `Person` table has a unique id column, so we can filter for a row matching t The name of the filter method just corresponds to the column name. ```rust -#[spacetimedb(reducer)] -fn filtering(id: u64) { - match Person::find_by_id(&id) { +#[reducer] +fn filtering(ctx: &ReducerContext, id: u64) { + match ctx.db.person().id().find(id) { Some(person) => println!("Found {person}"), None => println!("No person with id {id}"), } @@ -469,9 +469,9 @@ fn filtering(id: u64) { Our `Person` table also has a column for age. Unlike IDs, ages aren't unique. Filtering for every person who is 21, then, gives us an `Iterator` rather than an `Option`. ```rust -#[spacetimedb(reducer)] -fn filtering_non_unique() { - for person in Person::find_by_age(&21) { +#[reducer] +fn filtering_non_unique(ctx: &ReducerContext) { + for person in ctx.db.person().age().find(21) { println!("{person} has turned 21"); } } @@ -482,9 +482,9 @@ fn filtering_non_unique() { Like filtering, we can delete by a unique column instead of the entire row. ```rust -#[spacetimedb(reducer)] -fn delete_id(id: u64) { - Person::delete_by_id(&id) +#[reducer] +fn delete_id(ctx: &ReducerContext, id: u64) { + ctx.db.person().id().delete(id) } ``` From 7de9e7b57a4e97a826f6457d6be44ecbee1ef68b Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Fri, 4 Oct 2024 02:39:05 -0400 Subject: [PATCH 072/164] Added migration guide for v0.12 (#95) * Added initial migration guide for v0.12 * My C# additions so far * [v0.12-migration-guide]: build and style fixes * Polished migration guide * [v0.12-migration-guide]: docs update * [v0.12-migration-guide]: C# TODOs * [v0.12-migration-guide]: review --------- Co-authored-by: John Detter <4099508+jdetter@users.noreply.github.com> Co-authored-by: Zeke Foppa --- docs/migration/v0.12.md | 341 ++++++++++++++++++++++++++++++++++++++++ docs/nav.js | 118 ++++++-------- nav.ts | 3 + 3 files changed, 394 insertions(+), 68 deletions(-) create mode 100644 docs/migration/v0.12.md diff --git a/docs/migration/v0.12.md b/docs/migration/v0.12.md new file mode 100644 index 00000000..9384407f --- /dev/null +++ b/docs/migration/v0.12.md @@ -0,0 +1,341 @@ +# Updating your app for SpacetimeDB v0.12 + +We're excited to release SpacetimeDB v0.12, which includes a major overhaul of our Rust, C# and TypeScript APIs for both modules and clients. In no particular order, our goals with this rewrite were: + +- Our APIs should be as similar as possible in all three languages we support, and in clients and modules, so that you don't have to go to a ton of work figuring out why something works in one place but not somewhere else. +- We should be very explicit about what operations interact with the database and how. In addition to good hygiene, this means that a client can now connect to multiple remote modules at the same time without getting confused. (Some day a module will be able to connect to remote modules too, but we're not there yet.) +- Our APIs should expose low level database operations so you can program your applications to have predictable performance characteristics. An indexed lookup should look different in your code from a full scan, and writing the indexed lookup should be easier. This will help you write your apps as efficiently as possible as we add features to SpacetimeDB. (In the future, as we get more sophisticated at optimizing and evaluating queries, we will offer a higher level logical query API which let's us implement very high performance optimizations and abstract away concerns like indices.) + +The new APIs are a significant improvement to the developer experience of SpacetimeDB and enable some amazing features in the future. They're completely new APIs, so if you run into any trouble, please [ask us for help or share your feedback on Discord!](https://discord.gg/spacetimedb) + +To start migrating, update your SpacetimeDB CLI, and bump the `spacetimedb` and `spacetimedb-sdk` dependency versions to 0.12 in your module and client respectively. + +## Modules + +### The reducer context + +All your reducers must now accept a reducer context as their first argument. In Rust, this is now taken by reference, as `&ReducerContext`. All access to tables now go through methods on the `db` or `Db` field of the `ReducerContext`. + +```rust +#[spacetimedb::reducer] +fn my_reducer(ctx: &ReducerContext) { + for row in ctx.db.my_table().iter() { + // Do something with the row... + } +} +``` + +```csharp +[SpacetimeDB.Reducer] +public static void MyReducer(ReducerContext ctx) { + foreach (var row in ctx.Db.MyTable.Iter()) { + // Do something with the row... + } +} +``` + +### Table names and access methods + +You now must specify a name for every table, distinct from the type name. In Rust, write this as `#[spacetimedb::table(name = my_table)]`. The name you specify here will be the method on `ctx.db` you use to access the table. + +```rust +#[spacetimedb::table(name = my_table)] +struct MyTable { + #[primary_key] + #[auto_inc] + id: u64, + other_column: u32, +} +``` + +```csharp +[SpacetimeDB.Table(Name = "MyTable")] +public partial struct MyTable +{ + [SpacetimeDB.PrimaryKey] + [SpacetimeDB.AutoInc] + public long Id; + public int OtherColumn; +} +``` + +One neat upside of this is that you can now have multiple tables with the same row type! + +```rust +#[spacetimedb::table(name = signed_in_user)] +#[spacetimedb::table(name = signed_out_user)] +struct User { + #[primary_key] + id: Identity, + #[unique] + username: String, +} +``` + +```csharp +[SpacetimeDB.Table(Name = "SignedInUser")] +[SpacetimeDB.Table(Name = "SignedOutUser")] +public partial struct User +{ + [SpacetimeDB.PrimaryKey] + public SpacetimeDB.Identity Id; + [SpacetimeDB.Unique] + public String Username; +} +``` + +### Iterating, counting, inserting, deleting + +Each "table handle" `ctx.db.my_table()` has methods: + +| Rust name | C# name | Behavior | +|-----------|----------|-----------------------------------------| +| `iter` | `Iter` | Iterate over all rows in the table. | +| `count` | `Count` | Return the number of rows in the table. | +| `insert` | `Insert` | Add a new row to the table. | +| `delete` | `Delete` | Delete a given row from the table. | + +### Index access + +Each table handle also has a method for each BTree index and/or unique constraint on the table, which allows you to filter, delete or update by that index. BTree indices' filter and delete methods accept both point and range queries. + +```rust +#[spacetimedb::table( + name = entity, + index(name = location, btree = [x, y]), +)] +struct Entity { + #[primary_key] + #[auto_inc] + id: u64, + x: u32, + y: u32, + #[index(btree)] + faction: String, +} + +#[spacetimedb::reducer] +fn move_entity(ctx: &ReducerContext, entity_id: u64, x: u32, y: u32) { + let entity = ctx.db.entity().id().find(entity_id).expect("No such entity"); + ctx.db.entity.id().update(Entity { x, y, ..entity }); +} + +#[spacetimedb::reducer] +fn log_entities_at_point(ctx: &ReducerContext, x: u32, y: u32) { + for entity in ctx.db.entity().location().filter((x, y)) { + log::info!("Entity {} is at ({}, {})", entity.id, x, y); + } +} + +#[spacetimedb::reducer] +fn delete_faction(ctx: &ReducerContext, faction: String) { + ctx.db.entity().faction().delete(&faction); +} +``` + +```csharp +[SpacetimeDB.Table(Name = "Entity")] +[SpacetimeDB.Table(Name = "SignedOutUser")] +[SpacetimeDB.Index(Name = "Location", BTree = ["X", "Y"])] +[SpacetimeDB.Index(Name = "Faction", BTree = ["Faction"])] +public partial struct Entity +{ + [SpacetimeDB.PrimaryKey] + [SpacetimeDB.AutoInc] + public long Id; + public int X; + public int Y; + public string Faction; +} + +[SpacetimeDB.Reducer] +public static void MoveEntity(SpacetimeDB.ReducerContext ctx, long entityId, int x, int y) { + var entity = ctx.Db.Entity.Id.Find(entityId); + ctx.Db.Entity.Id.Update(new Entity { + Id = entityId, + X = x, + Y = y, + Faction = entity.Faction, + }); +} + +[SpacetimeDB.Reducer] +public static void LogEntitiesAtPoint(SpacetimeDB.ReducerContext ctx, int x, int y) { + foreach(var entity in ctx.Db.Entity.Location.Filter((x, y))) { + SpacetimeDB.Log.Info($"Entity {entity.Id} is at ({x}, {y})"); + } +} + +[SpacetimeDB.Reducer] +public static void DeleteFaction(SpacetimeDB.ReducerContext ctx, string Faction) { + ctx.Db.Entity.Faction.Delete(Faction); +} +``` + +### `query` + +Note that the `query!` macro in Rust and the `.Query()` method in C# have been removed. We plan to replace them with something even better in the future, but for now, you should write your query explicitly, either by accessing an index or multi-column index by chaining `ctx.db.my_table().iter().filter(|row| predicate)`. + +### Built-in reducers + +The Rust syntax for declaring builtin lifecycles have changed. They are now: + +- `#[spacetimedb::reducer(client_connected)]` +- `#[spacetimedb::reducer(client_disconnected)]` +- `#[spacetimedb::reducer(init)]` + +In C# they are now: + +- `[SpacetimeDB.Reducer(SpacetimeDB.ReducerKind.ClientConnected)]` +- `[SpacetimeDB.Reducer(SpacetimeDB.ReducerKind.ClientDisconnected)]` +- `[SpacetimeDB.Reducer(SpacetimeDB.ReducerKind.Init)]` + +## Clients + +Make sure to run `spacetime generate` after updating your module! + +### The connection object + +Your connection to a remote module is now represented by a `DbConnection` object, which holds all state associated with the connection. We encourage you to name the variable that holds your connection `ctx`. + +Construct a `DbConnection` via the [builder pattern](https://en.wikipedia.org/wiki/Builder_pattern) with `DbConnection::builder()` or your language's equivalent. Register on-connect and on-disconnect callbacks while constructing the connection via the builder. + +> NOTE: The APIs for the the `DbConnection` and `ReducerContext` are quite similar, allowing you to write the same patterns on both the client and server. + +### Polling the `DbConnection` + +In Rust, you now must explicitly poll your `DbConnection` to advance, where previously it ran automatically in the background. This provides a much greater degree of flexibility to choose your own async runtime and to work under the variety of exciting constraints imposed by game development - for example, you can now arrange it so that all your callbacks run on the main thread if you want to make GUI calls. You can recreate the previous behavior by calling `ctx.run_threaded()` immediately after buidling your connection. You can also call `ctx.run_async()`, or manually call `ctx.frame_tick()` at an appropriate interval. + +In C# the existing API already required you explictly poll your `DbConnection`, so not much has changed there. The `Update()` method is now called `FrameTick()`. + +### Subscribing to queries + +We're planning a major overhaul of the API for subscribing to queries, but we're not quite there yet. This means that our subscription APIs are not yet as consistent as will soon be. + +#### Rust + +Subscribe to a set of queries by creating a subscription builder and calling `subscribe`. + +```rust +ctx.subscription_builder() + .on_applied(|ctx| { ... }) + .subscribe([ + "SELECT * FROM my_table", + "SELECT * FROM other_table WHERE some_column = 123" + ]); +``` + +The `on_applied` callback is optional. A temporarily limitation of this API is that you should add all your subscription queries at one time for any given connection. + +#### C# + +```csharp +ctx.SubscriptionBuilder() + .OnApplied(ctx => { ... }) + .Subscribe( + "SELECT * FROM MyTable", + "SELECT * FROM OtherTable WHERE SomeColumn = 123" + ); +``` + +#### TypeScript + +```ts +ctx.subscriptionBuilder() + .onApplied(ctx => { ... }) + .subscribe([ + "SELECT * FROM my_table", + "SELECT * FROM other_table WHERE some_column = 123" + ]); +``` + +### Accessing tables + +As in modules, all accesses to your connection's client cache now go through the `ctx.db`. Support for client-side indices is not yet consistent across all our SDKs, so for now you may find that you can't make some queries in clients which you could make in modules. The table handles also expose row callbacks. + +### Observing and invoking reducers + +Register reducer callbacks and request reducer invocations by going through `ctx.reducers`. You can also add functions to subscribe to reducer events that the server sends when a particular reducer is executed. + +#### Rust + +```rust +ctx.reducers.my_reducer(my_first_arg, my_second_arg, ...); + +// Add a callback for each reducer event for `my_reducer` +let callback_id = ctx.reducers.on_my_reducer(|ctx, first_arg, second_arg, ...| { + ... +}); + +// Unregister the callback +ctx.reducers.remove_my_reducer(callback_id); +``` + +#### C# + +```cs +ctx.Reducers.MyReducer(myFirstArg, mySecondArg, ...); + +// Add a callback for each reducer event for `MyReducer` +void OnMyReducerCallback(EventContext ctx) { + ... +} +ctx.Reducers.OnMyReducer += OnMyReducerCallback; + +// Unregister the callback +ctx.Reducers.OnMyReducer -= OnMyReducerCallback; +``` + +#### TypeScript + +```ts +ctx.reducers.myReducer(myFirstArg, mySecondArg, ...); + +// Add a callback for each reducer event for `my_reducer` +const callback = (ctx, firstArg, secondArg, ...) => { + ... +}; +ctx.reducers.onMyReducer(callback); + +// Unregister the callback +ctx.reducers.removeMyReducer(callback); +``` + +### The event context + +Most callbacks now take a first argument of type `&EventContext`. This is just like your `DbConnection`, but it has an additional field `event: Event`. `Event` is an enum, tagged union, or sum type which encodes all the different events the SDK can observe. This fills the same role as `ReducerEvent` used to, but `Event` is more specific and more accurate to what actually happened. + +```rust +ctx.reducers.on_my_reducer(|ctx, first_arg, second_arg, ...| { + match ctx.event { + Reducer(reducer_event) => { + ... + }, + _ => unreachable!(); + } +}); +``` + +#### C# + +```csharp +ctx.Reducers.OnMyReducer += (ctx, firstArg, secondArg, ...) => { + switch (ctx.Event) { + case Event.Reducer (var value): + var reducerEvent = value.Reducer; + ... + break; + } +}; +``` + +#### TypeScript + +```ts +ctx.reducers.onMyReducer((ctx, firstArg, secondArg, ...) => { + if (ctx.event.tag === 'Reducer') { + const reducerEvent = ctx.event.value; + ... + } +}); +``` diff --git a/docs/nav.js b/docs/nav.js index 5a669500..a43c2e29 100644 --- a/docs/nav.js +++ b/docs/nav.js @@ -1,75 +1,57 @@ -'use strict'; -Object.defineProperty(exports, '__esModule', { value: true }); +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); function page(title, slug, path, props) { - return { type: 'page', path, slug, title, ...props }; + return { type: 'page', path, slug, title, ...props }; } function section(title) { - return { type: 'section', title }; + return { type: 'section', title }; } const nav = { - items: [ - section('Intro'), - page('Overview', 'index', 'index.md'), // TODO(BREAKING): For consistency & clarity, 'index' slug should be renamed 'intro'? - page('Getting Started', 'getting-started', 'getting-started.md'), - section('Deploying'), - page('Testnet', 'deploying/testnet', 'deploying/testnet.md'), - section('Unity Tutorial - Basic Multiplayer'), - page('Overview', 'unity-tutorial', 'unity/index.md'), - page('1 - Setup', 'unity/part-1', 'unity/part-1.md'), - page('2a - Server (Rust)', 'unity/part-2a-rust', 'unity/part-2a-rust.md'), - page( - '2b - Server (C#)', - 'unity/part-2b-c-sharp', - 'unity/part-2b-c-sharp.md' - ), - page('3 - Client', 'unity/part-3', 'unity/part-3.md'), - section('Unity Tutorial - Advanced'), - page('4 - Resources And Scheduling', 'unity/part-4', 'unity/part-4.md'), - page('5 - BitCraft Mini', 'unity/part-5', 'unity/part-5.md'), - section('Server Module Languages'), - page('Overview', 'modules', 'modules/index.md'), - page( - 'Rust Quickstart', - 'modules/rust/quickstart', - 'modules/rust/quickstart.md' - ), - page('Rust Reference', 'modules/rust', 'modules/rust/index.md'), - page( - 'C# Quickstart', - 'modules/c-sharp/quickstart', - 'modules/c-sharp/quickstart.md' - ), - page('C# Reference', 'modules/c-sharp', 'modules/c-sharp/index.md'), - section('Client SDK Languages'), - page('Overview', 'sdks', 'sdks/index.md'), - page( - 'Typescript Quickstart', - 'sdks/typescript/quickstart', - 'sdks/typescript/quickstart.md' - ), - page('Typescript Reference', 'sdks/typescript', 'sdks/typescript/index.md'), - page('Rust Quickstart', 'sdks/rust/quickstart', 'sdks/rust/quickstart.md'), - page('Rust Reference', 'sdks/rust', 'sdks/rust/index.md'), - page( - 'C# Quickstart', - 'sdks/c-sharp/quickstart', - 'sdks/c-sharp/quickstart.md' - ), - page('C# Reference', 'sdks/c-sharp', 'sdks/c-sharp/index.md'), - section('WebAssembly ABI'), - page('Module ABI Reference', 'webassembly-abi', 'webassembly-abi/index.md'), - section('HTTP API'), - page('HTTP', 'http', 'http/index.md'), - page('`/identity`', 'http/identity', 'http/identity.md'), - page('`/database`', 'http/database', 'http/database.md'), - page('`/energy`', 'http/energy', 'http/energy.md'), - section('WebSocket API Reference'), - page('WebSocket', 'ws', 'ws/index.md'), - section('Data Format'), - page('SATN', 'satn', 'satn.md'), - page('BSATN', 'bsatn', 'bsatn.md'), - section('SQL'), - page('SQL Reference', 'sql', 'sql/index.md'), - ], + items: [ + section('Intro'), + page('Overview', 'index', 'index.md'), // TODO(BREAKING): For consistency & clarity, 'index' slug should be renamed 'intro'? + page('Getting Started', 'getting-started', 'getting-started.md'), + section('Deploying'), + page('Testnet', 'deploying/testnet', 'deploying/testnet.md'), + section('Migration Guides'), + page('v0.12', 'migration/v0.12', 'migration/v0.12.md'), + section('Unity Tutorial - Basic Multiplayer'), + page('Overview', 'unity-tutorial', 'unity/index.md'), + page('1 - Setup', 'unity/part-1', 'unity/part-1.md'), + page('2a - Server (Rust)', 'unity/part-2a-rust', 'unity/part-2a-rust.md'), + page('2b - Server (C#)', 'unity/part-2b-c-sharp', 'unity/part-2b-c-sharp.md'), + page('3 - Client', 'unity/part-3', 'unity/part-3.md'), + section('Unity Tutorial - Advanced'), + page('4 - Resources And Scheduling', 'unity/part-4', 'unity/part-4.md'), + page('5 - BitCraft Mini', 'unity/part-5', 'unity/part-5.md'), + section('Server Module Languages'), + page('Overview', 'modules', 'modules/index.md'), + page('Rust Quickstart', 'modules/rust/quickstart', 'modules/rust/quickstart.md'), + page('Rust Reference', 'modules/rust', 'modules/rust/index.md'), + page('C# Quickstart', 'modules/c-sharp/quickstart', 'modules/c-sharp/quickstart.md'), + page('C# Reference', 'modules/c-sharp', 'modules/c-sharp/index.md'), + section('Client SDK Languages'), + page('Overview', 'sdks', 'sdks/index.md'), + page('Typescript Quickstart', 'sdks/typescript/quickstart', 'sdks/typescript/quickstart.md'), + page('Typescript Reference', 'sdks/typescript', 'sdks/typescript/index.md'), + page('Rust Quickstart', 'sdks/rust/quickstart', 'sdks/rust/quickstart.md'), + page('Rust Reference', 'sdks/rust', 'sdks/rust/index.md'), + page('C# Quickstart', 'sdks/c-sharp/quickstart', 'sdks/c-sharp/quickstart.md'), + page('C# Reference', 'sdks/c-sharp', 'sdks/c-sharp/index.md'), + section('WebAssembly ABI'), + page('Module ABI Reference', 'webassembly-abi', 'webassembly-abi/index.md'), + section('HTTP API'), + page('HTTP', 'http', 'http/index.md'), + page('`/identity`', 'http/identity', 'http/identity.md'), + page('`/database`', 'http/database', 'http/database.md'), + page('`/energy`', 'http/energy', 'http/energy.md'), + section('WebSocket API Reference'), + page('WebSocket', 'ws', 'ws/index.md'), + section('Data Format'), + page('SATN', 'satn', 'satn.md'), + page('BSATN', 'bsatn', 'bsatn.md'), + section('SQL'), + page('SQL Reference', 'sql', 'sql/index.md'), + ], }; exports.default = nav; diff --git a/nav.ts b/nav.ts index 19e69c76..0d191439 100644 --- a/nav.ts +++ b/nav.ts @@ -35,6 +35,9 @@ const nav: Nav = { section('Deploying'), page('Testnet', 'deploying/testnet', 'deploying/testnet.md'), + + section('Migration Guides'), + page('v0.12', 'migration/v0.12', 'migration/v0.12.md'), section('Unity Tutorial - Basic Multiplayer'), page('Overview', 'unity-tutorial', 'unity/index.md'), From 5ff556956cd3f4fe2b7279baa4f594ef86388885 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Fri, 4 Oct 2024 12:47:39 -0400 Subject: [PATCH 073/164] Whitespace (#98) --- nav.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nav.ts b/nav.ts index 0d191439..8ca41be7 100644 --- a/nav.ts +++ b/nav.ts @@ -35,7 +35,7 @@ const nav: Nav = { section('Deploying'), page('Testnet', 'deploying/testnet', 'deploying/testnet.md'), - + section('Migration Guides'), page('v0.12', 'migration/v0.12', 'migration/v0.12.md'), From 97be43cf04d58e79a24d4ea8ef6269706fdec37b Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Tue, 22 Oct 2024 10:49:41 -0400 Subject: [PATCH 074/164] Add note about integer literal type inference (#100) Companion to https://github.com/clockworklabs/SpacetimeDB/pull/1815 Also fix surrounding example code and text: you filter on indices, not columns. --- docs/modules/rust/index.md | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/docs/modules/rust/index.md b/docs/modules/rust/index.md index 83a751be..24fa82bf 100644 --- a/docs/modules/rust/index.md +++ b/docs/modules/rust/index.md @@ -419,6 +419,7 @@ struct Person { #[unique] id: u64, + #[index(btree)] age: u32, name: String, address: String, @@ -466,20 +467,40 @@ fn filtering(ctx: &ReducerContext, id: u64) { } ``` -Our `Person` table also has a column for age. Unlike IDs, ages aren't unique. Filtering for every person who is 21, then, gives us an `Iterator` rather than an `Option`. +Our `Person` table also has an index on its `age` column. Unlike IDs, ages aren't unique. Filtering for every person who is 21, then, gives us an `Iterator` rather than an `Option`. ```rust #[reducer] fn filtering_non_unique(ctx: &ReducerContext) { - for person in ctx.db.person().age().find(21) { - println!("{person} has turned 21"); + for person in ctx.db.person().age().filter(21u32) { + println!("{} has turned 21", person.name); } } ``` +> NOTE: An unfortunate interaction between Rust's trait solver and integer literal defaulting rules means that you must specify the types of integer literals passed to `filter` and `find` methods via the suffix syntax, like `21u32`. If you don't, you'll see a compiler error like: +> ``` +> error[E0271]: type mismatch resolving `::Column == u32` +> --> modules/rust-wasm-test/src/lib.rs:356:48 +> | +> 356 | for person in ctx.db.person().age().filter(21) { +> | ------ ^^ expected `u32`, found `i32` +> | | +> | required by a bound introduced by this call +> | +> = note: required for `i32` to implement `BTreeIndexBounds<(u32,), SingleBound>` +> note: required by a bound in `BTreeIndex::::filter` +> | +> 410 | pub fn filter(&self, b: B) -> impl Iterator +> | ------ required by a bound in this associated function +> 411 | where +> 412 | B: BTreeIndexBounds, +> | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `BTreeIndex::::filter` +> ``` + ### Deleting -Like filtering, we can delete by a unique column instead of the entire row. +Like filtering, we can delete by an indexed or unique column instead of the entire row. ```rust #[reducer] From d3d3cb54dc78a6ec0ecfac2dadadd2f95ec8a972 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Tue, 26 Nov 2024 10:57:41 -0500 Subject: [PATCH 075/164] Style guide for our docs (#110) * WIP style guide for our docs * More style * Style: tutorials * Add Tyler's suggestion re: avoiding passive voice Co-authored-by: Tyler Cloutier * Fill in grammar todos --------- Co-authored-by: Tyler Cloutier --- STYLE.md | 349 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 349 insertions(+) create mode 100644 STYLE.md diff --git a/STYLE.md b/STYLE.md new file mode 100644 index 00000000..25d5848d --- /dev/null +++ b/STYLE.md @@ -0,0 +1,349 @@ +# SpacetimeDB Documentation Style Guide + +## Purpose of this document + +This document describes how the documentation in this repo, which winds up on the SpacetimeDB website, should be written. Much of the content in this repository currently does not meet these standards. Reworking everything to meet these standards is a significant undertaking, and in all honesty will probably never be complete, but at the very least we want to avoid generating new text which doesn't meet our standards. We will request changes on or reject docs PRs which do not obey these rules, even if they are updating or replacing existing docs which also did not obey these rules. + +## General guidelines + +### Target audience + +The SpacetimeDB documentation should be digestable and clear for someone who is a competent web or game developer, but does not have a strong grounding in theoretical math or CS. This means we generally want to steer clear of overly terse formal notations, instead using natural language (like, English words) to describe what's going on. + +#### The exception: internals docs + +We offer some level of leeway on this for documentation of internal, low-level or advanced interfaces. For example, we don't expect the average user to ever need to know the details of the BSATN binary encoding, so we can make some stronger assumptions about the technical background of readers in that context. + +On the other hand, this means that docs for these low-level interfaces should be up-front that they're not for everyone. Start each page with something like, "SUBJECT is a low-level implementation detail of HIGHER-LEVEL SYSTEM. Users of HIGHER-LEVEL SYSTEM should not need to worry about SUBJECT. This document is provided for advanced users and those curious about SpacetimeDB internals." Also make the "HIGHER-LEVEL SYSTEM" a link to the documentation for the user-facing component. + +### Code formatting + +Use triple-backtick code blocks for any example longer than half a line on a 100-character-wide terminal. Always include a relevant language for syntax highlighting; reasonable choices are: + +- `csharp`. +- `rust`. +- `typescript`. +- `sql`. + +Use single-backtick inline code highlighting for names of variables, functions, methods &c. Where possible, make these links, usually sharpsign anchor links, to the section of documentation which describes that variable. + +In normal text, use italics without any backticks for meta-variables which the user is expected to fill in. Always include an anchor, sentence or "where" clause which describes the meaning of the meta-variable. (E.g. is it a table name? A reducer? An arbitrary string the user can choose? The output of some previous command?) + +For meta-variables in code blocks, enclose the meta-variable name in `{}` curly braces. Use the same meta-variable names in code as in normal text. Always include a sentence or "where" clause which describes the meaning of the meta-variable. + +Do not use single-backtick code highlighting for words which are not variable, function, method or type names. (Or other sorts of defined symbols that appear in actual code.) Similarly, do not use italics for words which are not meta-variables that the reader is expected to substitute. In particular, do not use code highlighting for emphasis or to introduce vocabulary. + +For example: + +> To find rows in a table *table* with a given value in a `#[unique]` or `#[primary_key]` column, do: +> +> ```rust +> ctx.db.{table}().{column}().find({value}) +> ``` +> +> where *column* is the name of the unique column and *value* is the value you're looking for in that column. This is equivalent to: +> +> ```sql +> SELECT * FROM {table} WHERE {column} = {value} +> ``` + +### Pseudocode + +Avoid writing pseudocode whenever possible; just write actual code in one of our supported languages. If the file you're writing in is relevant to a specific supported language, use that. If the file applies to the system as a whole, write it in as many of our supported languages as you're comfortable, then ping another team member to help with the languages you don't know. + +If it's just for instructional purposes, it can be high-level and include calls to made-up functions, so long as those functions have descriptive names. If you do this, include a note before the code block which clarifies that it's not intended to be runnable as-is. + +### Describing limitations and future plans + +Call missing features "current limitations" and bugs "known issues." + +Be up-front about what isn't implemented right now. It's better for our users to be told up front that something is broken or not done yet than for them to expect it to work and to be surprised when it doesn't. + +Don't make promises, even weak ones, about what we plan to do in the future, within tutorials or reference documents. Statements about the future belong in a separate "roadmap" or "future plans" document. Our idea of "soon" is often very different from our users', and our priorities shift rapidly and frequently enough that statements about our future plans rarely end up being accurate. + +If your document needs to describe a feature that isn't implemented yet, either rewrite to not depend on that feature, or just say that it's a "current limitation" without elaborating further. Include a workaround if there is one. + +## Reference pages + +Reference pages are where intermediate users will look to get a view of all of the capabilities of a tool, and where experienced users will check for specific information on behaviors of the types, functions, methods &c they're using. Each user-facing component in the SpacetimeDB ecosystem should have a reference page. + +Each reference page should start with an introduction paragraph that says what the component is and when and how the user will interact with it. It should then either include a section describing how to install or set up that component, or a link to another page which accomplishes the same thing. + +### Tone, tense and voice + +Reference pages should be written in relatively formal language that would seem at home in an encyclopedia or a textbook. Or, say, [the Microsoft .NET API reference](https://learn.microsoft.com/en-us/dotnet/api/?view=net-8.0). + +#### Declarative present tense, for behavior of properties, functions and methods + +Use the declarative voice when describing how code works or what it does. [For example](https://learn.microsoft.com/en-us/dotnet/api/system.collections.arraylist?view=net-8.0): + +> Public static (`Shared` in Visual Basic) members of this type are thread safe. Any instance members are not guaranteed to be thread safe. +> +> An `ArrayList` can support multiple readers concurrently, as long as the collection is not modified. To guarantee the thread safety of the `ArrayList`, all operations must be done through the wrapper returned by the `Synchronized(IList)` method. + +#### *Usually* don't refer to the reader + +Use second-person pronouns (i.e. "you") sparingly to draw attention to actions the reader should take to work around bugs or avoid footguns. Often these advisories should be pulled out into note, warning or quote-blocks. [For example](https://learn.microsoft.com/en-us/dotnet/api/system.collections.arraylist?view=net-8.0): + +> Enumerating through a collection is intrinsically not a thread-safe procedure. Even when a collection is synchronized, other threads can still modify the collection, which causes the enumerator to throw an exception. To guarantee thread safety during enumeration, you can either lock the collection during the entire enumeration or catch the exceptions resulting from changes made by other threads. + +#### *Usually* don't refer to "we" or "us" + +Use first-person pronouns sparingly to draw attention to non-technical information like design advice. Always use the first-person plural (i.e. "we" or "us") and never the singular (i.e. "I" or "me"). Often these should be accompanied by marker words like "recommend," "advise," "encourage" or "discourage." [For example](https://learn.microsoft.com/en-us/dotnet/api/system.collections.arraylist?view=net-8.0): + +> We don't recommend that you use the `ArrayList` class for new development. Instead, we recommend that you use the generic `List` class. + +#### *Usually* Avoid Passive Voice + +Use active voice rather than passive voice to avoid ambiguity regarding who is doing the action. Active voice directly attributes actions to the subject, making sentences easier to understand. For example: + +- Passive voice: "The method was invoked." +- Active voice: "The user invoked the method." + +The second example is more straightforward and clarifies who is performing the action. In most cases, prefer using the active voice to maintain a clear and direct explanation of code behavior. + +However, passive voice may be appropriate in certain contexts where the actor is either unknown or irrelevant. In these cases, the emphasis is placed on the action or result rather than the subject performing it. For example: + +- "The `Dispose` method is called automatically when the object is garbage collected." +### Tables and links + +Each reference page should have one or more two-column tables, where the left column are namespace-qualified names or signatures, and the right column are one-sentence descriptions. Headers are optional. If the table contains multiple different kinds of items (e.g. types and functions), the left column should include the kind as a suffix. [For example](https://learn.microsoft.com/en-us/dotnet/api/?view=net-8.0): + +> | Name | Description | +> |-|-| +> | `Microsoft.CSharp.RuntimeBinder` Namespace | Provides classes and interfaces that support interoperation between Dynamic Language Runtime and C#. | +> | `Microsoft.VisualBasic` Namespace | Contains types that support the Visual Basic Runtime in Visual Basic. | + +The names should be code-formatted, and should be links to a page or section for that definition. The short descriptions should be the same as are used at the start of the linked page or section (see below). + +Authors are encouraged to write multiple different tables on the same page, with headers between introducing them. E.g. it may be useful to divide classes from interfaces, or to divide names by conceptual purpose. [For example](https://learn.microsoft.com/en-us/dotnet/api/system.collections?view=net-8.0): + +> # Classes +> +> | ArrayList | Implements the IList interface using an array whose size is dynamically increased as required. | +> | BitArray | Manages a compact array of bit values, which are represented as Booleans, where true indicates that the bit is on (1) and false indicates the bit is off (0). | +> +> ... +> +> # Interfaces +> +> | ICollection | Defines size, enumerators, and synchronization methods for all nongeneric collections. | +> | IComparer | Exposes a method that compares two objects. | +> +> ... + +### Sections for individual definitions + +#### Header + +When writing a section for an individual definition, start with any metadata that users will need to refer to the defined object, like its namespace. Then write a short paragraph, usually just a single sentence, which gives a high-level description of the thing. This sentence should be in the declarative present tense with an active verb. Start with the verb, with the thing being defined as the implied subject. [For example](https://learn.microsoft.com/en-us/dotnet/api/system.collections.arraylist?view=net-8.0): + +> ArrayList Class +> [...] +> Namespace: `System.Collections` +> [...] +> Implements the IList interface using an array whose size is dynamically increased as required. + +Next, add a triple-backtick code block that contains just the declaration or signature of the variable, function or method you're describing. + +What, specifically, counts as the declaration or signature is somewhat context-dependent. A good general rule is that it's everything in the source code to the left of the equals sign `=` or curly braces `{}`. You can edit this to remove implementation details (e.g. superclasses that users aren't supposed to see), or to add information that would be helpful but isn't in the source (e.g. trait bounds on generic parameters of types which aren't required to instantiate the type, but which most methods require, like `Eq + Hash` for `HashMap`). [For example](https://learn.microsoft.com/en-us/dotnet/api/system.collections.arraylist?view=net-8.0): + +> ```csharp +> public class ArrayList : ICloneable, System.Collections.IList +> ``` + +If necessary, this should be followed by one or more paragraphs of more in-depth description. + +#### Examples + +Next, within a subheader named "Examples," include a code block with examples. + +To the extent possible, this code block should be freestanding. If it depends on external definitions that aren't included in the standard library or are not otherwise automatically accessible, add a note so that users know what they need to supply themselves (e.g. that the `mod module_bindings;` refers to the `quickstart-chat` module). Do not be afraid to paste the same "header" or "prelude" code (e.g. a table declaration) into a whole bunch of code blocks, but try to avoid making easy-to-miss minor edits to such "header" code. + +Add comments to this code block which describe what it does. In particular, if the example prints to the console, show the expected output in a comment. [For example](https://learn.microsoft.com/en-us/dotnet/api/system.collections.arraylist?view=net-8.0): + +> ```csharp +> using System; +> using System.Collections; +> public class SamplesArrayList { +> +> public static void Main() { +> +> // Creates and initializes a new ArrayList. +> ArrayList myAL = new ArrayList(); +> myAL.Add("Hello"); +> myAL.Add("World"); +> myAL.Add("!"); +> +> // Displays the properties and values of the ArrayList. +> Console.WriteLine( "myAL" ); +> Console.WriteLine( " Count: {0}", myAL.Count ); +> Console.WriteLine( " Capacity: {0}", myAL.Capacity ); +> Console.Write( " Values:" ); +> PrintValues( myAL ); +> } +> +> public static void PrintValues( IEnumerable myList ) { +> foreach ( Object obj in myList ) +> Console.Write( " {0}", obj ); +> Console.WriteLine(); +> } +> } +> +> +> /* +> This code produces output similar to the following: +> +> myAL +> Count: 3 +> Capacity: 4 +> Values: Hello World ! +> +> */ +> ``` + +#### Child items + +If the described item has any children (e.g. properties and methods of classes, variants of enums), include one or more tables for those children, as described above, followed by subsections for each child item. These subsections follow the same format as for the parent items, with a header, declaration, description, examples and tables of any (grand-)children. + +If a documentation page ends up with more than 3 layers of nested items, split it so that each top-level item has its own page. + +### Grammars and syntax + +Reference documents, particularly for SQL or our serialization formats, will sometimes need to specify grammars. Before doing this, be sure you need to, as a grammar specification is scary and confusing to even moderately technical readers. If you're describing data that obeys some other language that readers will be familiar with, write a definition in or suited to that language instead of defining the grammar. For example, when describing a JSON encoding, consider writing a TypeScript-style type instead of a grammar. + +If you really do need to describe a grammar, write an EBNF description inside a triple-backticks code block with the `ebnf` language marker. (I assume that any grammar we need to describe will be context-free.) Start with the "topmost" or "entry" nonterminal, i.e. the syntactic construction that we actually want to parse, and work "downward" towards the terminals. For example, when describing SQL, `statement` is at the top, and `literal` and `ident` are at or near the bottom. You don't have to include trivial rules like those for literals. + +Then, write a whole bunch of examples under a subheader "Examples" in another tripple-backtick code block, this one with an appropriate language marker for what you're describing. Include at least one simple example and at least one complicated example. Try to include examples which exercise all of the features your grammar can express. + +## Overview pages + +Landing page type things, usually named `index.md`. + +### Tone, tense and voice + +Use the same guidelines as for reference pages, except that you can refer to the reader as "you" more often. + +### Links + +Include as many links to more specific docs pages as possible within the text. Sharp-links to anchors/headers within other docs pages are super valuable here! + +### FAQs + +If there's any information you want to impart to users but you're not sure how to shoehorn it into any other page or section, just slap it in an "FAQ" section at the bottom of an overview page. + +Each FAQ item should start with a subheader, which is phrased as a question a user would ask. + +Answer these questions starting with a declarative or conversational sentence. Refer to the asker as "you," and their project as "your client," "your module" or "your app." + +For example: + +> #### What's the difference between a subscription query and a one-off query? +> +> Subscription queries are incremental: your client receives updates whenever the database state changes, containing only the altered rows. This is an efficient way to maintain a "materialized view," that is, a local copy of some subset of the database. Use subscriptions when you want to watch rows and react to changes, or to keep local copies of rows which you'll read frequently. +> +> A one-off query happens once, and then is done. Use one-off queries to look at rows you only need once. +> +> #### How do I get an authorization token? +> +> You can supply your users with authorization tokens in several different ways; which one is best for you will depend on the needs of your app. [...] (I don't actually want to write a real answer to this question - pgoldman 2024-11-19.) +> +> #### Can my client connect to multiple modules at the same time? +> +> Yes! Your client can construct as many `DbConnection`s simultaneously as it wants to, each of which will operate independently. If you want to connect to two modules with different schemas, use `spacetime generate` to include bindings for both of them in your client project. Note that SpacetimeDB may reject multiple concurrent connections to the same module by a single client. + +## Tutorial pages + +Tutorials are where we funnel new-to-intermediate users to introduce them to new concepts. + +Some tutorials are associated with specific SpacetimeDB components, and should be included in (sub)directories alongside the documentation for those components. Other tutorials are more general or holisitc, touching many different parts of SpacetimeDB to produce a complete game or app, and should stand alone or be grouped into a "tutorials" or "projects" directory. + +### Tone, tense and voice + +Be friendly, but still precise and professional. Refer to the reader as "you." Make gentle suggestions for optional actions with "can" or "could." When telling them to do something that's required to advance the tutorial, use the imperative voice. When reminding them of past tutorials or preparing them for future ones, say "we," grouping you (the writer) together with the reader. You two are going on a journey together, so get comfortable! + +### Scope + +You don't have to teach the reader non-SpacetimeDB-specific things. If you're writing a tutorial on Rust modules, for example, assume basic-to-intermediate familiarity with "Rust," so you can focus on teaching the reader about the "modules" part. + +### Introduction: tell 'em what you're gonna tell 'em + +Each tutorial should start with a statement of its scope (what new concepts are introduced), goal (what you build or do during the tutorial) and prerequisites (what other tutorials you should have finished first). + +> In this tutorial, we'll implement a simple chat server as a SpacetimeDB module. We'll learn how to declare tables and to write reducers, functions which run in the database to modify those tables in response to client requests. Before starting, make sure you've [installed SpacetimeDB](/install) and [logged in with a developer `Identity`](/auth/for-devs). + +### Introducing and linking to definitions + +The first time a tutorial or series introduces a new type / function / method / &c, include a short paragraph describing what it is and how it's being used in this tutorial. Make sure to link to the reference section on that item. + +### Tutorial code + +If the tutorial involves writing code, e.g. for a module or client, the tutorial should include the complete result code within its text. Ideally, it should be possible for a reader to copy and paste all the code blocks in the document into a file, effectively concatentating them together, and wind up with a coherent and runnable program. Sometimes this is not possible, e.g. because C# requires wrapping your whole file in a bunch of scopes. In this case, precede each code block with a sentence that describes where the reader is going to paste it. + +Include even uninteresting code, like imports! You can rush through these without spending too much time on them, but make sure that every line of code required to make the project work appears in the tutorial. + +> spacetime init should have pre-populated server/src/lib.rs with a trivial module. Clear it out so we can write a new, simple module: a bare-bones chat server. +> +> To the top of server/src/lib.rs, add some imports we'll be using: +> +> ```rust +> use spacetimedb::{table, reducer, Table, ReducerContext, Identity, Timestamp}; +> ``` + +For code that *is* interesting, after the code block, add a description of what the code does. Usually this will be pretty succinct, as the code should hopefully be pretty clear on its own. + +### Words for telling the user to write code + +When introducing a code block that the user should put in their file, don't say "copy" or "paste." Instead, tell them (in the imperative) to "add" or "write" the code. This emphasizes active participation, as opposed to passive consumption, and implicitly encourages the user to modify the tutorial code if they'd like. Readers who just want to copy and paste will do so without our telling them. + +> To `server/src/lib.rs`, add the definition of the connect reducer: +> +> ```rust +> I don't actually need to fill this in. +> ``` + +### Conclusion + +Each tutorial should end with a conclusion section, with a title like "What's next?" + +#### Tell 'em what you told 'em + +Start the conclusion with a sentence or paragraph that reminds the reader what they accomplished: + +> You've just set up your first database in SpacetimeDB, complete with its very own tables and reducers! + +#### Tell them what to do next + +If this tutorial is part of a series, link to the next entry: + +> You can use any of SpacetimDB's supported client languages to do this. Take a look at the quickstart guide for your client language of choice: [Rust](/docs/sdks/rust/quickstart), [C#](/docs/sdks/c-sharp/quickstart), or [TypeScript](/docs/sdks/typescript/quickstart). If you are planning to use SpacetimeDB with the Unity game engine, you can skip right to the [Unity Comprehensive Tutorial](/docs/unity/part-1) or check out our example game, [BitcraftMini](/docs/unity/part-3). + +If this tutorial is about a specific component, link to its reference page: + +> Check out the [Rust SDK Reference](/docs/sdks/rust) for a more comprehensive view of the SpacetimeDB Rust SDK. + +If this tutorial is the end of a series, or ends with a reasonably complete app, throw in some ideas about how the reader could extend it: + +> Our basic terminal interface has some limitations. Incoming messages can appear while the user is typing, which is less than ideal. Additionally, the user's input gets mixed with the program's output, making messages the user sends appear twice. You might want to try improving the interface by using [Rustyline](https://crates.io/crates/rustyline), [Cursive](https://crates.io/crates/cursive), or even creating a full-fledged GUI. +> +> Once your chat server runs for a while, you might want to limit the messages your client loads by refining your `Message` subscription query, only subscribing to messages sent within the last half-hour. +> +> You could also add features like: +> +> - Styling messages by interpreting HTML tags and printing appropriate [ANSI escapes](https://en.wikipedia.org/wiki/ANSI_escape_code). +> - Adding a `moderator` flag to the `User` table, allowing moderators to manage users (e.g., time-out, ban). +> - Adding rooms or channels that users can join or leave. +> - Supporting direct messages or displaying user statuses next to their usernames. + +#### Complete code + +If the tutorial involved writing code, add a link to the complete code. This should be somewhere on GitHub, either as its own repo, or as an example project within an existing repo. Ensure the linked folder has a README.md file which includes: + +- The name of the tutorial project. +- How to run or interact with the tutorial project, whatever that means (e.g. publish to testnet and then `spacetime call`). +- Links to external dependencies (e.g. for client projects, the module which it runs against). +- A back-link to the tutorial that builds this project. + +At the end of the tutorial that builds the `quickstart-chat` module in Rust, you might write: + +> You can find the full code for this module in [the SpacetimeDB module examples](https://github.com/clockworklabs/SpacetimeDB/tree/master/modules/quickstart-chat). From ba3f8ef0ea1bce1c77baa7e6c1005ff65bc59786 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Tue, 31 Dec 2024 17:05:13 -0500 Subject: [PATCH 076/164] Added .DS_store to the .gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 589c396e..d839abde 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ **/.vscode .idea *.log -node_modules \ No newline at end of file +node_modules +.DS_store From eb6a3a249c05ecdf6220b86ceabc0b60c4e57609 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Fri, 3 Jan 2025 00:44:00 -0500 Subject: [PATCH 077/164] Added a script to check the validity of docs links and a .github action (#122) * Added a script to check the validity of docs links and a .github action * Removed erroneous thing * Switched the action trigger * Added workflow to ensure that the nav.ts has been built to nav.js * typo * Build nav.ts * typo thing * Fixed script issue * Fix * Fixed a few links * Added relative link resolution and fixed the broken links * now checking fragments * Now checking fragments properly and publishing some stats * Forgot exit code * Fix broken links Well, in at least some cases, just remove broken links. - The BSATN ref contained links to type defns, but didn't have type defns. Replace the links with plain text. - HTTP database links for recovery-code related routes were getting mangled in some way I couldn't figure out, so the links weren't working despite their targets clearly existing. Conveniently, those routes have been removed, so remove the links and the corresponding sections. - The JSON doc (erroneously called "SATN") contained typos, spelling "producttype" as "productype". - C# SDK ref had links to a section on the `Address` type, but no such section. Replace the links with plain text. - Rust SDK ref had a link getting mangled in a way I couldn't figure out. Simplify the section title so that the anchor name is predictable. - TypeSciprt SDK ref used camelCase names in anchor links, but we downcase all section titles to create anchor names. Also slap a section in README.md which says how to run the checker locally. --------- Co-authored-by: Phoebe Goldman --- .github/workflows/check-links.yml | 26 +++ .github/workflows/validate-nav-build.yml | 40 ++++ README.md | 4 + docs/bsatn.md | 32 ++-- docs/http/database.md | 39 ---- docs/modules/c-sharp/quickstart.md | 2 +- docs/nav.js | 17 +- docs/satn.md | 4 +- docs/sdks/c-sharp/index.md | 9 +- docs/sdks/rust/index.md | 4 +- docs/sdks/typescript/index.md | 9 +- docs/unity/part-1.md | 4 +- docs/ws/index.md | 2 +- package.json | 5 +- scripts/checkLinks.ts | 231 +++++++++++++++++++++++ tsconfig.json | 2 + yarn.lock | 189 +++++++++++++++++++ 17 files changed, 539 insertions(+), 80 deletions(-) create mode 100644 .github/workflows/check-links.yml create mode 100644 .github/workflows/validate-nav-build.yml create mode 100644 scripts/checkLinks.ts diff --git a/.github/workflows/check-links.yml b/.github/workflows/check-links.yml new file mode 100644 index 00000000..1053fe7d --- /dev/null +++ b/.github/workflows/check-links.yml @@ -0,0 +1,26 @@ +name: Check Link Validity in Documentation + +on: + pull_request: + branches: + - master + +jobs: + check-links: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: '16' # or the version of Node.js you're using + + - name: Install dependencies + run: | + npm install + + - name: Run link check + run: | + npm run check-links diff --git a/.github/workflows/validate-nav-build.yml b/.github/workflows/validate-nav-build.yml new file mode 100644 index 00000000..b76378d6 --- /dev/null +++ b/.github/workflows/validate-nav-build.yml @@ -0,0 +1,40 @@ +name: Validate nav.ts Matches nav.js + +on: + pull_request: + branches: + - master + +jobs: + validate-build: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: '16' + + - name: Install dependencies + run: | + npm install + + - name: Backup existing nav.js + run: | + mv docs/nav.js docs/nav.js.original + + - name: Build nav.ts + run: | + npm run build + + - name: Compare generated nav.js with original nav.js + run: | + diff -q docs/nav.js docs/nav.js.original || (echo "Generated nav.js differs from committed version. Run 'npm run build' and commit the updated file." && exit 1) + + - name: Restore original nav.js + if: success() || failure() + run: | + mv docs/nav.js.original docs/nav.js diff --git a/README.md b/README.md index c31b2c3f..2165ae62 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,10 @@ git push -u origin a-branch-name-that-describes-my-change > NOTE! If you make a change to `nav.ts` you will have to run `npm run build` to generate a new `docs/nav.js` file. +### Checking Links + +We have a CI job which validates internal links. You can run it locally with `npm run check-links`. This will print any internal links (i.e. links to other docs pages) whose targets do not exist, including fragment links (i.e. `#`-ey links to anchors). + ## License This documentation repository is licensed under Apache 2.0. See LICENSE.txt for more details. diff --git a/docs/bsatn.md b/docs/bsatn.md index 0da55ce7..e8e6d945 100644 --- a/docs/bsatn.md +++ b/docs/bsatn.md @@ -24,12 +24,12 @@ To do this, we use inductive definitions, and define the following notation: ### At a glance -| Type | Description | -| ---------------- | ---------------------------------------------------------------- | -| `AlgebraicValue` | A value whose type may be any [`AlgebraicType`](#algebraictype). | -| `SumValue` | A value whose type is a [`SumType`](#sumtype). | -| `ProductValue` | A value whose type is a [`ProductType`](#producttype). | -| `BuiltinValue` | A value whose type is a [`BuiltinType`](#builtintype). | +| Type | Description | +|-------------------------------------|-----------------------------------------------------------------------| +| [`AlgebraicValue`](#algebraicvalue) | A value of any type. | +| [`SumValue`](#sumvalue) | A value of a sum type, i.e. an enum or tagged union. | +| [`ProductValue`](#productvalue) | A value of a product type, i.e. a struct or tuple. | +| [`BuiltinValue`](#builtinvalue) | A value of a builtin type, including numbers, booleans and sequences. | ### `AlgebraicValue` @@ -41,17 +41,17 @@ bsatn(AlgebraicValue) = bsatn(SumValue) | bsatn(ProductValue) | bsatn(BuiltinVal ### `SumValue` -An instance of a [`SumType`](#sumtype). +An instance of a sum type, i.e. an enum or tagged union. `SumValue`s are binary-encoded as `bsatn(tag) ++ bsatn(variant_data)` -where `tag: u8` is an index into the [`SumType.variants`](#sumtype) -array of the value's [`SumType`](#sumtype), +where `tag: u8` is an index into the `SumType.variants` +array of the value's `SumType`, and where `variant_data` is the data of the variant. For variants holding no data, i.e., of some zero sized type, `bsatn(variant_data) = []`. ### `ProductValue` -An instance of a [`ProductType`](#producttype). +An instance of a product type, i.e. a struct or tuple. `ProductValue`s are binary encoded as: ```fsharp @@ -62,7 +62,8 @@ Field names are not encoded. ### `BuiltinValue` -An instance of a [`BuiltinType`](#builtintype). +An instance of a buil-in type. +Built-in types include booleans, integers, floats, strings and arrays. The BSATN encoding of `BuiltinValue`s defers to the encoding of each variant: ```fsharp @@ -73,7 +74,6 @@ bsatn(BuiltinValue) | bsatn(F32) | bsatn(F64) | bsatn(String) | bsatn(Array) - | bsatn(Map) bsatn(Bool(b)) = bsatn(b as u8) bsatn(U8(x)) = [x] @@ -91,10 +91,6 @@ bsatn(F64(x: f64)) = bsatn(f64_to_raw_bits(x)) // lossless conversion bsatn(String(s)) = bsatn(len(s) as u32) ++ bsatn(bytes(s)) bsatn(Array(a)) = bsatn(len(a) as u32) ++ bsatn(normalize(a)_0) ++ .. ++ bsatn(normalize(a)_n) -bsatn(Map(map)) = bsatn(len(m) as u32) - ++ bsatn(key(map_0)) ++ bsatn(value(map_0)) - .. - ++ bsatn(key(map_n)) ++ bsatn(value(map_n)) ``` Where @@ -102,14 +98,12 @@ Where - `f32_to_raw_bits(x)` is the raw transmute of `x: f32` to `u32` - `f64_to_raw_bits(x)` is the raw transmute of `x: f64` to `u64` - `normalize(a)` for `a: ArrayValue` converts `a` to a list of `AlgebraicValue`s -- `key(map_i)` extracts the key of the `i`th entry of `map` -- `value(map_i)` extracts the value of the `i`th entry of `map` ## Types All SATS types are BSATN-encoded by converting them to an `AlgebraicValue`, then BSATN-encoding that meta-value. -See [the SATN JSON Format](/docs/satn-reference-json-format) +See [the SATN JSON Format](/docs/satn) for more details of the conversion to meta values. Note that these meta values are converted to BSATN and _not JSON_. diff --git a/docs/http/database.md b/docs/http/database.md index 9b6e0488..b23701e8 100644 --- a/docs/http/database.md +++ b/docs/http/database.md @@ -11,8 +11,6 @@ The HTTP endpoints in `/database` allow clients to interact with Spacetime datab | [`/database/set_name GET`](#databaseset_name-get) | Set a database's name, given its address. | | [`/database/ping GET`](#databaseping-get) | No-op. Used to determine whether a client can connect. | | [`/database/register_tld GET`](#databaseregister_tld-get) | Register a top-level domain. | -| [`/database/request_recovery_code GET`](#databaserequest_recovery_code-get) | Request a recovery code to the email associated with an identity. | -| [`/database/confirm_recovery_code GET`](#databaseconfirm_recovery_code-get) | Recover a login token from a recovery code. | | [`/database/publish POST`](#databasepublish-post) | Publish a database given its module code. | | [`/database/delete/:address POST`](#databasedeleteaddress-post) | Delete a database. | | [`/database/subscribe/:name_or_address GET`](#databasesubscribename_or_address-get) | Begin a [WebSocket connection](/docs/ws). | @@ -175,43 +173,6 @@ If the domain is already registered to another identity, returns JSON in the for } } ``` -## `/database/request_recovery_code GET` - -Request a recovery code or link via email, in order to recover the token associated with an identity. - -Accessible through the CLI as `spacetime identity recover `. - -#### Query Parameters - -| Name | Value | -| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `identity` | The identity whose token should be recovered. | -| `email` | The email to send the recovery code or link to. This email must be associated with the identity, either during creation via [`/identity`](/docs/http/identity#identity-post) or afterwards via [`/identity/:identity/set-email`](/docs/http/identity#identityidentityset_email-post). | -| `link` | A boolean; whether to send a clickable link rather than a recovery code. | - -## `/database/confirm_recovery_code GET` - -Confirm a recovery code received via email following a [`/database/request_recovery_code GET`](#-database-request_recovery_code-get) request, and retrieve the identity's token. - -Accessible through the CLI as `spacetime identity recover `. - -#### Query Parameters - -| Name | Value | -| ---------- | --------------------------------------------- | -| `identity` | The identity whose token should be recovered. | -| `email` | The email which received the recovery code. | -| `code` | The recovery code received via email. | - -On success, returns JSON in the form: - -```typescript -{ - "identity": string, - "token": string -} -``` - ## `/database/publish POST` Publish a database. diff --git a/docs/modules/c-sharp/quickstart.md b/docs/modules/c-sharp/quickstart.md index 5d8c873d..571351c1 100644 --- a/docs/modules/c-sharp/quickstart.md +++ b/docs/modules/c-sharp/quickstart.md @@ -312,6 +312,6 @@ spacetime sql "SELECT * FROM Message" ## What's next? -You've just set up your first database in SpacetimeDB! The next step would be to create a client module that interacts with this module. You can use any of SpacetimDB's supported client languages to do this. Take a look at the quick start guide for your client language of choice: [Rust](/docs/languages/rust/rust-sdk-quickstart-guide), [C#](/docs/languages/csharp/csharp-sdk-quickstart-guide), or [TypeScript](/docs/languages/typescript/typescript-sdk-quickstart-guide). +You've just set up your first database in SpacetimeDB! The next step would be to create a client module that interacts with this module. You can use any of SpacetimDB's supported client languages to do this. Take a look at the quick start guide for your client language of choice: [Rust](/docs/sdks/rust/quickstart), [C#](/docs/sdks/c-sharp/quickstart), or [TypeScript](/docs/sdks/typescript/quickstart). If you are planning to use SpacetimeDB with the Unity game engine, you can skip right to the [Unity Comprehensive Tutorial](/docs/unity/part-1) or check out our example game, [BitcraftMini](/docs/unity/part-3). diff --git a/docs/nav.js b/docs/nav.js index a43c2e29..5c3a920e 100644 --- a/docs/nav.js +++ b/docs/nav.js @@ -1,12 +1,23 @@ "use strict"; +var __assign = (this && this.__assign) || function () { + __assign = Object.assign || function(t) { + for (var s, i = 1, n = arguments.length; i < n; i++) { + s = arguments[i]; + for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) + t[p] = s[p]; + } + return t; + }; + return __assign.apply(this, arguments); +}; Object.defineProperty(exports, "__esModule", { value: true }); function page(title, slug, path, props) { - return { type: 'page', path, slug, title, ...props }; + return __assign({ type: 'page', path: path, slug: slug, title: title }, props); } function section(title) { - return { type: 'section', title }; + return { type: 'section', title: title }; } -const nav = { +var nav = { items: [ section('Intro'), page('Overview', 'index', 'index.md'), // TODO(BREAKING): For consistency & clarity, 'index' slug should be renamed 'intro'? diff --git a/docs/satn.md b/docs/satn.md index f21e9b30..6fb0ee9f 100644 --- a/docs/satn.md +++ b/docs/satn.md @@ -34,7 +34,7 @@ The tag is an index into the [`SumType.variants`](#sumtype) array of the value's ### `ProductValue` -An instance of a [`ProductType`](#producttype). `ProductValue`s are encoded as JSON arrays. Each element of the `ProductValue` array is of the type of the corresponding index in the [`ProductType.elements`](#productype) array of the value's [`ProductType`](#producttype). +An instance of a [`ProductType`](#producttype). `ProductValue`s are encoded as JSON arrays. Each element of the `ProductValue` array is of the type of the corresponding index in the [`ProductType.elements`](#producttype) array of the value's [`ProductType`](#producttype). ```json array @@ -69,7 +69,7 @@ All SATS types are JSON-encoded by converting them to an `AlgebraicValue`, then | --------------------------------------- | ------------------------------------------------------------------------------------ | | [`AlgebraicType`](#algebraictype) | Any SATS type. | | [`SumType`](#sumtype) | Sum types, i.e. tagged unions. | -| [`ProductType`](#productype) | Product types, i.e. structures. | +| [`ProductType`](#producttype) | Product types, i.e. structures. | | [`BuiltinType`](#builtintype) | Built-in and primitive types, including booleans, numbers, strings, arrays and maps. | | [`AlgebraicTypeRef`](#algebraictyperef) | An indirect reference to a type, used to implement recursive types. | diff --git a/docs/sdks/c-sharp/index.md b/docs/sdks/c-sharp/index.md index d85f5702..a044e4ea 100644 --- a/docs/sdks/c-sharp/index.md +++ b/docs/sdks/c-sharp/index.md @@ -16,10 +16,10 @@ The SpacetimeDB client C# for Rust contains all the tools you need to build nati - [Method `SpacetimeDBClient.Connect`](#method-spacetimedbclientconnect) - [Event `SpacetimeDBClient.onIdentityReceived`](#event-spacetimedbclientonidentityreceived) - [Event `SpacetimeDBClient.onConnect`](#event-spacetimedbclientonconnect) - - [Query subscriptions & one-time actions](#subscribe-to-queries) + - [Subscribe to queries](#subscribe-to-queries) - [Method `SpacetimeDBClient.Subscribe`](#method-spacetimedbclientsubscribe) - [Event `SpacetimeDBClient.onSubscriptionApplied`](#event-spacetimedbclientonsubscriptionapplied) - - [Method `SpacetimeDBClient.OneOffQuery`](#method-spacetimedbclientoneoffquery) + - [Method \[`SpacetimeDBClient.OneOffQuery`\]](#method-spacetimedbclientoneoffquery) - [View rows of subscribed tables](#view-rows-of-subscribed-tables) - [Class `{TABLE}`](#class-table) - [Static Method `{TABLE}.Iter`](#static-method-tableiter) @@ -45,7 +45,6 @@ The SpacetimeDB client C# for Rust contains all the tools you need to build nati - [Static Property `AuthToken.Token`](#static-property-authtokentoken) - [Static Method `AuthToken.SaveToken`](#static-method-authtokensavetoken) - [Class `Identity`](#class-identity) - - [Class `Identity`](#class-identity-1) - [Customizing logging](#customizing-logging) - [Interface `ISpacetimeDBLogger`](#interface-ispacetimedblogger) - [Class `ConsoleLogger`](#class-consolelogger) @@ -104,7 +103,7 @@ The Unity SpacetimeDB SDK relies on there being a `NetworkManager` somewhere in ![Unity-AddNetworkManager](/images/unity-tutorial/Unity-AddNetworkManager.JPG) -This component will handle updating and closing the [`SpacetimeDBClient.instance`](#property-spacetimedbclientinstance) for you, but will not call [`SpacetimeDBClient.Connect`](#method-spacetimedbclientconnect), you still need to handle that yourself. See the [Unity Quickstart](./UnityQuickStart) and [Unity Tutorial](./UnityTutorialPart1) for more information. +This component will handle updating and closing the [`SpacetimeDBClient.instance`](#property-spacetimedbclientinstance) for you, but will not call [`SpacetimeDBClient.Connect`](#method-spacetimedbclientconnect), you still need to handle that yourself. See the [Unity Tutorial](/docs/unity-tutorial) for more information. ### Method `SpacetimeDBClient.Connect` @@ -172,7 +171,7 @@ class SpacetimeDBClient { } ``` -Called when we receive an auth token, [`Identity`](#class-identity) and [`Address`](#class-address) from the server. The [`Identity`](#class-identity) serves as a unique public identifier for a user of the database. It can be for several purposes, such as filtering rows in a database for the rows created by a particular user. The auth token is a private access token that allows us to assume an identity. The [`Address`](#class-address) is opaque identifier for a client connection to a database, intended to differentiate between connections from the same [`Identity`](#class-identity). +Called when we receive an auth token, [`Identity`](#class-identity) and `Address` from the server. The [`Identity`](#class-identity) serves as a unique public identifier for a user of the database. It can be for several purposes, such as filtering rows in a database for the rows created by a particular user. The auth token is a private access token that allows us to assume an identity. The `Address` is opaque identifier for a client connection to a database, intended to differentiate between connections from the same [`Identity`](#class-identity). To store the auth token to the filesystem, use the static method [`AuthToken.SaveToken`](#static-method-authtokensavetoken). You may also want to store the returned [`Identity`](#class-identity) in a local variable. diff --git a/docs/sdks/rust/index.md b/docs/sdks/rust/index.md index 50e8aa9b..d8befe53 100644 --- a/docs/sdks/rust/index.md +++ b/docs/sdks/rust/index.md @@ -149,7 +149,7 @@ impl DbConnection { `frame_tick` will advance the connection until no work remains, then return rather than blocking or `await`-ing. Games might arrange for this message to be called every frame. `frame_tick` returns `Ok` if the connection remains active afterwards, or `Err` if the connection disconnected before or during the call. -## Trait `spacetimedb_sdk::DbContext` +## Trait `DbContext` [`DbConnection`](#type-dbconnection) and [`EventContext`](#type-eventcontext) both implement `DbContext`, which allows @@ -185,7 +185,7 @@ impl SubscriptionBuilder { } ``` -Register a callback to run when the subscription is applied and the matching rows are inserted into the client cache. The [`EventContext`](#type-module_bindings-eventcontext) passed to the callback will have `Event::SubscribeApplied` as its `event`. +Register a callback to run when the subscription is applied and the matching rows are inserted into the client cache. The [`EventContext`](#type-eventcontext) passed to the callback will have `Event::SubscribeApplied` as its `event`. #### Method `subscribe` diff --git a/docs/sdks/typescript/index.md b/docs/sdks/typescript/index.md index 4f4e17da..34d9edef 100644 --- a/docs/sdks/typescript/index.md +++ b/docs/sdks/typescript/index.md @@ -471,7 +471,7 @@ Identity.fromString(str: string): Identity ### Class `Address` -An opaque identifier for a client connection to a database, intended to differentiate between connections from the same [`Identity`](#type-identity). +An opaque identifier for a client connection to a database, intended to differentiate between connections from the same [`Identity`](#class-identity). Defined in [spacetimedb-sdk.address](https://github.com/clockworklabs/spacetimedb-typescript-sdk/blob/main/src/address.ts): @@ -561,9 +561,8 @@ The generated class has a field for each of the table's columns, whose names are | Properties | Description | | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | [`Table.name`](#table-name) | The name of the class. | -| [`Table.tableName`](#table-tableName) | The name of the table in the database. | +| [`Table.tableName`](#table-tablename) | The name of the table in the database. | | Methods | | -| [`Table.isEqual`](#table-isequal) | Method to compare two identities. | | [`Table.all`](#table-all) | Return all the subscribed rows in the table. | | [`Table.filterBy{COLUMN}`](#table-filterbycolumn) | Autogenerated; return subscribed rows with a given value in a particular column. `{COLUMN}` is a placeholder for a column name. | | [`Table.findBy{COLUMN}`](#table-findbycolumn) | Autogenerated; return a subscribed row with a given value in a particular unique column. `{COLUMN}` is a placeholder for a column name. | @@ -857,7 +856,7 @@ Person.onUpdate((oldPerson, newPerson, reducerEvent) => { ### {Table} removeOnUpdate -Unregister a previously-registered [`onUpdate`](#table-onUpdate) callback. +Unregister a previously-registered [`onUpdate`](#table-onupdate) callback. ```ts {Table}.removeOnUpdate(callback: (oldValue: {Table}, newValue: {Table}, reducerEvent: ReducerEvent | undefined) => void): void @@ -912,7 +911,7 @@ Person.onDelete((person, reducerEvent) => { ### {Table} removeOnDelete -Unregister a previously-registered [`onDelete`](#table-onDelete) callback. +Unregister a previously-registered [`onDelete`](#table-ondelete) callback. ```ts {Table}.removeOnDelete(callback: (value: {Table}, reducerEvent: ReducerEvent | undefined) => void): void diff --git a/docs/unity/part-1.md b/docs/unity/part-1.md index 8e0a49e3..10967b33 100644 --- a/docs/unity/part-1.md +++ b/docs/unity/part-1.md @@ -119,5 +119,5 @@ We chose ECS for this example project because it promotes scalability, modularit From here, the tutorial continues with your favorite server module language of choice: -- [Rust](part-2a-rust.md) -- [C#](part-2b-csharp.md) +- [Rust](part-2a-rust) +- [C#](part-2b-c-sharp) diff --git a/docs/ws/index.md b/docs/ws/index.md index 587fbad0..1a3780cc 100644 --- a/docs/ws/index.md +++ b/docs/ws/index.md @@ -1,6 +1,6 @@ # The SpacetimeDB WebSocket API -As an extension of the [HTTP API](/doc/http-api-reference), SpacetimeDB offers a WebSocket API. Clients can subscribe to a database via a WebSocket connection to receive streaming updates as the database changes, and send requests to invoke reducers. Messages received from the server over a WebSocket will follow the same total ordering of transactions as are committed to the database. +As an extension of the [HTTP API](/docs/http), SpacetimeDB offers a WebSocket API. Clients can subscribe to a database via a WebSocket connection to receive streaming updates as the database changes, and send requests to invoke reducers. Messages received from the server over a WebSocket will follow the same total ordering of transactions as are committed to the database. The SpacetimeDB SDKs comminicate with their corresponding database using the WebSocket API. diff --git a/package.json b/package.json index 2c2b9445..e7716fa4 100644 --- a/package.json +++ b/package.json @@ -5,10 +5,13 @@ "main": "index.js", "dependencies": {}, "devDependencies": { + "@types/node": "^22.10.2", + "tsx": "^4.19.2", "typescript": "^5.3.2" }, "scripts": { - "build": "tsc" + "build": "tsc nav.ts --outDir docs", + "check-links": "tsx scripts/checkLinks.ts" }, "author": "Clockwork Labs", "license": "ISC" diff --git a/scripts/checkLinks.ts b/scripts/checkLinks.ts new file mode 100644 index 00000000..78a8daf8 --- /dev/null +++ b/scripts/checkLinks.ts @@ -0,0 +1,231 @@ +import fs from 'fs'; +import path from 'path'; +import nav from '../nav'; // Import the nav object directly + +// Function to map slugs to file paths from nav.ts +function extractSlugToPathMap(nav: { items: any[] }): Map { + const slugToPath = new Map(); + + function traverseNav(items: any[]): void { + items.forEach((item) => { + if (item.type === 'page' && item.slug && item.path) { + const resolvedPath = path.resolve(__dirname, '../docs', item.path); + slugToPath.set(`/docs/${item.slug}`, resolvedPath); + } else if (item.type === 'section' && item.items) { + traverseNav(item.items); // Recursively traverse sections + } + }); + } + + traverseNav(nav.items); + return slugToPath; +} + +// Function to assert that all files in slugToPath exist +function validatePathsExist(slugToPath: Map): void { + slugToPath.forEach((filePath, slug) => { + if (!fs.existsSync(filePath)) { + throw new Error(`File not found: ${filePath} (Referenced by slug: ${slug})`); + } + }); +} + +// Function to extract links from markdown files with line numbers +function extractLinksFromMarkdown(filePath: string): { link: string; line: number }[] { + const fileContent = fs.readFileSync(filePath, 'utf-8'); + const lines = fileContent.split('\n'); + const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g; + + const links: { link: string; line: number }[] = []; + lines.forEach((lineContent, index) => { + let match: RegExpExecArray | null; + while ((match = linkRegex.exec(lineContent)) !== null) { + links.push({ link: match[2], line: index + 1 }); // Add 1 to make line numbers 1-based + } + }); + + return links; +} + +// Function to resolve relative links using slugs +function resolveLink(link: string, currentSlug: string): string { + if (link.startsWith('#')) { + // If the link is a fragment, resolve it to the current slug + return `${currentSlug}${link}`; + } + + if (link.startsWith('/')) { + // Absolute links are returned as-is + return link; + } + + // Resolve relative links based on slug + const currentSlugDir = path.dirname(currentSlug); + const resolvedSlug = path.normalize(path.join(currentSlugDir, link)).replace(/\\/g, '/'); + return resolvedSlug.startsWith('/docs') ? resolvedSlug : `/docs${resolvedSlug}`; +} + +// Function to extract headings from a markdown file +function extractHeadingsFromMarkdown(filePath: string): string[] { + if (!fs.existsSync(filePath) || !fs.lstatSync(filePath).isFile()) { + return []; // Return an empty list if the file does not exist or is not a file + } + + const fileContent = fs.readFileSync(filePath, 'utf-8'); + const headingRegex = /^(#{1,6})\s+(.*)$/gm; // Match markdown headings like # Heading + const headings: string[] = []; + let match: RegExpExecArray | null; + + while ((match = headingRegex.exec(fileContent)) !== null) { + const heading = match[2].trim(); // Extract the heading text + const slug = heading + .toLowerCase() + .replace(/[^\w\- ]+/g, '') // Remove special characters + .replace(/\s+/g, '-'); // Replace spaces with hyphens + headings.push(slug); + } + + return headings; +} + +// Function to check if the links in .md files match the slugs in nav.ts and validate fragments +function checkLinks(): void { + const brokenLinks: { file: string; link: string; line: number }[] = []; + let totalFiles = 0; + let totalLinks = 0; + let validLinks = 0; + let invalidLinks = 0; + let totalFragments = 0; + let validFragments = 0; + let invalidFragments = 0; + let currentFileFragments = 0; + + // Extract the slug-to-path mapping from nav.ts + const slugToPath = extractSlugToPathMap(nav); + + // Validate that all paths in slugToPath exist + validatePathsExist(slugToPath); + + console.log(`Validated ${slugToPath.size} paths from nav.ts`); + + // Extract valid slugs + const validSlugs = Array.from(slugToPath.keys()); + + // Reverse map from file path to slug for current file resolution + const pathToSlug = new Map(); + slugToPath.forEach((filePath, slug) => { + pathToSlug.set(filePath, slug); + }); + + // Get all .md files to check + const mdFiles = getMarkdownFiles(path.resolve(__dirname, '../docs')); + + totalFiles = mdFiles.length; + + mdFiles.forEach((file) => { + const links = extractLinksFromMarkdown(file); + totalLinks += links.length; + + const currentSlug = pathToSlug.get(file) || ''; + + links.forEach(({ link, line }) => { + // Exclude external links (starting with http://, https://, mailto:, etc.) + if (/^([a-z][a-z0-9+.-]*):/.test(link)) { + return; // Skip external links + } + + const siteLinks = ['/install', '/images']; + for (const siteLink of siteLinks) { + if (link.startsWith(siteLink)) { + return; // Skip site links + } + } + + + // Resolve the link + const resolvedLink = resolveLink(link, currentSlug); + + // Split the resolved link into base and fragment + const [baseLink, fragmentRaw] = resolvedLink.split('#'); + const fragment: string | null = fragmentRaw || null; + + if (fragment) { + totalFragments += 1; + } + + // Check if the base link matches a valid slug + if (!validSlugs.includes(baseLink)) { + brokenLinks.push({ file, link: resolvedLink, line }); + invalidLinks += 1; + return; + } else { + validLinks += 1; + } + + // Validate the fragment, if present + if (fragment) { + const targetFile = slugToPath.get(baseLink); + if (targetFile) { + const targetHeadings = extractHeadingsFromMarkdown(targetFile); + + if (!targetHeadings.includes(fragment)) { + brokenLinks.push({ file, link: resolvedLink, line }); + invalidFragments += 1; + invalidLinks += 1; + } else { + validFragments += 1; + if (baseLink === currentSlug) { + currentFileFragments += 1; + } + } + } + } + }); + }); + + if (brokenLinks.length > 0) { + console.error(`\nFound ${brokenLinks.length} broken links:`); + brokenLinks.forEach(({ file, link, line }) => { + console.error(`File: ${file}:${line}, Link: ${link}`); + }); + } else { + console.log('All links are valid!'); + } + + // Print statistics + console.log('\n=== Link Validation Statistics ==='); + console.log(`Total markdown files processed: ${totalFiles}`); + console.log(`Total links processed: ${totalLinks}`); + console.log(` Valid links: ${validLinks}`); + console.log(` Invalid links: ${invalidLinks}`); + console.log(`Total links with fragments processed: ${totalFragments}`); + console.log(` Valid links with fragments: ${validFragments}`); + console.log(` Invalid links with fragments: ${invalidFragments}`); + console.log(`Fragments referring to the current file: ${currentFileFragments}`); + console.log('================================='); + + if (brokenLinks.length > 0) { + process.exit(1); // Exit with an error code if there are broken links + } +} + +// Function to get all markdown files recursively +function getMarkdownFiles(dir: string): string[] { + let files: string[] = []; + const items = fs.readdirSync(dir); + + items.forEach((item) => { + const fullPath = path.join(dir, item); + const stat = fs.lstatSync(fullPath); + + if (stat.isDirectory()) { + files = files.concat(getMarkdownFiles(fullPath)); // Recurse into directories + } else if (fullPath.endsWith('.md')) { + files.push(fullPath); + } + }); + + return files; +} + +checkLinks(); diff --git a/tsconfig.json b/tsconfig.json index 2a5ee7d2..efe136bd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,8 @@ "target": "ESNext", "module": "commonjs", "outDir": "./docs", + "esModuleInterop": true, + "strict": true, "skipLibCheck": true } } diff --git a/yarn.lock b/yarn.lock index fce89544..d923eebd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,7 +2,196 @@ # yarn lockfile v1 +"@esbuild/aix-ppc64@0.23.1": + version "0.23.1" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz#51299374de171dbd80bb7d838e1cfce9af36f353" + integrity sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ== + +"@esbuild/android-arm64@0.23.1": + version "0.23.1" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz#58565291a1fe548638adb9c584237449e5e14018" + integrity sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw== + +"@esbuild/android-arm@0.23.1": + version "0.23.1" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.23.1.tgz#5eb8c652d4c82a2421e3395b808e6d9c42c862ee" + integrity sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ== + +"@esbuild/android-x64@0.23.1": + version "0.23.1" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.23.1.tgz#ae19d665d2f06f0f48a6ac9a224b3f672e65d517" + integrity sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg== + +"@esbuild/darwin-arm64@0.23.1": + version "0.23.1" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz#05b17f91a87e557b468a9c75e9d85ab10c121b16" + integrity sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q== + +"@esbuild/darwin-x64@0.23.1": + version "0.23.1" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.23.1.tgz#c58353b982f4e04f0d022284b8ba2733f5ff0931" + integrity sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw== + +"@esbuild/freebsd-arm64@0.23.1": + version "0.23.1" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz#f9220dc65f80f03635e1ef96cfad5da1f446f3bc" + integrity sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA== + +"@esbuild/freebsd-x64@0.23.1": + version "0.23.1" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz#69bd8511fa013b59f0226d1609ac43f7ce489730" + integrity sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g== + +"@esbuild/linux-arm64@0.23.1": + version "0.23.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz#8050af6d51ddb388c75653ef9871f5ccd8f12383" + integrity sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g== + +"@esbuild/linux-arm@0.23.1": + version "0.23.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz#ecaabd1c23b701070484990db9a82f382f99e771" + integrity sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ== + +"@esbuild/linux-ia32@0.23.1": + version "0.23.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz#3ed2273214178109741c09bd0687098a0243b333" + integrity sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ== + +"@esbuild/linux-loong64@0.23.1": + version "0.23.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz#a0fdf440b5485c81b0fbb316b08933d217f5d3ac" + integrity sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw== + +"@esbuild/linux-mips64el@0.23.1": + version "0.23.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz#e11a2806346db8375b18f5e104c5a9d4e81807f6" + integrity sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q== + +"@esbuild/linux-ppc64@0.23.1": + version "0.23.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz#06a2744c5eaf562b1a90937855b4d6cf7c75ec96" + integrity sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw== + +"@esbuild/linux-riscv64@0.23.1": + version "0.23.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz#65b46a2892fc0d1af4ba342af3fe0fa4a8fe08e7" + integrity sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA== + +"@esbuild/linux-s390x@0.23.1": + version "0.23.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz#e71ea18c70c3f604e241d16e4e5ab193a9785d6f" + integrity sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw== + +"@esbuild/linux-x64@0.23.1": + version "0.23.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.23.1.tgz#d47f97391e80690d4dfe811a2e7d6927ad9eed24" + integrity sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ== + +"@esbuild/netbsd-x64@0.23.1": + version "0.23.1" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.23.1.tgz#44e743c9778d57a8ace4b72f3c6b839a3b74a653" + integrity sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA== + +"@esbuild/openbsd-arm64@0.23.1": + version "0.23.1" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz#05c5a1faf67b9881834758c69f3e51b7dee015d7" + integrity sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q== + +"@esbuild/openbsd-x64@0.23.1": + version "0.23.1" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz#2e58ae511bacf67d19f9f2dcd9e8c5a93f00c273" + integrity sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA== + +"@esbuild/sunos-x64@0.23.1": + version "0.23.1" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz#adb022b959d18d3389ac70769cef5a03d3abd403" + integrity sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA== + +"@esbuild/win32-arm64@0.23.1": + version "0.23.1" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz#84906f50c212b72ec360f48461d43202f4c8b9a2" + integrity sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A== + +"@esbuild/win32-ia32@0.23.1": + version "0.23.1" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz#5e3eacc515820ff729e90d0cb463183128e82fac" + integrity sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ== + +"@esbuild/win32-x64@0.23.1": + version "0.23.1" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz#81fd50d11e2c32b2d6241470e3185b70c7b30699" + integrity sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg== + +"@types/node@^22.10.2": + version "22.10.2" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.10.2.tgz#a485426e6d1fdafc7b0d4c7b24e2c78182ddabb9" + integrity sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ== + dependencies: + undici-types "~6.20.0" + +esbuild@~0.23.0: + version "0.23.1" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.23.1.tgz#40fdc3f9265ec0beae6f59824ade1bd3d3d2dab8" + integrity sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg== + optionalDependencies: + "@esbuild/aix-ppc64" "0.23.1" + "@esbuild/android-arm" "0.23.1" + "@esbuild/android-arm64" "0.23.1" + "@esbuild/android-x64" "0.23.1" + "@esbuild/darwin-arm64" "0.23.1" + "@esbuild/darwin-x64" "0.23.1" + "@esbuild/freebsd-arm64" "0.23.1" + "@esbuild/freebsd-x64" "0.23.1" + "@esbuild/linux-arm" "0.23.1" + "@esbuild/linux-arm64" "0.23.1" + "@esbuild/linux-ia32" "0.23.1" + "@esbuild/linux-loong64" "0.23.1" + "@esbuild/linux-mips64el" "0.23.1" + "@esbuild/linux-ppc64" "0.23.1" + "@esbuild/linux-riscv64" "0.23.1" + "@esbuild/linux-s390x" "0.23.1" + "@esbuild/linux-x64" "0.23.1" + "@esbuild/netbsd-x64" "0.23.1" + "@esbuild/openbsd-arm64" "0.23.1" + "@esbuild/openbsd-x64" "0.23.1" + "@esbuild/sunos-x64" "0.23.1" + "@esbuild/win32-arm64" "0.23.1" + "@esbuild/win32-ia32" "0.23.1" + "@esbuild/win32-x64" "0.23.1" + +fsevents@~2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +get-tsconfig@^4.7.5: + version "4.8.1" + resolved "https://registry.yarnpkg.com/get-tsconfig/-/get-tsconfig-4.8.1.tgz#8995eb391ae6e1638d251118c7b56de7eb425471" + integrity sha512-k9PN+cFBmaLWtVz29SkUoqU5O0slLuHJXt/2P+tMVFT+phsSGXGkp9t3rQIqdz0e+06EHNGs3oM6ZX1s2zHxRg== + dependencies: + resolve-pkg-maps "^1.0.0" + +resolve-pkg-maps@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz#616b3dc2c57056b5588c31cdf4b3d64db133720f" + integrity sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw== + +tsx@^4.19.2: + version "4.19.2" + resolved "https://registry.yarnpkg.com/tsx/-/tsx-4.19.2.tgz#2d7814783440e0ae42354d0417d9c2989a2ae92c" + integrity sha512-pOUl6Vo2LUq/bSa8S5q7b91cgNSjctn9ugq/+Mvow99qW6x/UZYwzxy/3NmqoT66eHYfCVvFvACC58UBPFf28g== + dependencies: + esbuild "~0.23.0" + get-tsconfig "^4.7.5" + optionalDependencies: + fsevents "~2.3.3" + typescript@^5.3.2: version "5.3.2" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.2.tgz#00d1c7c1c46928c5845c1ee8d0cc2791031d4c43" integrity sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ== + +undici-types@~6.20.0: + version "6.20.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.20.0.tgz#8171bf22c1f588d1554d55bf204bc624af388433" + integrity sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg== From e1753ec4f550ca221208eb303e82140d635e5b0e Mon Sep 17 00:00:00 2001 From: james gilles Date: Fri, 3 Jan 2025 14:30:18 -0500 Subject: [PATCH 078/164] Rewrite index.md (#111) * Start rewriting index.md * Docs rewrite * Typo, SQL, more sales talk * Address comments * Finish addressing comments * Update docs/index.md Co-authored-by: Phoebe Goldman * Address comments * Clarify Identity section * Fix links, clarify deployment section --------- Co-authored-by: Phoebe Goldman --- docs/index.md | 182 ++++++++++++++++++++++++++++++++++---------------- 1 file changed, 126 insertions(+), 56 deletions(-) diff --git a/docs/index.md b/docs/index.md index 700c2bfc..86b72bec 100644 --- a/docs/index.md +++ b/docs/index.md @@ -14,13 +14,11 @@ To get started running your own standalone instance of SpacetimeDB check out our ## What is SpacetimeDB? -You can think of SpacetimeDB as a database that is also a server. +SpacetimeDB is a database that is also a server. -It is a relational database system that lets you upload your application logic directly into the database by way of very fancy stored procedures called "modules". +SpacetimeDB is a full-featured relational database system that lets you run your application logic **inside** the database. You no longer need to deploy a separate web or game server. [Several programming languages](#module-libraries) are supported, including C# and Rust. You can still write authorization logic, just like you would in a traditional server. -Instead of deploying a web or game server that sits in between your clients and your database, your clients connect directly to the database and execute your application logic inside the database itself. You can write all of your permission and authorization logic right inside your module just as you would in a normal server. - -This means that you can write your entire application in a single language, Rust, and deploy it as a single binary. No more microservices, no more containers, no more Kubernetes, no more Docker, no more VMs, no more DevOps, no more infrastructure, no more ops, no more servers. +This means that you can write your entire application in a single language and deploy it as a single binary. No more microservices, no more containers, no more Kubernetes, no more Docker, no more VMs, no more DevOps, no more infrastructure, no more ops, no more servers. An application deployed this way is called a **module**.
SpacetimeDB Architecture @@ -30,71 +28,158 @@ This means that you can write your entire application in a single language, Rust
-It's actually similar to the idea of smart contracts, except that SpacetimeDB is a database, has nothing to do with blockchain, and it's a lot faster than any smart contract system. - -So fast, in fact, that the entire backend our MMORPG [BitCraft Online](https://bitcraftonline.com) is just a SpacetimeDB module. We don't have any other servers or services running, which means that everything in the game, all of the chat messages, items, resources, terrain, and even the locations of the players are stored and processed by the database before being synchronized out to all of the clients in real-time. - -SpacetimeDB is optimized for maximum speed and minimum latency rather than batch processing or OLAP workloads. It is designed to be used for real-time applications like games, chat, and collaboration tools. - -This speed and latency is achieved by holding all of application state in memory, while persisting the data in a write-ahead-log (WAL) which is used to recover application state. - -## State Synchronization - -SpacetimeDB syncs client and server state for you so that you can just write your application as though you're accessing the database locally. No more messing with sockets for a week before actually writing your game. +This is similar to ["smart contracts"](https://en.wikipedia.org/wiki/Smart_contract), except that SpacetimeDB is a **database** and has nothing to do with blockchain. Because it isn't a blockchain, it can be dramatically faster than many "smart contract" systems. -## Identities +In fact, it's so fast that we've been able to write the entire backend of our MMORPG [BitCraft Online](https://bitcraftonline.com) as a Spacetime module. Everything in the game -- chat messages, items, resources, terrain, and player locations -- is stored and processed by the database. SpacetimeDB [automatically mirrors](#state-mirroring) relevant state to connected players in real-time. -A SpacetimeDB `Identity` is a unique identifier that is used to authenticate and authorize access to the database. Importantly, while it represents who someone is, does NOT represent what they can do. Your application's logic will determine what a given identity is able to do by allowing or disallowing a transaction based on the caller's `Identity` along with any module-defined data and logic. +SpacetimeDB is optimized for maximum speed and minimum latency, rather than batch processing or analytical workloads. It is designed for real-time applications like games, chat, and collaboration tools. -SpacetimeDB associates each user with a 256-bit (32-byte) integer `Identity`. These identities are usually formatted as 64-digit hexadecimal strings. Identities are public information, and applications can use them to identify users. Identities are a global resource, so a user can use the same identity with multiple applications, so long as they're hosted by the same SpacetimeDB instance. +Speed and latency is achieved by holding all of your application state in memory, while persisting data to a commit log which is used to recover data after restarts and system crashes. -Each identity has a corresponding authentication token. The authentication token is private, and should never be shared with anyone. Specifically, authentication tokens are [JSON Web Tokens](https://datatracker.ietf.org/doc/html/rfc7519) signed by a secret unique to the SpacetimeDB instance. +## State Mirroring -Additionally, each database has an owner `Identity`. Many database maintenance operations, like publishing a new version or evaluating arbitrary SQL queries, are restricted to only authenticated connections by the owner. +SpacetimeDB can generate client code in a [variety of languages](#client-side-sdks). This creates a client library custom-designed to talk to your module. It provides easy-to-use interfaces for connecting to a module and submitting requests. It can also **automatically mirror state** from your module's database. -SpacetimeDB provides tools in the CLI and the [client SDKs](/docs/sdks) for managing credentials. - -## Addresses - -A SpacetimeDB `Address` is an opaque identifier for a database or a client connection. An `Address` is a 128-bit integer, usually formatted as a 32-character (16-byte) hexadecimal string. - -Each SpacetimeDB database has an `Address`, generated by the SpacetimeDB host, which can be used to connect to the database or to request information about it. Databases may also have human-readable names, which are mapped to addresses internally. - -Each client connection has an `Address`. These addresses are opaque, and do not correspond to any metadata about the client. They are notably not IP addresses or device identifiers. A client connection can be uniquely identified by its `(Identity, Address)` pair, but client addresses may not be globally unique; it is possible for multiple connections with the same `Address` but different identities to co-exist. SpacetimeDB modules should treat `Identity` as differentiating users, and `Address` as differentiating connections by the same user. +You write SQL queries specifying what information a client is interested in -- for instance, the terrain and items near a player's avatar. SpacetimeDB will generate types in your client language for the relevant tables, and feed your client live updates whenever the database state changes. Note that this is a **read-only** mirror -- the only way to change the database is to submit requests, which are validated on the server. ## Language Support -### Server-side Libraries +### Module Libraries -Currently, Rust is the best-supported language for writing SpacetimeDB modules. Support for lots of other languages is in the works! +SpacetimeDB modules are server-side applications that are deployed using the `spacetime` CLI tool. - [Rust](/docs/modules/rust) - [(Quickstart)](/docs/modules/rust/quickstart) - [C#](/docs/modules/c-sharp) - [(Quickstart)](/docs/modules/c-sharp/quickstart) -- Python (Coming soon) -- Typescript (Coming soon) -- C++ (Planned) -- Lua (Planned) ### Client-side SDKs +SpacetimeDB clients are applications that connect to SpacetimeDB modules. The `spacetime` CLI tool supports automatically generating interface code that makes it easy to interact with a particular module. + - [Rust](/docs/sdks/rust) - [(Quickstart)](/docs/sdks/rust/quickstart) - [C#](/docs/sdks/c-sharp) - [(Quickstart)](/docs/sdks/c-sharp/quickstart) - [TypeScript](/docs/sdks/typescript) - [(Quickstart)](/docs/sdks/typescript/quickstart) -- Python (Planned) -- C++ (Planned) -- Lua (Planned) ### Unity SpacetimeDB was designed first and foremost as the backend for multiplayer Unity games. To learn more about using SpacetimeDB with Unity, jump on over to the [SpacetimeDB Unity Tutorial](/docs/unity/part-1). +## Key architectural concepts + +### Host +A SpacetimeDB **host** is a combination of a database and server that runs [modules](#module). You can run your own SpacetimeDB host, or use the SpacetimeDB maincloud. + +### Module +A SpacetimeDB **module** is an application that runs on a [host](#host). + +A module exports [tables](#table), which store data, and [reducers](#reducer), which allow [clients](#client) to make requests. + +Technically, a SpacetimeDB module is a [WebAssembly module](https://developer.mozilla.org/en-US/docs/WebAssembly) that imports a specific low-level [WebAssembly ABI](/docs/webassembly-abi) and exports a small number of special functions. However, the SpacetimeDB [server-side libraries](#module-libraries) hide these low-level details. As a developer, writing a module is mostly like writing any other C# or Rust application, except for the fact that a [special CLI tool](/install) is used to build and deploy the application. + +### Table +A SpacetimeDB **table** is a database table. Tables are declared in a module's native language. For instance, in Rust, a table is declared like so: + +```csharp +[SpacetimeDB.Table(Name = "players", Public = true)] +public partial struct Player +{ + [SpacetimeDB.PrimaryKey] + uint playerId; + string name; + uint age; + Identity user; +} +``` + + +The contents of a table can be read and updated by [reducers](#reducer). +Tables marked `public` can also be read by [clients](#client). + +### Reducer +A **reducer** is a function exported by a [module](#module). +Connected [clients](#client-side-sdks) can call reducers to interact with the module. +This is a form of [remote procedure call](https://en.wikipedia.org/wiki/Remote_procedure_call). +Reducers can be invoked across languages. For example, a Rust [module](#module) can export a reducer like so: + +```csharp +[SpacetimeDB.Reducer] +public static void SetPlayerName(ReducerContext ctx, uint playerId, string name) +{ + // ... +} +``` + + +And a C# [client](#client) can call that reducer: + +```cs +void Main() { + // ...setup code, then... + Connection.Reducer.SetPlayerName(57, "Marceline"); +} +``` + +These look mostly like regular function calls, but under the hood, the client sends a request over the internet, which the module processes and responds to. + +The `ReducerContext` passed into a reducer includes information about the caller's [identity](#identity) and [address](#address). +It also allows accessing the database and scheduling future operations. + +### Client +A **client** is an application that connects to a [module](#module). A client logs in using an [identity](#identity) and receives an [address](#address) to identify the connection. After that, it can call [reducers](#reducer) and query public [tables](#table). + +Clients are written using the [client-side SDKs](#client-side-sdks). The `spacetime` CLI tool allows automatically generating code that works with the client-side SDKs to talk to a particular module. + +Clients are regular software applications that module developers can choose how to deploy (through Steam, app stores, package managers, or any other software deployment method, depending on the needs of the application.) + +### Identity + +A SpacetimeDB `Identity` identifies someone interacting with a module. It is a long lived, public, globally valid identifier that will always refer to the same end user, even across different connections. + +A user's `Identity` is attached to every [reducer call](#reducer) they make, and you can use this to decide what they are allowed to do. + +Modules themselves also have Identities. When you `spacetime publish` a module, it will automatically be issued an `Identity` to distinguish it from other modules. Your client application will need to provide this `Identity` when connecting to the [host](#host). + +Identities are issued using the [OpenID Connect](https://openid.net/developers/how-connect-works/) specification. Typically, module authors are responsible for issuing Identities to their end users. OpenID Connect makes it easy to allow users to authenticate to these accounts through standard services like Google and Facebook. (The idea is that you issue user accounts -- `Identities` -- but it's easy to let users log in to those accounts through Google or Facebook.) + + + +### Address + + + +An `Address` identifies client connections to a SpacetimeDB module. + +A user has a single [`Identity`](#identity), but may open multiple connections to your module. Each of these will receive a unique `Address`. + +### Energy +**Energy** is the currency used to pay for data storage and compute operations in a SpacetimeDB host. + + + ## FAQ 1. What is SpacetimeDB? - It's a whole cloud platform within a database that's fast enough to run real-time games. + It's a cloud platform within a database that's fast enough to run real-time games. 1. How do I use SpacetimeDB? - Install the `spacetime` command line tool, choose your favorite language, import the SpacetimeDB library, write your application, compile it to WebAssembly, and upload it to the SpacetimeDB cloud platform. Once it's uploaded you can call functions directly on your application and subscribe to changes in application state. + Install the `spacetime` command line tool, choose your favorite language, import the SpacetimeDB library, write your module, compile it to WebAssembly, and upload it to the SpacetimeDB cloud platform. Once it's uploaded you can call functions directly on your application and subscribe to changes in application state. 1. How do I get/install SpacetimeDB? Just install our command line tool and then upload your application to the cloud. @@ -102,20 +187,5 @@ SpacetimeDB was designed first and foremost as the backend for multiplayer Unity 1. How do I create a new database with SpacetimeDB? Follow our [Quick Start](/docs/getting-started) guide! -TL;DR in an empty directory: - -```bash -spacetime init --lang=rust -spacetime publish -``` - 5. How do I create a Unity game with SpacetimeDB? Follow our [Unity Project](/docs/unity-tutorial) guide! - -TL;DR in an empty directory: - -```bash -spacetime init --lang=rust -spacetime publish -spacetime generate --out-dir --lang=csharp -``` From 0c1b5fc645068a8fe09c2854db5fb52940e03462 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Fri, 3 Jan 2025 16:06:09 -0500 Subject: [PATCH 079/164] Switches to a canonical GitHub slugger format so that our slugs always match links generated on GitHub (#123) Standardized slugging across docs and web --- package.json | 4 +++- scripts/checkLinks.ts | 7 +++---- yarn.lock | 5 +++++ 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index e7716fa4..26e48ffb 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,9 @@ "version": "1.0.0", "description": "This repository contains the markdown files which are used to display documentation on our [website](https://spacetimedb.com/docs).", "main": "index.js", - "dependencies": {}, + "dependencies": { + "github-slugger": "^2.0.0" + }, "devDependencies": { "@types/node": "^22.10.2", "tsx": "^4.19.2", diff --git a/scripts/checkLinks.ts b/scripts/checkLinks.ts index 78a8daf8..d67302f7 100644 --- a/scripts/checkLinks.ts +++ b/scripts/checkLinks.ts @@ -1,6 +1,7 @@ import fs from 'fs'; import path from 'path'; import nav from '../nav'; // Import the nav object directly +import GitHubSlugger from 'github-slugger'; // Function to map slugs to file paths from nav.ts function extractSlugToPathMap(nav: { items: any[] }): Map { @@ -76,12 +77,10 @@ function extractHeadingsFromMarkdown(filePath: string): string[] { const headings: string[] = []; let match: RegExpExecArray | null; + const slugger = new GitHubSlugger(); while ((match = headingRegex.exec(fileContent)) !== null) { const heading = match[2].trim(); // Extract the heading text - const slug = heading - .toLowerCase() - .replace(/[^\w\- ]+/g, '') // Remove special characters - .replace(/\s+/g, '-'); // Replace spaces with hyphens + const slug = slugger.slug(heading); // Slugify the heading text headings.push(slug); } diff --git a/yarn.lock b/yarn.lock index d923eebd..1527675f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -171,6 +171,11 @@ get-tsconfig@^4.7.5: dependencies: resolve-pkg-maps "^1.0.0" +github-slugger@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/github-slugger/-/github-slugger-2.0.0.tgz#52cf2f9279a21eb6c59dd385b410f0c0adda8f1a" + integrity sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw== + resolve-pkg-maps@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz#616b3dc2c57056b5588c31cdf4b3d64db133720f" From 432965551bc1745f2e478eb4049f2c82572e7c0a Mon Sep 17 00:00:00 2001 From: Ingvar Stepanyan Date: Mon, 6 Jan 2025 21:12:47 +0000 Subject: [PATCH 080/164] Describe how to define multiple tables with the same type (#113) * Describe how to define multiple tables with the same type Fixes #90. Co-authored-by: Phoebe Goldman --------- Co-authored-by: Phoebe Goldman --- docs/modules/c-sharp/index.md | 11 +++++++++++ docs/modules/rust/index.md | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/docs/modules/c-sharp/index.md b/docs/modules/c-sharp/index.md index f6763fc7..2c31bb1c 100644 --- a/docs/modules/c-sharp/index.md +++ b/docs/modules/c-sharp/index.md @@ -253,6 +253,17 @@ public partial struct Person } ``` +You can create multiple tables backed by items of the same type by applying it with different names. For example, to store active and archived posts separately and with different privacy rules, you can declare two tables like this: + +```csharp +[SpacetimeDB.Table(Name = "Post", Public = true)] +[SpacetimeDB.Table(Name = "ArchivedPost", Public = false)] +public partial struct Post { + public string Title; + public string Body; +} +``` + #### Column attributes Attribute `[SpacetimeDB.Column]` can be used on any field of a `SpacetimeDB.Table`-marked `struct` or `class` to customize column attributes as seen above. diff --git a/docs/modules/rust/index.md b/docs/modules/rust/index.md index 24fa82bf..dba75ab2 100644 --- a/docs/modules/rust/index.md +++ b/docs/modules/rust/index.md @@ -165,6 +165,17 @@ struct Person { } ``` +You can create multiple tables backed by items of the same type by applying it with different names. For example, to store active and archived posts separately and with different privacy rules, you can declare two tables like this: + +```rust +#[table(name = post, public)] +#[table(name = archived_post)] +struct Post { + title: String, + body: String, +} +``` + ### Defining reducers `#[reducer]` is always applied to top level Rust functions. They can take arguments of types known to SpacetimeDB (just like fields of structs must be known to SpacetimeDB), and either return nothing, or return a `Result<(), E: Debug>`. From 7920ebfa77e535977e8c4247949e56196d443682 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Tue, 7 Jan 2025 16:57:58 -0500 Subject: [PATCH 081/164] Fix missing tick marks in Rust quickstart (#124) Fixed missing tick marks in rust quickstart --- docs/sdks/rust/quickstart.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/sdks/rust/quickstart.md b/docs/sdks/rust/quickstart.md index 38d9dee7..e7e3fd3e 100644 --- a/docs/sdks/rust/quickstart.md +++ b/docs/sdks/rust/quickstart.md @@ -290,6 +290,7 @@ fn print_message(ctx: &EventContext, message: &Message) { .unwrap_or_else(|| "unknown".to_string()); println!("{}: {}", sender, message.text); } +``` ### Print past messages in order From 03a360c0d0e0679da2f5fc91286ea1140d6a9f90 Mon Sep 17 00:00:00 2001 From: james gilles Date: Wed, 8 Jan 2025 14:09:11 -0500 Subject: [PATCH 082/164] Add vocabulary to style guide, update index.md accordingly (#126) * Add vocabulary to style guide, update index.md accordingly * s/runs/hosts/ * Update STYLE.md Co-authored-by: Phoebe Goldman * Rewrite 'Reducer' --------- Co-authored-by: Phoebe Goldman --- STYLE.md | 41 +++++++++++++++++++++++++++++++++++++ docs/getting-started.md | 2 +- docs/index.md | 45 +++++++++++++++++++++-------------------- 3 files changed, 65 insertions(+), 23 deletions(-) diff --git a/STYLE.md b/STYLE.md index 25d5848d..a72b1dd2 100644 --- a/STYLE.md +++ b/STYLE.md @@ -63,6 +63,47 @@ Don't make promises, even weak ones, about what we plan to do in the future, wit If your document needs to describe a feature that isn't implemented yet, either rewrite to not depend on that feature, or just say that it's a "current limitation" without elaborating further. Include a workaround if there is one. +## Key vocabulary + +There are a small number of key terms that we need to use consistently throughout the documentation. + +The most important distinction is the following: + +- **Database**: This is the active, running entity that lives on a host. It contains a bunch of tables, like a normal database. It also has extra features: clients can connect to it directly and remotely call its stored procedures. +- **Module**: This is the source code that a developer uses to specify a database. It is a combination of a database schema and a collection of stored procedures. Once built and published, it becomes part of the running database. + +A database **has** a module; the module **is part of** the database. + +The module does NOT run on a host. The **database** runs on a host. + +A client does NOT "connect to the module". A client **connects to the database**. + +This distinction is subtle but important. People know what databases are, and we should reinforce that SpacetimeDB is a database. "Module" is a quirky bit of vocabulary we use to refer to collections of stored procedures. A RUNNING APPLICATION IS NOT CALLED A MODULE. + +Other key vocabulary: +- (SpacetimeDB) **Host**: the application that hosts **databases**. It is multi-tenant and can host many **databases** at once. +- **Client**: any application that connects to a **database**. +- **End user**: anybody using a **client**. +- **Database developer**: the person who maintains a **database**. + - DO NOT refer to database developers as "users" in documentation. + Sometimes we colloquially refer to them as "our users" internally, + but it is clearer to use the term "database developers" in public. +- **Table**: A set of typed, labeled **rows**. Each row stores data for a number of **columns**. Used to store data in a **database**. +- **Column**: you know what this is. +- **Row**: you know what this is. + - DO NOT refer to rows as "tuples", because the term overlaps confusingly with "tuple types" in module languages. + We reserve the word "tuple" to refer to elements of these types. +- **Reducer**: A stored procedure that can be called remotely in order to update a **database**. + - Confusingly, reducers do not actually "reduce" data in the sense of querying and compressing it to return a result. + But it is too late to change it. C'est la vie. +- **Connection**: a connection between a **client** and a **database**. Receives an **Address**. A single connection may open multiple **subscriptions**. +- **Subscription**: an active query that mirrors data from the database to a **client**. +- **Address**: identifier for an active connection. +- **Identity**: A combination of an issuing OpenID Connect provider and an Identity Token issued by that provider. Globally unique and public. + - Technically, "Identity" should be called "Identifier", but it is too late to change it. + - A particular **end user** may have multiple Identities issued by different providers. + - Each **database** also has an **Identity**. + ## Reference pages Reference pages are where intermediate users will look to get a view of all of the capabilities of a tool, and where experienced users will check for specific information on behaviors of the types, functions, methods &c they're using. Each user-facing component in the SpacetimeDB ecosystem should have a reference page. diff --git a/docs/getting-started.md b/docs/getting-started.md index 33265dc2..7afeec31 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,6 +1,6 @@ # Getting Started -To develop SpacetimeDB applications locally, you will need to run the Standalone version of the server. +To develop SpacetimeDB databases locally, you will need to run the Standalone version of the server. 1. [Install](/install) the SpacetimeDB CLI (Command Line Interface) 2. Run the start command: diff --git a/docs/index.md b/docs/index.md index 86b72bec..bfa957ef 100644 --- a/docs/index.md +++ b/docs/index.md @@ -18,7 +18,7 @@ SpacetimeDB is a database that is also a server. SpacetimeDB is a full-featured relational database system that lets you run your application logic **inside** the database. You no longer need to deploy a separate web or game server. [Several programming languages](#module-libraries) are supported, including C# and Rust. You can still write authorization logic, just like you would in a traditional server. -This means that you can write your entire application in a single language and deploy it as a single binary. No more microservices, no more containers, no more Kubernetes, no more Docker, no more VMs, no more DevOps, no more infrastructure, no more ops, no more servers. An application deployed this way is called a **module**. +This means that you can write your entire application in a single language and deploy it as a single binary. No more microservices, no more containers, no more Kubernetes, no more Docker, no more VMs, no more DevOps, no more infrastructure, no more ops, no more servers.
SpacetimeDB Architecture @@ -30,7 +30,7 @@ This means that you can write your entire application in a single language and d This is similar to ["smart contracts"](https://en.wikipedia.org/wiki/Smart_contract), except that SpacetimeDB is a **database** and has nothing to do with blockchain. Because it isn't a blockchain, it can be dramatically faster than many "smart contract" systems. -In fact, it's so fast that we've been able to write the entire backend of our MMORPG [BitCraft Online](https://bitcraftonline.com) as a Spacetime module. Everything in the game -- chat messages, items, resources, terrain, and player locations -- is stored and processed by the database. SpacetimeDB [automatically mirrors](#state-mirroring) relevant state to connected players in real-time. +In fact, it's so fast that we've been able to write the entire backend of our MMORPG [BitCraft Online](https://bitcraftonline.com) as a single SpacetimeDB database. Everything in the game -- chat messages, items, resources, terrain, and player locations -- is stored and processed by the database. SpacetimeDB [automatically mirrors](#state-mirroring) relevant state to connected players in real-time. SpacetimeDB is optimized for maximum speed and minimum latency, rather than batch processing or analytical workloads. It is designed for real-time applications like games, chat, and collaboration tools. @@ -38,22 +38,22 @@ Speed and latency is achieved by holding all of your application state in memory ## State Mirroring -SpacetimeDB can generate client code in a [variety of languages](#client-side-sdks). This creates a client library custom-designed to talk to your module. It provides easy-to-use interfaces for connecting to a module and submitting requests. It can also **automatically mirror state** from your module's database. +SpacetimeDB can generate client code in a [variety of languages](#client-side-sdks). This creates a client library custom-designed to talk to your database. It provides easy-to-use interfaces for connecting to the database and submitting requests. It can also **automatically mirror state** from your database to client applications. -You write SQL queries specifying what information a client is interested in -- for instance, the terrain and items near a player's avatar. SpacetimeDB will generate types in your client language for the relevant tables, and feed your client live updates whenever the database state changes. Note that this is a **read-only** mirror -- the only way to change the database is to submit requests, which are validated on the server. +You write SQL queries specifying what information a client is interested in -- for instance, the terrain and items near a player's avatar. SpacetimeDB will generate types in your client language for the relevant tables, and feed clients a stream of live updates whenever the database state changes. Note that this is a **read-only** mirror -- the only way to change the database is to submit requests, which are validated on the server. ## Language Support ### Module Libraries -SpacetimeDB modules are server-side applications that are deployed using the `spacetime` CLI tool. +Every SpacetimeDB database contains a collection of stored procedures called a **module**. Modules can be written in C# or Rust. They specify a database schema and the business logic that responds to client requests. Modules are administered using the `spacetime` CLI tool. - [Rust](/docs/modules/rust) - [(Quickstart)](/docs/modules/rust/quickstart) - [C#](/docs/modules/c-sharp) - [(Quickstart)](/docs/modules/c-sharp/quickstart) ### Client-side SDKs -SpacetimeDB clients are applications that connect to SpacetimeDB modules. The `spacetime` CLI tool supports automatically generating interface code that makes it easy to interact with a particular module. +**Clients** are applications that connect to SpacetimeDB databases. The `spacetime` CLI tool supports automatically generating interface code that makes it easy to interact with a particular database. - [Rust](/docs/sdks/rust) - [(Quickstart)](/docs/sdks/rust/quickstart) - [C#](/docs/sdks/c-sharp) - [(Quickstart)](/docs/sdks/c-sharp/quickstart) @@ -66,17 +66,19 @@ SpacetimeDB was designed first and foremost as the backend for multiplayer Unity ## Key architectural concepts ### Host -A SpacetimeDB **host** is a combination of a database and server that runs [modules](#module). You can run your own SpacetimeDB host, or use the SpacetimeDB maincloud. +A SpacetimeDB **host** is a server that hosts [databases](#database). You can run your own host, or use the SpacetimeDB maincloud. Many databases can run on a single host. -### Module -A SpacetimeDB **module** is an application that runs on a [host](#host). +### Database +A SpacetimeDB **database** is an application that runs on a [host](#host). -A module exports [tables](#table), which store data, and [reducers](#reducer), which allow [clients](#client) to make requests. +A database exports [tables](#table), which store data, and [reducers](#reducer), which allow [clients](#client) to make requests. -Technically, a SpacetimeDB module is a [WebAssembly module](https://developer.mozilla.org/en-US/docs/WebAssembly) that imports a specific low-level [WebAssembly ABI](/docs/webassembly-abi) and exports a small number of special functions. However, the SpacetimeDB [server-side libraries](#module-libraries) hide these low-level details. As a developer, writing a module is mostly like writing any other C# or Rust application, except for the fact that a [special CLI tool](/install) is used to build and deploy the application. +A database's schema and business logic is specified by a piece of software called a **module**. Modules can be written in C# or Rust. + +(Technically, a SpacetimeDB module is a [WebAssembly module](https://developer.mozilla.org/en-US/docs/WebAssembly) that imports a specific low-level [WebAssembly ABI](/docs/webassembly-abi) and exports a small number of special functions. However, the SpacetimeDB [server-side libraries](#module-libraries) hide these low-level details. As a developer, writing a module is mostly like writing any other C# or Rust application, except for the fact that a [special CLI tool](/install) is used to deploy the application.) ### Table -A SpacetimeDB **table** is a database table. Tables are declared in a module's native language. For instance, in Rust, a table is declared like so: +A SpacetimeDB **table** is a SQL database table. Tables are declared in a module's native language. For instance, in C#, a table is declared like so: ```csharp [SpacetimeDB.Table(Name = "players", Public = true)] @@ -106,10 +108,10 @@ The contents of a table can be read and updated by [reducers](#reducer). Tables marked `public` can also be read by [clients](#client). ### Reducer -A **reducer** is a function exported by a [module](#module). -Connected [clients](#client-side-sdks) can call reducers to interact with the module. +A **reducer** is a function exported by a [database](#database). +Connected [clients](#client-side-sdks) can call reducers to interact with the database. This is a form of [remote procedure call](https://en.wikipedia.org/wiki/Remote_procedure_call). -Reducers can be invoked across languages. For example, a Rust [module](#module) can export a reducer like so: +A reducer can be written in C# like so: ```csharp [SpacetimeDB.Reducer] @@ -136,17 +138,16 @@ void Main() { } ``` -These look mostly like regular function calls, but under the hood, the client sends a request over the internet, which the module processes and responds to. +These look mostly like regular function calls, but under the hood, the client sends a request over the internet, which the database processes and responds to. -The `ReducerContext` passed into a reducer includes information about the caller's [identity](#identity) and [address](#address). -It also allows accessing the database and scheduling future operations. +The `ReducerContext` passed into a reducer includes information about the caller's [identity](#identity) and [address](#address). The database can reject any request it doesn't approve of. ### Client -A **client** is an application that connects to a [module](#module). A client logs in using an [identity](#identity) and receives an [address](#address) to identify the connection. After that, it can call [reducers](#reducer) and query public [tables](#table). +A **client** is an application that connects to a [database](#database). A client logs in using an [identity](#identity) and receives an [address](#address) to identify the connection. After that, it can call [reducers](#reducer) and query public [tables](#table). -Clients are written using the [client-side SDKs](#client-side-sdks). The `spacetime` CLI tool allows automatically generating code that works with the client-side SDKs to talk to a particular module. +Clients are written using the [client-side SDKs](#client-side-sdks). The `spacetime` CLI tool allows automatically generating code that works with the client-side SDKs to talk to a particular database. -Clients are regular software applications that module developers can choose how to deploy (through Steam, app stores, package managers, or any other software deployment method, depending on the needs of the application.) +Clients are regular software applications that developers can choose how to deploy (through Steam, app stores, package managers, or any other software deployment method, depending on the needs of the application.) ### Identity @@ -156,7 +157,7 @@ A user's `Identity` is attached to every [reducer call](#reducer) they make, and Modules themselves also have Identities. When you `spacetime publish` a module, it will automatically be issued an `Identity` to distinguish it from other modules. Your client application will need to provide this `Identity` when connecting to the [host](#host). -Identities are issued using the [OpenID Connect](https://openid.net/developers/how-connect-works/) specification. Typically, module authors are responsible for issuing Identities to their end users. OpenID Connect makes it easy to allow users to authenticate to these accounts through standard services like Google and Facebook. (The idea is that you issue user accounts -- `Identities` -- but it's easy to let users log in to those accounts through Google or Facebook.) +Identities are issued using the [OpenID Connect](https://openid.net/developers/how-connect-works/) specification. Database developers are responsible for issuing Identities to their end users. OpenID Connect lets users log in to these accounts through standard services like Google and Facebook. From 7fde2db10a98141b337d6d8551c8497217b27d38 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Wed, 15 Jan 2025 21:12:20 -0500 Subject: [PATCH 083/164] Blackholio Tutorial Update (#128) * First commit to test images * Progress on the unity tutorial * moar docs * Part 3 incoming * Part 3 and almost part 4! * Finalized part 4 and deleted unused files * Small fixes and clarifications * Review typos and fixes * Fixed link validation for images * Removed the reference to an image which is going to be moving * Fixed the tsconfig issue (it was not actually using the tsconfig) * Shortened titles * Just testing something * Undo change * Consistent headers * Commenting out images for now * Missed an image --- docs/nav.js | 30 +- docs/sdks/c-sharp/index.md | 2 - docs/unity/index.md | 29 +- docs/unity/part-1-hero-image.png | Bin 0 -> 357247 bytes docs/unity/part-1-unity-hub-new-project.jpg | Bin 0 -> 38324 bytes docs/unity/part-1-universal-2d-template.png | Bin 0 -> 475078 bytes docs/unity/part-1.md | 113 +- docs/unity/part-2.md | 414 +++++++ docs/unity/part-2a-rust.md | 314 ------ docs/unity/part-2b-c-sharp.md | 339 ------ docs/unity/part-3-player-on-screen.png | Bin 0 -> 64962 bytes docs/unity/part-3.md | 1109 +++++++++++++------ docs/unity/part-4.md | 527 +++++---- docs/unity/part-5.md | 108 -- nav.ts | 14 +- package.json | 2 +- scripts/checkLinks.ts | 124 ++- tsconfig.json | 7 +- 18 files changed, 1677 insertions(+), 1455 deletions(-) create mode 100644 docs/unity/part-1-hero-image.png create mode 100644 docs/unity/part-1-unity-hub-new-project.jpg create mode 100644 docs/unity/part-1-universal-2d-template.png create mode 100644 docs/unity/part-2.md delete mode 100644 docs/unity/part-2a-rust.md delete mode 100644 docs/unity/part-2b-c-sharp.md create mode 100644 docs/unity/part-3-player-on-screen.png delete mode 100644 docs/unity/part-5.md diff --git a/docs/nav.js b/docs/nav.js index 5c3a920e..fea9ed85 100644 --- a/docs/nav.js +++ b/docs/nav.js @@ -1,23 +1,10 @@ -"use strict"; -var __assign = (this && this.__assign) || function () { - __assign = Object.assign || function(t) { - for (var s, i = 1, n = arguments.length; i < n; i++) { - s = arguments[i]; - for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) - t[p] = s[p]; - } - return t; - }; - return __assign.apply(this, arguments); -}; -Object.defineProperty(exports, "__esModule", { value: true }); function page(title, slug, path, props) { - return __assign({ type: 'page', path: path, slug: slug, title: title }, props); + return { type: 'page', path, slug, title, ...props }; } function section(title) { - return { type: 'section', title: title }; + return { type: 'section', title }; } -var nav = { +const nav = { items: [ section('Intro'), page('Overview', 'index', 'index.md'), // TODO(BREAKING): For consistency & clarity, 'index' slug should be renamed 'intro'? @@ -29,12 +16,9 @@ var nav = { section('Unity Tutorial - Basic Multiplayer'), page('Overview', 'unity-tutorial', 'unity/index.md'), page('1 - Setup', 'unity/part-1', 'unity/part-1.md'), - page('2a - Server (Rust)', 'unity/part-2a-rust', 'unity/part-2a-rust.md'), - page('2b - Server (C#)', 'unity/part-2b-c-sharp', 'unity/part-2b-c-sharp.md'), - page('3 - Client', 'unity/part-3', 'unity/part-3.md'), - section('Unity Tutorial - Advanced'), - page('4 - Resources And Scheduling', 'unity/part-4', 'unity/part-4.md'), - page('5 - BitCraft Mini', 'unity/part-5', 'unity/part-5.md'), + page('2 - Connecting to SpacetimeDB', 'unity/part-2', 'unity/part-2.md'), + page('3 - Gameplay', 'unity/part-3', 'unity/part-3.md'), + page('4 - Moving and Colliding', 'unity/part-4', 'unity/part-4.md'), section('Server Module Languages'), page('Overview', 'modules', 'modules/index.md'), page('Rust Quickstart', 'modules/rust/quickstart', 'modules/rust/quickstart.md'), @@ -65,4 +49,4 @@ var nav = { page('SQL Reference', 'sql', 'sql/index.md'), ], }; -exports.default = nav; +export default nav; diff --git a/docs/sdks/c-sharp/index.md b/docs/sdks/c-sharp/index.md index a044e4ea..0315d36c 100644 --- a/docs/sdks/c-sharp/index.md +++ b/docs/sdks/c-sharp/index.md @@ -101,8 +101,6 @@ This is the global instance of a SpacetimeDB client in a particular .NET/Unity p The Unity SpacetimeDB SDK relies on there being a `NetworkManager` somewhere in the scene. Click on the GameManager object in the scene, and in the inspector, add the `NetworkManager` component. -![Unity-AddNetworkManager](/images/unity-tutorial/Unity-AddNetworkManager.JPG) - This component will handle updating and closing the [`SpacetimeDBClient.instance`](#property-spacetimedbclientinstance) for you, but will not call [`SpacetimeDBClient.Connect`](#method-spacetimedbclientconnect), you still need to handle that yourself. See the [Unity Tutorial](/docs/unity-tutorial) for more information. ### Method `SpacetimeDBClient.Connect` diff --git a/docs/unity/index.md b/docs/unity/index.md index 0fa181c6..2a2d78f4 100644 --- a/docs/unity/index.md +++ b/docs/unity/index.md @@ -1,25 +1,30 @@ -# Unity Tutorial Overview +# Unity Tutorial - Overview Need help with the tutorial or CLI commands? [Join our Discord server](https://discord.gg/spacetimedb)! -The objective of this progressive tutorial is to help you become acquainted with the basic features of SpacetimeDB. By the end, you should have a basic understanding of what SpacetimeDB offers for developers making multiplayer games. It assumes that you have a basic understanding of the Unity Editor, using a command line terminal and coding. +In this tutorial you'll learn how to build a small-scoped MMORPG in Unity, from scratch, using SpacetimeDB. Although, the game we're going to build is small in scope, it'll scale to hundreds of players and will help you get acquanted with all the features and best practices of SpacetimeDB, while building a fun little game. -We'll give you some CLI commands to execute. If you are using Windows, we recommend using Git Bash or PowerShell. For Mac, we recommend Terminal. +By the end, you should have a basic understanding of what SpacetimeDB offers for developers making multiplayer games. -Tested with UnityEngine `2022.3.20f1 LTS` (and may also work on newer versions). +The game is inspired by [agar.io](https://agar.io), but SpacetimeDB themed with some fun twists. If you're not familiar [agar.io](https://agar.io), it's a web game in which you and hundreds of other players compete to cultivate mass to become the largest cell in the Petri dish. -## Unity Tutorial - Basic Multiplayer +Our game, called Blackhol.io, will be similar but with space themes in twists. It should give you a great idea of the types of games you can develop with SpacetimeDB. + +This tutorial assumes that you have a basic understanding of the Unity Editor, using a command line terminal and coding. We'll give you some CLI commands to execute. If you are using Windows, we recommend using Git Bash or PowerShell. For Mac, we recommend Terminal. + +Tested with UnityEngine `2022.3.32f1 LTS` (and may also work on newer versions). + +## Blackhol.io Tutorial - Basic Multiplayer Get started with the core client-server setup. For part 2, you may choose your server module preference of [Rust](/docs/modules/rust) or [C#](/docs/modules/c-sharp): - [Part 1 - Setup](/docs/unity/part-1) -- [Part 2a - Server (Rust)](/docs/unity/part-2a-rust) -- [Part 2b - Server (C#)](/docs/unity/part-2b-c-sharp) -- [Part 3 - Client](/docs/unity/part-3) +- [Part 2 - Connecting to SpacetimeDB](/docs/unity/part-2) +- [Part 3 - Gameplay](/docs/unity/part-3) +- [Part 4 - Moving and Colliding](/docs/unity/part-4) -## Unity Tutorial - Advanced +## Blackhol.io Tutorial - Advanced -By this point, you should already have a basic understanding of SpacetimeDB client, server and CLI: +If you already have a good understanding of the SpacetimeDB client and server, check out our completed tutorial project! -- [Part 4 - Resources & Scheduling](/docs/unity/part-4) -- [Part 5 - BitCraft Mini](/docs/unity/part-5) +https://github.com/ClockworkLabs/Blackholio diff --git a/docs/unity/part-1-hero-image.png b/docs/unity/part-1-hero-image.png new file mode 100644 index 0000000000000000000000000000000000000000..b37d9690bfb1823a26b8db35cac03591ae7ddad0 GIT binary patch literal 357247 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 literal 0 HcmV?d00001 diff --git a/docs/unity/part-1-unity-hub-new-project.jpg b/docs/unity/part-1-unity-hub-new-project.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7f23fdb88520d9aa11edcde3cef469a678814fcc GIT binary patch literal 38324 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 cB*tC^B^8-iljb;(Q93!@2$t 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 z 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%0D#apvjaZ!$xIH^kz+V}_ElMyv^- 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 z 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 @N$7- z*bdvxDb9xA8fS;DMlTu-&PrzrX|O+2DeR}F?7mM2I?IwU>hG^ky#$0|KBDl3bHd$ad&M>&b(nXy3bC(xXeSFtBE;>GF%R(1*zJwzw=b1xx|J0FAU z&}eu1G^!j;){S*A<_$GCh}Z^I?Nwl`@a)`=gv{f^GCy1Gi?YXXPwQOR)uWcAV5m~3 zZQ!q5ifid)1qhlCqrk2z&fzP=DFdgQLg zSGr>`6K9fx<6QCSVXS{v+7Bv+5{>EoyYszsrAIAXxBtaQR+pCR^j{UrrZ(xVFuTOs zYg*pne=S%zxz1ef16ZrNt$*C1y$;O>TX!;xRj zP_EvvEkY}P8m98S)hMlVSF5B7s8SjGuNT(!n@~ljL^&w!Vq`E#h2us9;W#+EVNd>W~=Rg@6lb zB#TLHrV3hLy-KFkU+<=&Q2Bvoo`d4zXt1&TiQ#TnArQX>!Q>sOu9}&BPnS z>=hiH$lZH>?IWv6Jl}(HQ!J+98ZmBDySoMnrx(!&2&45%<7{>>Roly~M+?V_m3$XZ zITECG&5GS=O$x}BtjOqVV^=ZnlgN6!G@==25u3ib6({3bR5-C-GD_;Jr{+NH$Gqhf z&hD%)H=gdl`Z2*hUTC~ADt=uOIr5Q_i48~v;UlD$(G+yXaujKL%|XNSi;jwL3*OKQs54Q{LDf{`gWxh8oI*I#bC z+|n$)z8s2%rg?GtPkM-XMxq7>m3e>p3aW(h!)po2$CIX(4Vib2Zg0D~+BPX~=G=Tf zMaCvmv#T|bcm6jE0Acui*vIUUhHsh?HxYc3^XKeV(Uks6A>!nzxToT?js^+Xb)BnH|`L)`dJw8!o|2du_dcz$dcImS3dGmLmf~!tSY}ra(uXufPILJwkKSK~J1$qwYhYEIkwTSg4PWc)M=28XR z9V%|zP0eF`-rO8sl*l>n7C)`<`f?T6{?SZd#YpsthdGUtv6@Ye z`T&(4QsR7%klQmeGz9`|D751uVAz;hx|5T}D8{CGSitXml-s>QUE8k!W#5GXz0V_w zgyL`S=bJ5bGkK(F$C}0sZhzs!AN?3UuD>^L0s1;&ynU``5yW*%5o|5S2|wm8$ue`5 zi}zJ|9b@e&eA-x9sdP%&TnClNAxH3U@9pjMxz)$A-8F_tzf+6=O~#He6nKRpJn>k_ zQGBP3uEmZqV|^c6T#|;Sqj2yWMu8PZYZn{zVY?B*tG@QeebVjq7roxZ07FgJ!|xZJ zoT`K_88(ote1e1-TltfqLqjW3eLktt{7qU_{xFAmyc%V)6%pPZW~wW=8#Kv-56Bls zORYtTJL067d(-c?O0vGVanB22v@NaXkH{ws>;{_lu8xru%ndAdjOj*Syhl^r8c&}L zcJScHdV|#DIni^=yD8*I6-_zxg8H)2J19VsA&(3J>&;D3m& zn}ytGyAzprJ5O@J0MwlSCt={fZJqvcSZ4};Pu+7w>8~b zi$d(qCBDglciChc+v-a$ZYhSRIOgmjCH=1{XBR+X8C(jk zAB@D2#C;v}UWynNefz`iJ{{6CtqXRtJ7L8WVuUYITfydbVvL`>qW66!!Nn)sj4BTy z^q=*2(h{4qZOGg0I61QOCfnqL@k3r?`i7Y(onn~RbEDpXC7ySPD163X6VV=HWI0kE zg5A1C9V%NB>AK}jQC8R=VjBkubdKop9oFy?{2{EG|eWzD>2r2sMXct#>e}ax>p5? zc;!mhxd%TvA>OO%hUUBbw0SXd8L+`$ns^T%nDabe)?{AH7JcDjIOpQ9mx)@gN1w^G z$_lfvW`ZH9Wb0>+a+<=bK21)PWc~!C6OO|(+D4N>Rj=XnYukir!LXlIYJXT?VH1~n zqec$D>PR*HKopD6BZ=`L6YOjKeCWNU`4F%6_?1<^eVyiuJ+diKh#U_++-skIRMwpf zONKBG>6V76|IBt+w&@dyxqU_yxOezq&`K{zo|MbV8NX@&Gq}tYqsEq*Fl+Y>Oa*Tz z_J%}QR9u1xZM>_FW)@Ldmr4J3Joq13e&{hq?7yyn=J8j#JR_*j9-i5$Pe{8dE@@l; zs`>=zpx!>q!vl(J_n+Z8bGW~aDc8HnCqC(O9#@kz9w@!dyz*1kb1lp%t{p6Q5yc4p-cw4XXQ@rsby)F)xxs1TO)jVp%GvTggHLXJPb4=qyEi=a% z!bAJBi5b(p_l~wkW^C={rojE%L3v?WSzIrC#_t4K1zWXgKWFA#jzAYQa@Fw3+bId* zUYlIpbM44zsDZ8i(SdaS+gPSrY=}r4`kW`+mRR9=G~($`N}-iwm(0k ze!W&=_5QOFClVd=RTEi1J?q-TXZp%Du|yK&4zPrmdjamz88#)r&u85^g$c_*!nG>+ zEq6#U;a`HKG8T!yl**&SmP&QIe+X@!`I+4?d$_#skOB`Je0xQyafzjx`eG9vb8L9a zg;l5L256pGp%?R|jZp*TgI+mMkc)oZ4I6+(TpJGaP=DlQ}CausN9s36YbqpjZc;PBof}dZYKf}IvyU5p!u||tj!!}Jgr%#|JsogbEPexN zmU~9U#Lf^X0B`Aa$o6F>x&($1OxbS1)ZUh@VhD%`{ra-;zvj(yO$P%fh==PF@jmGt;4R{UnRF}}!n2bZb?)plAt8! zzn#3;1^j4siNsGMonr{sF4>90ZPe!yGsuNdv+Om=#T4&^sXMJno5j@-kHcX@^Yz05`~=&hvaVluXZlV8A%P$yQZ^zlj;}qPF^(i zUs7cDI{pu0Yd6@mUWD9K2hci^s#!0U0Qu=%Gl7c^ixW9f*VAa}xGk6v(UYA}{kU!OUpgI!V`$~Bw zLdeV&Lvgq++FDO|Ptp3UDvv$sxPX(T^G^YRMrgkM&XCLAmxIwYVG+O^yw&n7=K7;S zZv9G1x^p{HvR`IVL-i?2aEcMZN=M-H+4>q3@gfdUE&ait&zPEN;|!Y9NdHa~F#}nq z>PRcAU3P4!CS``dxI*lhE&`vV?J?Ha)-1~^ilcSbnVq``>?t{THE{=Z_V?<{qPm}l zt=8f^b!BK#{bqXgXOrJXl9hnxC&lmM8ziPA$gd#b>)O0u_7EPMMFmuevi>i#{Z*Y+ z=#n+{>Xb)b;xUc8aR9Grm+@10pR{s`HT5zE+6|G{w%;K59ANl%8^eFq#xn}=Yxu3x zBiKYtXP=|xamLs5`C2#^|34G3|B=h|-&NB;mxqB#Pjo9r(%>2!vyYeV*q||Q9PK@N zOVC*G#V}W83O3f@h$$yYHTQKf0k++sFooc!=koxGj{$iqGXc;2N6!TcD*0(8z!|2C|SSRJaTn6FU)|fxPwaFn7j0>OgciM11N4OMB$A*!fsZc(j ztuWnJA2}Fz8LVd+VWaXXvc5TsIz?Uq{VH-?60JIX{4Hl^ec?CfwaqNX3JBZ?RfZ$R ztKIVe_WlwPA+>4frS{aT2yoRmU5x~j-^bgDT6FSQIeHxO!xtL{XP4kcixS(&7WaF5 zGESHYJjTJOg?Vm&ODXv3-d>FAl4?n{H}9zI@Ozt4fwK=R*&Q~+T)>-I7n&h!Jj_&T8g%bIlE!{ju5w9nAk(KhUCZ&BO+p(=))M@J zZBv$yMuxntNTUooS+#_Qv3B1>&Nd|mikMxeV*4)wnLA{uX^9Ibfk-=vbsu#-IuJ0tpgB6&%& zt(Q;}Rl-`fGR5k1C8{4KxXRU!;Xt zTDFGA9=)ubHFx+2KfDoO_YlRL5EF8b|AUpl?|MI`d9LhcIDM3{V0d?m3cJGpOo;&= z5`QPh#wcQrLXO$K1=wh{^Q=IF{W)tSG)|@H?=_}ya~3uC@eDAFH}@#j^L7&s!!?GYzMTY za{3BU8$}A5OYr!3J5vJ`oE^NP!N3cjnnAXhNNE3eSJlru`DsLPJ!C6`7>AT7Ok@=O ztrYU)I4*n!-z(Lmv3xwfkrN1l!(HO&=ma?!X!=hX_Ol2(SA(YXAX+ssc|8K3ULt=H z7^p=y2 zK>{8wW##_lzAYtf5yXPX6YeSe`{WVEzMdC^JD9aOLE_ukaTF}ayR-N-t^mh>!}Fv- zG4w;hsF8=W@f9y%gzbi{{^R}`gUKqEU4__t)OG<9iFb46-N2cp^PugnZ^5H*oxhds zXWJgvC*EEz{zbSzanNp87c4(%hdC=UHOF;TQc?1$WLr+b1U38r+fx6J6Y) z1dzoZ(3AM`)dI^Wy>XB6^Ld>}UdpUPy7lL&_`Z_$v;Nj5Y6#)iIQ{XV1}Jr^ie$)p zO83U}I^fu#GY3Df`ipkzDlQORT0s}G(+9INGZ~3pnDjXNW^EA92?X~njxWf?do&AN z#i!mxwEJlU#O#$0f2SwW03{7*!)a68#)s-M?d~wYxhS5jWXmWy{Itft`JE?B`Nz;mssK2z~~y(YS^ zeLd8zk%A9~?Ky}wga(+~U+9mRtgT9nal~kGRIoO`qZjg_VXq#41>Pl#wQ4L}AR^Y5 zQ(l=>b4X;Na|iC1VaAmQe}JrR`AlAp(eCuVagosSdvTwy4iP@c-JYRcSXF^KtP$Cc z2dEx$9L_rtO<#9;M6Nj}tL$LBnbbt5G_ep`H^?9;)`zB)-TT#(?mShNM=%AgmK3Yz zJ>I&ZY;;IyQoXbn9~UDJB_7*I#ktp*RDYEzd>;Jd+RLOPk~i(fiL}LfhC|y_`peg7 zc&LY0qr?!6uayd3j>DgEYG;EYJ)pBo?2_aJe@z|b`EcUw<>*`M=#ooqLe~w4z$umW z-1TqF9Vz-=e47+(Q>6bkTmC<*rjZ_Y?oBOl6HRO>q^}Zs^hKgWRn+<=l&&l_&JVQ( z*^qM5;74SR5ZZ0m-+-sRp|QQ(T>0yVjc&uPTiEJG``>wICZMH4J?X?SW&E8_R|;9+ z-slK(4g!7j)%eUC!_{~@FIHW-KbBl|UUrrPpB8g`6aMg6hWr4-z+@Mfo zt|1RDNTu^{l~!AynYcs-H41aqI9GLf6jBWH-V*kb*)T%!*$ zS8%{)_(L!9mE?@W5tHRK*JSNGn<&DE520;u><)7&*8sP#pB#)kj&96gShO_3@2H|C zg&E(oG)-60^>iKhI!u4`Z^g^#kI+qkwiTE7DPts)BT~T6I2=uhtN#WvCeYyVqtnqP z)ljU3%)bw(>O=B%xc3u_ojBF#9;moY7Tu|)_39(U_#^4P#!J+2`(@$7`MNC9vwyue z=~#0)kv&2TL_Do5=Y2ZQO^a!zSXqA>yGwEQ(O$0BeX_;8b$EF#B1-0D?>kkPrMK{n zW~c+=a${jqAt$%bZ^m;CrDL-wj3XK%Xv2I~dR6Ufex7APCXr>qx;vlD%m-iM2ilXw z7fZ@2Y3MErUqbEV+QN90lc_*R{C7g8;#cK(<3_l|0&Y9wdM>stH3_SjX0}(BD-NCOYN_#7Jn+USOJ62 zu)m@HXySg3$2zdoD(8Non34vB?S=3=jFzh&-pzmOqW=$@#Xm>kWh10iONW!l=LOVH zZqci!G|;$d5Iw3tU{s|+*C?*E*Hc2=A2T+p=UI$P;f71K3V5ms@UIu^E8h3;?7ZxL zs!1Jd=H%q2N}aWel(rX_n8omi0XUiCqqky)7aqlV(F=K(h37zU%49?vHXuL)BvzQf3k;m=?Ig-Kh9e>jZUQ{GbOgCCW>9 z^K;M3s!?|?(<4?noLu{xbPZgcmD?~HlB96dH&#Agyq3gSX{1Ld?f)%=I8!c)5(&wv zdn`dXY;^?39dEOyw&0C``qF;j!cV|Uxv?nebk0`v^#{KwowI=JJBnK@~2jSyIZQ!@pSSm+3B$nAb z#c*~L`xjT4O|^Gh>R~@$e%TLPAJsWeY%y!kfv^ZPEp3nZ6Qj5W40Mp~xS)PqW;5oG z-hQ-j&%}{OD15t9fC190<8Uz*kuI{N$q|LDKOCdJs8|$K^KEqe^;bM51)ddwuq~mK zjT4qTZrO^O@t+pkqWmD<=5O=@&I%uL%)x z=NP)xK~69SAqu=NUq)s!M&`l;lIC9+7JO}7c~ADQtaw|Dwj=%g_oZtfLx~SDB#pN< zBWV_(gLEH|0mMp|z> zpKYKv;sOA(NTOgsD?X8@(2?|8WEzgWPR|Lhl7v`r5YJe8oY(d#W?VBRp=R8R0=^jF z;+`)UAr{~pRKq94O!VN?#|3Jh8WNd$(PanWG5D*;{PM_R=${~E!KhoU_c@ImG0B00*l$lmmoXbhloA_Lhq|3K< z3!BqP;Oe&4W)xO{d_#0v1$p-qbCt8pH{;HUQE$!vf zsC|y5u9Yj4{y-NinYv1XYG7e}N z!)`rGUVKdFExAAbcipKQ3CXU`8&?1v+{kFiIzd@Op_4?*^4PMk)ZXaD`%|1G`t&Qw zrC_&rE&RkIK821ACm^yUE8zJ7?75v@lg6QYb;+QQQ&4kWW+g#3+l;duP|JeDRUv<0 zrX|m=gUiDSm_?+!^`t^-Pia!I{UjxDiAA7s@csw4r%{uoz|AhPn^F7^#$=^X6JsDc zxVS0=N|NiN`Aj8n^mO);0`Yw9>;?D~|09u|mx=-kDd+^9si|gN;0c!1y{bI2#2O2Z zEv9rZtfZXe)A38{ZGkSK;z<)5G8P}B$4m^v^V<_szB68rm`AgTbd^8{*ixXh0#^Z{ z%~UV*u>PCDTp&;qU}oYs_F+K`TR|Wlt&Tr8LH+Yb@QN39j1Wjh;^QeWKTNqecsdC9 znp{+FX)Ed0UGK;;k+kfC%O+l&9T=&tebs8)ubHj;X;9iW?=2fi>S?Ov^Iz<ouSX{-E6N=}g_;yMwe~*9Xh{h2HOJD5eF-A!@ z6caf+lWADL=_q7ro=jiup9K$Hjh?;m-!QwGveN4DyT9Rae>=&=zzLpW-K@Z)C`Q06 zZd8qp%I5~9NTZa!#NxeLL(9)&SKEq4+RdS-o$#FCRVOUcqQ&~dy3%JNhc$vZb(1ol z@6s(Y+tTQ?8j{%-mC@9#>Y^gCMK7{?ovu#YO{<#V}F+~BNxpRd8@D?DJ2#|P3Iyk^I^_0wze_vzz8 zfgevx&b?V>f10e*zLDPO-Ashgo@)Jht4&?*B){P?63Ljv%F*K*O8VNF>YO&V)%)^0 zXl+;dVG=(nnYT{t&3ndO)ta55$7}cuhVe+iv`J`=_rU}H%Ie~C#Lm?8|K3%oO-ZGA zG+I{8ueilH*`+|b_6=v0P*osa2^BxHusemUKQH{EBisaMz1Z+sM zbLQj2d!#Bp4+VB3R_)r(&Gm((hmV4l9s6qwC8E>rA(RpQ1RX-Bv53jYdIm%m*p{Po z7;}c0mw&3&_>{=VAp@T1%bvXETG}z<*KKQ+g6}Pc|qUiUiFTzB%!oQV?dj#tJr;zJzP?nHKX8HM?DEKP# zsX!FP8BWzlBRL-~NtoVS^9nm3k_xu|N+le>X*AjB4zRe}IhcWw)}#hRHmmr)0J~1M zZsN**4I|;ZzGr4}Hz7>*JA+c9xKKLsy>OxmlFB)6K4Ri&gp2e8tMTpVj)1%4hF(Ge zB?M&bGl~;&Du&*rPLA<$Cojs(?7izPI3S?z_N(z~MZFpS(^?*jls*yGavJ}1kLsaV z(wsaV-zbfFY!(x4`-24|_k1{0K9@{Fw7S|+ng3q7zLK$#SBaAGoVo4Vu=s`2c!aFA-g}-u~V@9@)2NiS0BAoC6{PUY9IY;0dp})4t-@)`VCX zxb;qH+eszF<3CM}Q@QJ>^Aoc8lm4_i);BTf+d5a#mgx&`tJ7rAI8*NXN(23^2B51q z3H*~opZyG-wikWb@=`oBtF^{3o#QGGpgXTJXKxI!#}!&iN{1Mmh|5s!2FF*YeQ9GFbhMI8bzg!pCOPFnT*>8|I z=C41QRISEUG?UAn`e%7@KRiHpZgT{#ULY!SVX*(Z!2om%^@oMb{FUYkz8SpF;FV^h z0^iqVnG)(bmz0qV2yZWv@)shby-P_G8wR5ueX<|5)CmVB1DE2Bl&$t!QhWh5HH>jw zo;;+5I)OA!Cn>8V@qd|bhQZEunJTaJ$(|mdvwK6{))_SyPvS51L=`{(K^L)DI@$du zK?;xqcF#!kho63lz0+RwL3`%_qc{Q{u;J8V5w%T0<=)TXBt3EtACA@Ad_uc+ro=ZK z2s3Gf?4EI27t9EI9=%)txKncdoZC9Qd>Y|@D|kEKbB6$r1b+4aM)EE_p3Eo3MEHp%1qu z&*#dSp#XoEuB>bbG2FV#Vi*@&Q<}xb*elg%RZF6Fn)}9z&lSlI|{G8IQ z>AF=zGm1gMdm>dpWk{Dckoe0h2ot$Vh8%Bm1r(O}YHM0jsH>auYvDC}S8<5iH$vSd zX^Z1NC8_&&5>rv3)Ahew_9l~dUJj2XMZ^z6-N9+PI}`Z;;TD0=?qBCzH;RHC8PhT?-Ucw>uK^QPDdmo53pZ zLge>S8pq zS(J@Qu$>CbCPwQWN1M!F_Dx#-1<%X;eDufa>UaeiokDAD7Tk72@a9l45Qqtb0`=@# zokIf!CgRqujO0CF=A<&Ll=_zFp(hjdm_dyVwMZ8vI^T5%O1VwaG(6X~$1WH{A7U-2Z!VHPWNj^NPe{sU>jCO_$@1Pp{CX zVZ$j8hrUl|lXmdRM=rDkg##3a3EDM|b;+TdglEa;o8Qn8NFfx9;>+`CcZcqJSF1Z? z3@1IuJK$!K3y%IpUn>%m4(I3gjN#)1l(XHgk#M2P8FjKX{$hIu_0P49UU_S&6>Lt< z=30IX{c&PDxcv-j_k(hQL=8++Z$uw#xgzY4Nf~2%;s3X^&=y6L;k{0lFM!Kw z&jBcZ5Tm_Y9!FsiLucidC5R;wqjC%V;B&_@cEL2{iSmrpN$@yUGnM+ns>{F7(x#H` zn*>aj&!46LUN9_)oBEX~SybUv5wWzBfj`y39Zl=2A&X1lSYWexW~AemUO!0+B=lgw zMb#QmuZ4X@AD#KxZ!x8s!W55lMSq^#hF(g=z*7S6DJykbA}{RgdpKGVA0<*$zZxjB+C6^|Kh-1lqfsRb#|_ z2YH;iF`$hh2w1%sMjOZ!oW{Q(nZqGOqj$Gl@mv6X4H zS7DGofFW@Je(F8^YWgS`^A+XC>B!{r8zE`%^L(?zmBUtlqO`A;e9p4mC88d5c1-=4 zW@h19i@Cos;RpZsq`px31XiDxpG1RloI#dSKL{g4Tz~^jTn319yLd6GAd~f!*t8gG z+c;wJu%7LN`kNMTm)`|W_exEw8g=%|C@_gNpx=M);-&a1y`0+o|51F6y?SHc`D^u! zd*syM+CsyosBN(czlR}B&AhR9vXZx2BbTxi{1M8jni_CR9iQ6Y?=z=Vh9 zbv|kW|B*ToGEWj(L{|H-&Bs5&f1R%5>N=kN8$bLHKhD*AVspLmVjXJfvF^qU_LZJK zCfav5tIAH5SB_Yv9@Se(>7SztJ$W)Z*ud#(E^!lC!XNJh8|%_pA+X!a7d7jp4<@?t zeSC^e-Mf0b=2{RipZ7zp{A*f#{~L8-T#t~~{Kk%Ql5s9o@6_UQMBlpzx^Coc8J-O{ zL$%%eiW-Q##l7Y~eXXYBz3P&{{$YXi&i(vz`A$g-KT9-pD;Pv?34qr_0MWV zhYj@J8>a`ozS`?;PvbQuR5Bw5Xl~>s;y3goa%X&6&)FqIrB2lAj}p)gIMO7B%PsbH z5|om6MyN&-o+d0bkS2NBadS(vNBsmRulX6kNtaPq)mo<=xStwrAmST3&3gI@# zsSrhl+U`zgIY1Hv4!j&@w5cAyVcqkfHS|#igQmbHEdZ26+w_ub7nR7eF?3P=a}!aJ zIRwL)&_5J>*4@8_yYnT7UCD<&+mRHD4ttLj6nKG0ulzbjf%BZo!1^b1a;+wNZ&Ib| z&ty7ZM10L{M_XT;W=PMSph_)UXGOD`Yqu9`oQ)cgJ3i;@2su;E z&F{PT<<5Wwy67rL;=~ytAkb8dH#`Sr$k%3wTsq9XUvG6b6wPG7Z!5_rI*=-Pq zbHX_!bj`ngf%i`?^~rK#Z)|C~5j^hVkGIVV&m6|BfNw8JpDU5D_LIU*d+Ss&JeYX?-^y!@mQ&+NQ=j#@E zXGG>$ubL}kPE5HL;%^Cm$9oM&#V1! z=KszOj$1#zYExFNAGu#FSZntjsa$&9K#&9y4d$8VS0Lp)=rKz9^$*{JdTD((oxjHM z5;D5|E)c(3!$Hr&Y(2Pa_f4&l8Gv?Ia-8GyKSk$Tyj#qcS+E2%Ttgmv7un|QSW}S#+j^h}ZKz-wl^q-PwQ@ph=X!4H2#jV4Jmn6$ zJIvnR9>$ii{_bcHEc3w>w<8S@mYcT$sI62#U$Vd1>BDz^lNd;|GF}K3y>@E+QaM8; zKHqSOD6jc0x43x8^*g?(L7dRCGVfAf{6pqaboggzb9JmnVQSl#oteP;FNEzh^*hV8AGJSn z>6ql0W2+Ozr>{L7OuEOhjCMeyXm2S4ZJ@>3Rv$pY!#Th%(0G)E#dyao3ym&g_cuwZ zCn=v7*>RE0f;l8Wx9!|hg|SpO$VPP;*zc#rd15x0UaHAVy*SpX_lfWYY(W!J=AUHXMbD)Yb8 zoK!jLOSlY6+}&p%FjEC@RWv}oZ%nYO!{2_*Xk3{O?YR2a{KW}xLS>Ea)5*OW)igT9 zbxzcKcHWsye2!-Gx7&$a9TPKUIyTp9bHpZ(*4Efmq{j*~o!!`hqrH@ho}; zZU-aO8kE65{k8`SaeR^)c2>fKG98VDXuJUzJNU!|_)-M*qW2}hP z(o3&}mQ%t!C&#uZ6;7-EviaYzPh3Iwk3M(T+_dZE`z^y}?LIA!+pezHVxDH>r`JR0 z<`MbM;RE)&b^}E&7%t)UXcV@Sy!mm{(_X25S`^A{BoZj?;IhJae5(FPSPVARMfu|= z#SHl+z4Mp4e1l!0Cy$U*vvc>+v z0NY*P+GDw45H{U6p4Wf8isM5CgTE=dy8?e2-eERE|ulVp{bZi_X*=N_lH+5b4)~e z3yBuQmA@6D<5KKJHCjUbETL>z)hqrT9}2upntQ^}eTyaDhl-1mCbPz-O0f1>ATe9o zb5Keakx*%>PM-H?X1?h8qsLmLs0VopFdBKYC!sO;W0vP&V(R3oOeKAs!gf(kxOG@o zsYju6bfG=(x%gj#wT)?8;mGR@*Zb8`OGpDCHov^qUJMyRMQT z%3wDEIDWIT=HM+IvpK7fXL!Cluh4~id*X&Xg>Fb3cmkjd3IUE<`P26|Z@&{EH<&yg zhfxT3^0U-`aN4DbM4)g)vCf>E_%v2{{kuP{&HcSmdtQ*7wto|DmT0t`E7hE;!?)rgN)z#t?#(bH<8}LFCI}* z4}>L>mrC+wx4{tJx5)<>nm7q)LKdh1^px=auR4{t{{~Z7#`?rJ;h4gp`MUvPf)wdl z+6w>IIm1>Cop(i&NA%yEI=jPG)=wGJ+}!U$YrATJ8J}v0b^ey2R@0QYXNQ-aj5cz8Ng+=2|&ic3ERt>3jn@kO|6A_ zqlqEshK$MB7Vu9{?BRI+kFVpL$aTZ@(D8<4wLw zBlCMzE~SQ3rz?vH_H67m6^#9;h1RkYy$t05?qT_{!KcS}-rb_? z=8Z@(@z^x|GDifu)7f4l1tYolmdq%38?KiwwYjp>_1 zNP%qmoe1}=Oboslc@R4<{^Sl2X3<@Q=JBl^$oP&)cr#w&m#{wxn!ik*ofGyNWKK-m zej}+BITmAVph2=+fh7iB9#wrUQJ8XJx1NUk``i%LESYyxoqJw@`at=6(@l!eYz7_8 zikz%kP)hcbPyN<-U8a&9S@uM6|GX|EO{X0t3ltLlttilZQ+kKN-#)Dg#~DyO(CUnj zv*W@tPZ4J*{(Z(bE?A7zJ6D=SI?(Ss+2+_2YCLnkWpYni9NSD%OfJeujtv^dmpvYK zUnuW$F=Ke^8H(8Q^_oo6B?f`cTZL_WkuJwaEexu!ZnUsbRHT^Hgy>-jdqs|GiePk0 z6fPb^f-qT5P_9sj_s-GX#@51@=a$WMD*a7WPVu9FQHPhWW_2iE*Z;rb=l_bXGh_bs zh+{9r%)=(aXvYpbj14;rVPXTi#=HvHhXkWlmv&!!$iqoMk2^;qt$4fCp3N9jZg0H#w*a2TlD6#E}UO?xJr_yl0dk3^0~-Puh91)OER^>l(;-Pdhka|zYbQc#lSdz9N42k_TR#on z(wRN5m~mM*Kp#u=&L$7(isMX3<$uEhh%)*=2Pqwla={Gu0)!K8J)#j=^(n~(2AXc z({HGoIv%*92U^c4sGn3UVvz*l!V;v0_eoLRCNfA(6h;yA{tEd!NC{qnWK)T^Jkh6& zn(Q*p);NY5{K*tQxLl?=BVwqi*?GO4aCMJgjX2yYt7Q`EN@a7zRb~KYeLDqvWOj_* zVk;G3>}FE3O+h@~AOd{Aj6XCkZn?)@AW~!5D@mydc}7HG*I$Bx?~_5=Uy%}B9<(QW%HW-*6(=d>0PFol9Iy! zHj@HpW%zV46w!4T4||95R))_s-~!=?{qfLXS2G*IJ9KjWIi}c4^efoWC*Ar*Ef5qb z9YRbtKp0@2<|~*I<&?qg@sYzA0CI5#8YHYeKlzbT?`6&YSHjU$Uq#r)i z@TjpId=|<(0f<#HKEY!nOQtYr!~t(RhzxZ65!B0A^@=bYG8wi^DRw3)Bp21jGA~Z$ z3yNC|;cAxmiW$Oef)uyG4PW%GwV$;g} zqP}~pLOkyTm!XPNcE*~p8N7R2p@62ujYJpe-g6hNwU|Yp!OeXfCw~1sO0uRoNMMx+ z{E2eC(#xPORf(K|kolWO4?!kBG2f4`M+2*Si5U@1d+(&pmC#|gVP3?edc9>4H?7X1 zRybxdzbAiKONh#3N+AZnf@6r7AE-Hgg1x)BPXI10zL`%CG!mH@V#>ZH=9L7?{zwVc zF;dk@eE0X20{1xBPd2FK4YW(5)*zt09#u;o<4&-Z&p%McTdrnhXPK>h#I#RHT~gIC z6m9b4sO=M~_N+Il1Wqq=N5xbtO-sZ>y=9S80 zOSgZs<1@H@0x)R$#<6+%EI>x*OCSWLfOrb!tA*Ibjn4XootBfrklJq>r|KJYR~jh= zJc6G=)17D8vwL#ns9O62fnEzY3qukW%4+-~`inwJNkZ!nx38x^4em;#B-LAP3-K(9 z&#p=BX6j?Brhji(F(ypd@F8sYeuQSk-kRDV1VdJOWmIgk#kPKkP!ywCOFbtbzdF<3 zcdyn2mrp^|6B{Wf=p;1%n-_o|tfzpSjro#=*i&XUKOQ2o3CsNWtiRG4v_M~JxFUR0 zMR6_Z_CGJv{#%;y=P5WQq<35qBDHO+25i=*i(^*)Tw(7;;#oxwn9uc#G}?3tSZ2G8 zK-b2{moC!W{`;IA+f8496T65w9d6~n(Bnmk$t%CpQZyNl6tv~cv1Q`Hl7*99{Dsw<2O*${Sg8;7ygtzvV3DF)sMNksnC>R`k;zjVBv&Jc6@yN; z1lM{BISrEbf5;Od6OUqm1t*AKgT7Z`MGn zst3l)rw@Bll*d7V$Tb(VQd*QX#m5%nj7PDUV%y6##mG}Kpm-Z>nV(kCqqp(Sm|1}{ z*;q4;@t^-Nnd`CT<)+rnB=0|+Bq~U|6Im@PH!xU5;U90Li06s?70VGzsd{HV0y1{j z_*8J4q8Z)^qlY$I#QS$Ti|lvsF&b#b2G{}AZb_ti%y6(^#z|y5i~_=6P9};O(r)VG zFB$}h5?Dt*x%q~V-T#uoA)Xlv0C_KisI%*CX5Q>nIX;d zR_MsmS$r)E1z&``i6lq=Wj-`07#Ksp=8n@8HzS~=8G&!E#N&d1fDcLy+r%dG?V|DR z?!vqS@#ZU%m7NiQ$qcHz0xAHqg-c`!#{3Kew1CdXkK*eweBg)+}+ zoO(%DdzbgZ0p=PHs@ou50~Gr2DdvB1?goU z_-1vZmxT|{D>e@>q~bCTuhRJTUGfiAQ*4)kcshrJz?PDu{mcjEF?2qTWc6LjF|8!h zkAy2&%g`u5?6I+%HW7C__{hc`h*D%JOID`dSa+4ygw_pjhbzU4ev2XBrEL64464T_vLl5*(CUx9^{_-9;&Ug zcSm1x$8k~|+1x-tFgD6JbL{K1?c(?-CNJWy$B-LdI!VV(`r$cFcDb0w;H-aH$_Lmw zN9QBy$3?^T0$bR)%7o|?F=j(%{j{)`)p3OVXUNR@=(nm;DCsV2AD-hkze>3~rH#Kt6HE*Yd5E=Dv{q&i9XaPB`G+6kPTa zoUV%4YV1&4ELQ*heCC_c2@5d4R0m9{VYj0{!c+2@yx7Ct!4TPx(UOH}L;FNcn36dq zlb)Ndx0DeRo~jrRD0%E0w)U1lv}O~!0rB=ZI32;YEYJTGgbFgdD=uYB;`xJ#j*~I6 zSP>GjTZl1JtF8*L1qn*tjt{cB!kfA5G#OUtL3LYm17+GYgz`cZ(&tHJp0~vS;E0l- z4^i2VG#(w$@}ELYMQ8-9Z*fYS#{>SjmF4j^j@~=(#@TTPn*%*M-e;tju&P3S36B;! zYW(XLwf|}Zt+x*RG~5If@_qvYuBo?;FGZAMo+V5Zc3#?yh?D^Vnw8s))Y>K^#Je=& zZmsVNMtR*$Xz9i{+S6I|{UZV3q2l){j_%d>kW1#<1CR+Ry5^J()6kfN1t&5gg|1}#R4Po5ct$aW2c zITnTU&;yOPlb(_>-+K_=KBod4YJ)9gHC{s22L^BdDYr&$fBxQH6I3xS177=I#)m`{ zj$IdNurWp+orh!w9~VVW^xFBUEh*5^p z#%n$)(})vg-$V3h9|wr&iOCJ^oD^hyKC6v;Z7F(C&d3|CbKjI>ySd5~C~=(YZIkqg zsapLO+EZDKMx<7Ir@ZD~wvq=814F_+^C_&QWiz zq4nm$b0LvlITFiL&$($*D*%;3-$4ne-ju3lEe0z^0xZHP-;@cU4bEzzj_7O^ zFfRc_9wbsP(~B%m4Q6Ndmwr|V(pYU9ACrk}h)AJVcef6j18|^vj9y#~KzW&jMV;bA zYlJHk@t_dzBe?EkizQ8bJsvGGV&G_sE}w1pOb@fDCC~t<5O{B%`pAvq^Ht`U3J;U@ zF7tfBDhE_kD%P(_+~LA_+{L(cyPi=Zs)o`1#@u;(J-_*)R{vzw*#=JF1LY&5Vm$mO=1Yz(JVJAciy5Tc}ujorzy ztT~Fh6*thgW#{*5;{X1)q9gZvaox@?nm*@$Ou62kc$8F}a;fjXAV$6v&Ao*zGD)Qm z+v~Lr!T5d%h(OUryYr6sL{#yFdQX-(9LOL$1b3@jPY+(W-n}KyYEHj4&mSPaTif*V zEsjP$8~OqCb+gKOn+%n+({IUTW8u8w0efd5%YTuQ;~k6yF`RwV5{!1b1l^USijzY_ zNuVR&fpxYs^>sCWhr3v~(qYt>kAo0{%}H3(e*p%>n8lf_aDmO&Op#d9(wctiWa{?LgoO1&n{aB)s+8z7^1I3W7+0YChM zDbtz`AFezdnHpU|q~PRA?&LeOZJx27%N|h~$JlL%j;;4{4H%p8Oy!zxz{XISR)MEj zh&Fi74_zK=XS^?m`noX777O^B9N{tV&(5Y$T+h48=Os2y^NaV37P`v_cUWQP2H?>0sPjJL+4MDZ*(t-gGGTCRr^re4&A%E!ir2dc`^)* zFnht3_b}P-N`!k?j&*mA&?6GO31$JWgC14%gFe5zK6Pds=G8eVba$mwYs!DQf$&t~fp+-*b)4^Qaop%2Kxb{MCEnU5%I z_rhgYc8BSa&4fT-BtR(VM5NVL#M3Uqdq?{5EOR4Cc?{=pl=|=I5Bh(LS&a?mEZt3X zD(4!utzDe2Mk;@P`mm)6!`NLW^sUFM_|Pa}sTYhov7xd2hyGuvui!iN17@7Hav*Z<9(Rprp#ATpQ0w*&a@SC>t`taPvnV_o?Hu>^jDKQ5<6j8GDvg%ft5PAK!0TT6cwF!qhkX^V}Ch%2>YfPT(Un4B(gR#cQu{we;%jm9XNcxHlAq59J-qM)b3VW+!^6aK^@ul zt|gKRPWVU$zoz;p?azJ!yUMMpo#uU<#+{7@r@jl7xAd>pi&r^g6Pi?RIqrd4kKD~* z1r&K`F(CQOuJpyI<}e4p|9~{0dfV+bawq6SSpdbjd;{z*<-E-`i4m_L+pOGZxHVaU zMDOMb0}H|eiwE#NBC+X@)Lf+^St=GB6t@$V(TB!+esnqe#TStOIgD9LJh9Paq$^i! zh+HY&DOp(b?U++sGH|bOnVemWfkdlj9t28RJbL2 z()0OZI6nV5tsc#d6xr7TNbA~bXgV`ZOHj3Av@*#qVNLaPBf*OVRTsXRk(>7dObQ^} z-)g*Pl&ay{HQ)V{ZFSy8vYTm`AtS{3I2M^$9)8azRcRWqBzAorkKI!=*0fLp%WrKI=MD zbEe6oVE0K^`o-wsX7lP%ld6~!*CR8MEWgD6rBJ(mz(bHtLxF)^V-Pj_*{Sm6eQD#o zo%gGJf3(u#2ss>U>(LbEg!~$G9_xIBF=Rm=9}c2XVN@`R0&51ho+KfYx2^9hVjdBvW_rzgnUB#bN0(vCG1KN`>hl`(YG^ z#W13x6LGWIBal1p%NWxJ&tBlD`=^t_U7lbtGwGnD(_evDEp%S2RW>sTL zI&8@2#gwVp(-Yyd#TNh4AXyP$Jj-B3#Yggtp~KB$56Gan&`fLpeM0}SAq_q5R|T}< z(q=1%my-LBD29FfVge#%ZjDBC*m>}luq0Ax9K|zzSwL=&Zrm>`(_@@-(VkX45Y2+3 zAX03*W_cJ*d&k%FY`4k7!4>v6OT)^*7qZ>bjU3)m3d5=StX>^ zpC(Cl2@NE~jkvy=kc4&qE9n+SshDlD-q#`h9@WX6@XMg|%5?a7S_%`743)w8w;EdU z2FfQ$u#5I`1MOpZbQhZo`^SdFjWu`E<2ZsZupaAPT}skC*mr!ZuUJUo*Zu?yjGY^| zvG@WFdpU*qz@J~8ihsz>G0K=`1-|9+_I+2`9wg#T1B?%7FnTRm9r#z_U;f7_-~Z!B zFNQqMfDDgV{%acBXBy!=w+EbdS#pEN$M&pvpHkl(ul4jurvGL=c#fQ)es6i0$DgFH9ABmLchTYlA8St;3 z86hEh`;eS*@Vkw2un>Q8V!wbL<1{lTyUfR^xNOXF`K9wzWd(W2JJv>JlNxq}tw_ex z!l~A1C05^6rzC`8!YPWVL}mgS`nK0`<;GZ#A5cqLP$F>a$n9c_L_!OOne;$83e>%! z(94~y8EZJeW_8b2bw3Fwv;BB51Tm^-wzF5-OSW_rpPsv86lPcvc~gth(6siZDl}Tu z_huP-rr?HlLKj8zGl85+I)3?E&Dh9MIAP@SZXO(usO&l(>0&NIl$=gNkUK(0f}e)x zT-g|CT+8h>DHA!wm&0fKH!9H`jIM>1pg*Q4G#QKE%I6j%DrFZ!Lc46y7TteV<%~l` z1u>Gw?J=_rA>_0gJ(U&dnfcsEk8H<|H8Ze84MKq5Q->;xA&ZJ~iu?>IOK@uSZP*DB zj0ppc|8wpOoPms|bk9=gz8?hS67oufCtKHevFpm0Jg$q6BSFVhODT+1_4|1a7ZMUY zoI_W~M8EQfGWPV_7J0&jNDusNQ)H|*?vDK<_W0tAVim- z<-zsKBgscc4+GDT066dC6!&ID_eAQ15~=`^uJmBKVWP zMn0Bf1de^{g(rMsR7Cxls46*LK}LKw#z&nZS}o;JZKY+Tk>$oZQhTmL)L1KgiZ!xq z9vbwczIlYs9EVVP_6@!K`x%n2UYD<_VLf7`b1e59QWb$3xbxKaBu8U3 z-u9mw0SPL!)`Gt^A-Lb;eH5BblLLjy=Eul}>*1&doXcNvzxpj-ge#}eN@$o?DjDq^5wW+zHMZ%6?Lvu*Ych^&P)xQ6;%3*N#f9-Ea<(O5^PGmGA8U z33%7kaI$6XoctNs|H-%XnVq&{949NT-eUa{LkW&j?k?VyM(9qAW@(~Tlh*H?f9{VZ zK(%PDb?jkf^v`7cZ?xVpwiz#0{-;~M*9>#1Ml}G$D@1DY3td`rF=5u!h61GBJ=6Zi z9NU$ZPRU^6UfazCyG8uZyRnEB9oe1194Xhej+V3(vAMiAX!OwR{Ti(!(}bN%KnHv#KccYhaxE`;Y~5;RFiZWYCMSyk z7mMNwT8g3du4D8uZ*ibn0}8JcEsn_rcG>fmbD|toiVKW7CnKvl1ys7G{Wp8iPGD;- z2EsQ+9!8N=7V09S+GVA?k<9j`AUaOW&!#XhjyJ?YNv9e^jckI|LN%e}2a$H$EVi+{ z_%qW4G!Jo;6AaybK!@(=X(JR zR`EmF!^W{CbaUPwbsSb-+Q=lJ?mS^G^GIGCe;f&=o}y93D+rC$O62W~E#l^rBuQ@@ znI_bloXIwR=nAnPMSlK$?t~y8drAPz5xT1VdIgu>84CF!?j#x+j7bwMKZZfG2wI?N zDS#>T!T-5-mOOu(-PlkbhxU=wk)=n2v!eLv{RgYuou3zL>;N9kQ|+JY5fKpe8A<$FAJ_eyzQIYA9+%Mpu6xzF7k?urv-PVPfAJ6zyts|AFT&z~>$A_Dt|Qo&}MSG+Z~Xl!gh> zf-2$9uZf}#C9HQ1P-dg+=nQz1$%d52w6{!$B0=F#ik~2##GFq6Z?}9lY%#Hg{x#8` zDTq&PlQ5=mUkY{49KF9ISMXw%rP&%^wudjVn zhsgSu3!jFbF`wKqrOVIg7nwfmY5^GGa9+{(OUe)X63Y83{0S6@*&j**-NMA=r?t-= zk6s)2xxZFhkvkEWnB_aTGfBI>A9sI3ran_0jj@k?yv>O{UE7B^(q=!rCL|gf@vm80 z8T^eJvZz#S?>Q`$b9kZ%kx7s$u!tDG;fZXO6){LzgFR%7er^9ex1X4hB5Cg|!&G4j z@5M}VD$@S47#C5YZcilEkHjrS|HH1zq}cTc{om?{_wh7nn6n9|D(LC(bst!6Vg03+ z`=NFtlFDVBfRv0(GSyU@{C247ap-OH!%2&Ji|~Ji(BbzGNxz%khpl@vzxh*&6Nbb8 zv2TCv*s7a5fc*)tw#EG8f>)Ob{ira5q+G7A3}*>21Q^vRsK4Yn1i2-$!N>KcxF#u1 zxdX1%2+F*n;v`M28}`wU00dPhkDxkI6}o1h%~&>~@+7BYFA=LNc>`e*j^L_4+Tg2% zNJ#UoGvRS9f3kg2Zc?)@qYOtuj}vn|!TWy7M!NyES1sTo0JFqN9K0_%>Q9>OlV>W>;I0?xKw-cmf0ase%e5l(o4BbDRfc?9|}t64nZ3DxxU%Q1P70^kab8NjTP>q#dlSAIv?f7#*Llq^ho3fvZ$2O`6%U0$#be@4H8mn z-vaqyw;fWDnz~th9B-Nyy}T=pJ>}PN27KHL2cjAVx_*mQdFe3>atU0wT()&E>NjP! zfNP)Bf4w|{ze4LEdN1oCKR1jexSr38cQ52HY6=0?gUj!On~RPLu>Eglxx9d}y6hSY z_FbG4)|{N#-0#;C}ijdyOdKuK$(u|B=Nf;13f5` zMatYG2va*G+P9(R09!G&3i1c;fp_MWElS7d{td}fQOA{vdkC9iS9xGaq(GTj_q}qo z-S=3eg4nlqyNrfkZCE^Ueu>IjT=iA$Nci*xjbuaJQQ3`&f;nkD@n+xMmA)bKrk31p zx$(yD*RFzkzbBi<9A`}tL+-W_C?Xco8uV}w*-*ZbdvAp2v@2@g4}GVWL>0NsjTZvP|JIPc=RzR)JHQMdTDm2TaWYp!woQ!Bk{uwpNsPXpzM&)nchCyZ1sYuA5{2)q^Abv)Tg&RdBZfaO%?!y86=hb7q z)7sUvl@h3D*rmPHXeQS{rx(nFMR@YucD<8kLBF$p-mi_X-4{UsnfCXg)Lu)B-;Gj! zugl4XSUfpEUi*0|##>a_CcRkcPdfhCL-;xYeEm)i{-F4x`BqsL%7rbG{#oH}*$hxCdj)D%38loKUrZG9!jQ^&ZRzOTmK!T|7|0%N*6VET z$)w5PE$p5F@{;T}RuT+bKFiLI79;YtB!tCb9)LA%6X&jWP6O<>&>0kbiZ*rseacuY z3#zEF66aDTg@zxpXQ84f=&F>}Ik7&LXf$2mQoCjMo}sf|kV&s7^!I?%s0MjGPp5KXXOnq{Sb{LXMWj>sT+~ zi@VTODN7I4BgBt3-_#U>^Vnzh*mpLl9hgiN@bm?hd-)Q^e~M#_hinjj0AA|=l>F8F zPb^8sIKp$j1CW6x+u!15a_>^Ivf`>yez>`S+Skp(=&sjP_3>pR!Q$oF#2@G7{=ScV zUU7Bbo7Er6L{_M0m*EvMf80m|G~iGFdhE;bdUZ@Gw-0;Lcn~jBbHZt{n#Oruc7k*> zydb2~0h@}UpA-9HuFJ92L=Pn7xIGn+T+YJ6(R&AzG;Tc;5Y7kvd(MMB%2PNaY0$CA zb9oXsPjevQW*YsV*Tb?;1^zCnx9qLm&pgMbe$`C3+ntcQ=gucx)HevbMV06`A%)g0 zU)mpqE})U?n!@X8;h?Cih0@4Am`)ofR}rff1s<|8+x`dUMD;wb1!4wqOs=#U{dl^i zXS-73b8>dPzhd+UcKmc!&RPjNj%B)Piw6l{kJL+Zqd&;Jw61Y1fc1Uq7P!{B5#D{1 z1=if#1`G#}6Q1mu1(aIM85Qep(Pnp%#;C%j@+^I5Xy#JIsLjRXVBKUrn$AM6LU7!A z4ud6?-qP=DzaaTmJWD1a3vOc{cA+=l@5r5Z=EQV^MMP1Da#R$r0+a=4W2tKhyNsHv z-z-Z^^vXokGLfdI|8d;IZ zce>$jdzPqQNF4zZX_pHtt*z(tU3MSjOI7@fY^|<6S@3$*xjqeb zc89L6eEAu;(*B=Xc~3NU{I+LRz2RnhwbSaZQ7*l~Cbm@&j+;KTyu(?r6x5yuFD3c4Twj(~%Qk!_(6Ht| zGn~SVHo%_)e^sWaYSgEGEt2>QWMaJ^^Qyt^tAxoI7@CrM_1+nP``xQ5O(&Y8OIeL| zWxpoj0D0%*lDpDM309yCzpteZK#aTg<(G=IZgWlUOQwb>F*%cK&IS5v8jrVchmJ5z7Fh9e zLrT72Pc}Nw4>j8LEc4nZUwJx-arZikd3nGov=ci^HZ1+25v=rlNcHL8g2n|ONH{4s z=Dz+d##J2|y}~lO>wAQH<82aF*UNdd;DG58kt!V=kMxz`a}%OE=gWX<-S}9qv+Ne4 zV_0}a-+~C{DO;k7O@3@?A zScjUQvN;A@?q6OclL)CRwJ|Nd#OcmTiCYOgemooKHTjColhyM_fyVk1%S)B{zSz7w2QnSHu;8f(<&? zB|Y)$TxCU5jBY{pH^@{n_W^*5>A$ zT!Bt!(&xQI<4jkA{oN1K?T0Pxjy~7F&8SZ)#XJ4;{4WMKJn|ow&;F~~{{BeY9Pn_1 z>-u#%J5v0#mA~@u$~gDXLY-|?(9?Vn_4CpAv(rh-0v@f@EA|Y2W(5Wu0cojDT29+H za9w?nJs__gL?k+`znjn6!>aPrtKTj@{=Zc}*H-Vt-ZRIJ8^<0K?-fCRxNso+Y}@R4 zI}ZJBSNi2{<6q$EKOe=r)CnO{nqhFuKa)JpMvzW+)c4jcCgj!A_!q>$^$>KlIQ%y$5nrk-D-@6v_(xsg^(B zi0t@i=lXGv2pSpbyaG@&M}kzao;1LYp=hAZb&1apyrGZ`t`hG4o&AcYUXuygn{Pyi zJwWZ8c;((E?J&EfC61k@rCZ!g`#*6x74C@bTxiwlP@L=~!8`!lyCjwiFDDW2RWdt} zWL9X`-ofbf0^V*66DYzkDTz%ujN*g)0O>28wS<4XJzCqJP>BbY3XIe);o&+wE8i7R z(zWm)V5?q6en(WVC||_mxpH8$RF9+A&oRNEhgzZuLt41Krb07ZEX-7FEC=mjo!>i9&=Q>)U7T3l7A=bQJiam`-VEoNx^fC?3Ixz*d2l1 zZJZa|M$YoB=Iw$7Cl?GDf{0|(+zNAl^J&s0_cIc< zi{rXV_N*X%y-p~FU~Q>?wy5EzfX!8y?P2!UWLhwDtxKvYbo`X_g_pn!GGSxy-I~Xm z=M!9-3Xp7|TpNu?0U#=T&F;tG*$HNWYz;(GLOem73m4DE+LuRR~HNtszxvU5Sh zaj*6t%4&E6PTT(Btv!yeNj#pMKA)U{Uv9x3mtU8bR)t!31gj>)T{-mi^&?#$8eOT{ zuZ=ps+^(Zbi#+ZIJ=+#jM}BWRvR*ETB5-!PXabMF+6xcQ`;X4!bc%cK5dfcV!7q@H zf4+bJ9<}7!5pesr>Ol;8e`G5yKGjrXdO!6&LFgu*vk|%N;A0hK;3i9;Wn|Q+_KP%R zF08w!wU?*Dcy zARVq1P!Fatq@wYA1PG^ao95K<3=5hJG(e2T&t@7;F48_WM59`8C@B|mY{X@()S7dg zTAFM%R%JP4o_%yHC!PKhIri$4jT-C-92{v|u2(fXUM5b5VB}$+EyroKk(YI@qn=*0 zZFEjm6HxiHXjkInOS*p*ad%2PJl=_vXcuwq7cmXhhIDhUJJ^r_ zM9!>?V+lFb7JM2Wu@1r^Ncfov$|#vkNz(3$MWZTi{RhYAkf)XNaI9s}^WB4XqHR$h z&+FG%I|IWt;#8d$lI4Cuz)P&$jlG*`rh@Ms{p6Tl^3=0G5wdlx*Kn-vp!BreHP{RPb!GyNECErz>Z(JR9DxSYD6jKP-c3g_{6 z0W3S>NxOHyCjIFgU=-@jR($m6;k7PZIe#1rCQ{r)So@uGQtW7@sXKa<$K$z#DEx@$ z?Z`io!fC3JdB(upp|U3sva?VoU-Ya|};q(|Eo=U`1gCx&uL3_La8p1&3$m zCN1koPGi>~ZXgkNW3pc$N~EcMp<}*w-Mj>3AMwJrSt$Ee9aEHCgziT_r8wTZwt-X4 zr98z|g{g#|@}^pHaNpss@U0cDMEc9j!u7^h=5=$yF}ahu7H-9<+WsVtP@+?;M|T;X zMxDr(h~T6=W*a@g&c(Sv$xv%c0w;J5By4)OqQDNnTkB@y-C%Dc*M`9#*F&j{QJRDB zO=T};+_tuwCtIC?@k^qSn9X@4Og8jZUL%xgI_lZD+dHTDyM$A_n7SvZUa22s?SCYG zqn+t=CqzFftb88RpWE*l%9=YpXawE8c3rI-&;Dz=;88b!(>-f@dwZX8zg7OlnSSvZ zx2CoZg%+=4%GLY1COimsz~|LlsIWVK+mrjBsw({c1nSs#fczp+&r|^c5yN__+370ft@d3(VeUKHxx3a2in#XQ@4x75{z z|GbV5ZK)v5S9Mq^eJvItNN9^|#qZn>Oa43kMoXI@|y_5L*GEp)7IvYKvzUiZW z=ufD?HS;+eaT7S1>u6QJc4=@EpKFl35O|}Y`~q^uJ#^G@bSCEvqO7H2t9Q1aD>gcx za}G7vLz=v@-AX*1HD%-m+J!dl(mF%z7|CNv7c44Sam7eCEC{8B)y1QFqW<9s(;8Eb zRk-w)1m=L@j$N@wxoCd$b^gYVdS*C7z;q(Sl15df=7i0-`si<+a%5_P>k!@>Rhi-6 zKR-8yHTUy0G=B&RtW0*O(0AS@@UA*p69lw7}wzYzBO(uMiuW(TEBX(xJorSzT^28 zx`Z_l^r_Xsb~k~N%qAt2H}#Ek4pnb3>{nw0(1%a&ZD)kK(C{>? zSE5CBm==bOE0 zWaL_on-p}W9=;@@u5VI4k#*lz53QoI27^3^{iE^Yy zWY_MUFY9|%&w0;fd4zt~bpA<##OEysxm&B9Z>!%vI^Rz^!#19NZZK3c1c+UtkqQr0 zkQ+SwSwtawJCuv|T@`1wv9ak57rmu$S~~^;mfpc5NW|m`DXIk0=i|X&tn=1mrmM+k zv-Vkb|X%`XZn- z{Xe^N4k7~kJx<@xnkxI``A0#1FSnONR2C+_)~W8B1Dq<@%u~i7%})&jOqsZmd|F)% zk!vFu?DzR!wtFCudUY))b-scYLL^@-M>YOlqad`N4&(8o02hlXY*wK=VuJB1^5Ssb zVR@Y)f!72KM7m}`kk*)EXr3Mon#(I5neFk0N7ON58t$);Vrr7FfkC0@*lk1lqz|Qg0i$yg-vEvgOLB#uc-W^t5kkQ;nP~2 z$*y}QNshli9guael}jZeIB|$%zckSyWekhR>2v2@9SU*YKGmars~OtH)B9nvMVJZQ z;v@VmL(YEf6)|>0#}2;Sx|+=`vc(K`_OfOQFgGfOsFrs$N_KK(Jk56iaq^8<$uz>Q zt1EoX16MjPt*oip&q({WEYWr6_3;f=)fStz|ALeBs{KzR2N~um2b*~THA?pI=U{y0 zET!2E41&W_&$+K_3dHVONOMI@<><-PloC~nK02$ma3zfgUKBrn(0%MmMncYBY<@Axp04F0Q*|CKn!nVG}&S?>j6p7mQS;A-;6t~23eX1I5$e#}*p-5D|DwEBwr<{OZ zv_<5JV|k^e*O2mA-vDhS9isRNbABwZbzh+P&Mm5ToAdLoD?1##OoCr<10RpzUJ}kV zKpcwIp;C>++sa}`8i3e=qKi)SD$+x4Idkj%<$}y_Ps7iwkLy+JG7t^>h3##IjpfV= z%Ahgt<0-Fm7byDb@Tpr)^g8kR7wYmqms8}t_uCSrQ*}_(bj|=*_xr@f>%?_^;LX7# zTIcPzPBq>;49M5E6TJ&Y_lrq{Rhj=`topHkg+^~dTL|Juz}>jFx1_;zxX%&D1aSQ^;0KC?h(L?3SINSgHZx(p?8aw=inc-68?PZ}Pb;J5oy)&lX<)3t^=|vp(Ds;Y z-Bi^;fYB8=+|>}uI*~dMAF)%)o##xS_J#V@0`DbM=2aRf-Mydx6`I(0KfC^50yp8) z!qkneVMWZ?phvU>77FgFF_%9PTh3CGy?dKAB`FP=`-#Ej2`%CCl&u&5xn3)(y+|tR z{#NRQ6ZVePQSDFv&`w@I>lbVt9zt-rPt`cyR|0z2l}b|5;|-VI8Hd?G89RQmVY$9p z=MGN-wn+*@NV!8*I7SQ98XCV`giY*8r5e4+%t!CuZ+Oy|#zE7&flXpg^VKo&WwJkl zMid0fV&|erqcVL?h2vl@2xb$ERQ)rFUhoDk#KWaj2Re9)vV8l#aZnWz`GNzI+jyDk zEs%;INj_;p&U^;+5fv#3I9q~rutz=Z*1y<)=#NEz(?T-ME2Xd#qmfw}y9)Hs<)$U( z<9ZOtOXsT@IIxIe?Qpma@HL8jQGKy8c>3{|YLs_cNyD}*U$&~^j>%x2W^+wP4; z!o?-Kg=BD(*0WHR8Oom|=+*Nr2EswbBOX~WioY{Yklsw^lO#(*haPHeEB%uND}X)U z0vXd&@qp0fQalGl*KWSWfS_kQ%_gzM(G;<%M&NEcPP{K@ptlumZL%>ZnvL>);;Krv zR-b*_34T)Hh=tu$k398?y-KMH)D!J5)x7(ETzp)yzJpiY2F$#GBHx^M^=j7Ui;fqM z0`ZepE$_P4cgIiRqXkmoYi)(knTGyni++H}Po!^;Z2&@IHEiOTq`q23vFPyoL~CLrw00(Sr=J)@?4jz6bS3;D!$q9)R(1-?oD1Gx}SNW)RVO$8%r-Pb2_-KmAyd zGFnWsb^Y;|x74ZWbo+gcklYyof|a+IePtOGSEJ3d0_enVlr&oGM(O>k>*911JL;qM zx}%s&GSN3ny=}fR%7lezz32!Dgg>C6Cm8rzS8q1&ki7jxA<24YqWmec*GV+&^|U`> zdb&*6uC}I$g~Yyb-_yn^9OkcQyiggQo9zMY>C+9~5xfp|Q4@>R@5;`MB4o^b6uy}z zsnUSya`o#<-w8pe#_dd97)@uqay|NZg^Ij;Sq1?*etc+HgsE-uGi)Qy+LEsDvh>}w zMx)a}!liuNJY`}+4lff{Y?Du`c>|U=<^y2GOOMZPjA0j5`bPt}eduW^V_<{N!@x&p zQ?kjLRRUgz|06E`fF_5q0=E~i2$&jQscDzCDvV{WXnW@*tU4ydLo`$Flqe|4`9O3? z<^;oEigWyRUVfU9(GGS?MI~N*5j*DF9*+}<#{L7S8AKBv<;n&(tN`{WC&=rIZc8px z;vYm*r-P78wAWE`P&*be@;On>an6uB;C0OoX1bp4x@nagxBcbL)$60}kG2APBHOQG z*{$!fH~nf&L}y4yNWB#XZ^tc14e{|}K985WL`WLy>djC7gCy?Ak8Q7eY)y>s-Stnm z-j7@1(d)km(L^6Nb(7&pJ@SX%yaFkk$G~IYZ>PV%#K%9M`b;kG`*Zb&*W=%dx4&i0 z5C5O<9tKJ-{+}h6n;pOFaob|(H$B`l=d%nW^Mz2**EPa1MRU_Yo*nLBpO~rp8hQW{ zf4%;p?8dQ6GJN(@ojb@=Se!7Owsi)(rj_etSv@9@ww)al*~Z8N8U05>|GH3E>v;sq zLGzR`0LA^-M%AD8oH~fYlukH)_6nHI<at5QY4OpeA7F55AW!LYEM=6obwl#|816 zvu1)h;fRj$pk1tRhdOW6iD%D2DOKS<uv&Rafb4+MsfaL5-aMvZG<4HUDGol4)QUf|Om! zbP0TG;b6~c^8t2`QlR%})my;VGWZ-7VZh!O<-mC!HI1$$sacVuXeU`j>}enaQ3h-P zg(BAg63!Ov?$KZKb&^(Ag zIy^#NdnQO=zUo^6m<@}hJGZqELW2+50wQQ^wYj2|b0Be{SsI4CHwxX1e~3D8Q^_BzuLDT{Zi#$BUMhS$=qAtORz|cKRa)-g z-Z-1InHw_v?8l|P0do=FgLW%1```^hi+OzrhjzfJ3D?FR$%RLFw$1|Iy31_Ldqu}_ zMcBn-+y(ympOY#Uz6%RtqQK7Iv6gnuOsz>?3wm(nFAt62sju% zI9#m1SYC&QE1(S;2e0&dwqC4^A&t%m+(rDZkq!Dj0`Kh8h`mfEyUdz_IiB69BdL(e z6`Tffu(2U9{uAI>@{VbM<|xWrN9wz6Yb$u7;1mZEA($SBkegbT*rSL!w7w~Xgv_T- zB3Z5;z+OD3Wf{}y!@*UIbc}{eP%?CmOk$WuY@UYD7zhtplUNXESX3{}Ng-P4=?04G z4}x&rf9y;grwxJJq4X&kqMV`~vro}4bAU(=BN4N}tM5z)7T+Sjm|!XTjo9WGoW)`w z3KJ&r3IM;3`-Fzob-9G$WF^AJU6CXAfm2MlwE*r5COITsp6`V}(;ykxK z$XjX@530@ur|$H)ox2jSrWTng=0V0 zW%2m2aBT^0d1(o395XQBy>IW1C(M30Z=wUPKMsa{+^HX`JL?KE}1ok_jW zKdVQw?U$*Rgx$y8tNXXtkMF2wKa*DY{a!n9{Wl-Le9*n~@8o{ABD&^!@ONv9g=x*fbF*6&itmnX04TbC zrqnCJIU)=V*w~>d8DgT?&r{sfoa${hmf4@~e(DN0f#V|vTq#i@$5QK@(98=ooL*al zgEIe+L=!mq?3KuKRg0XjwE#ShL|(%G!LQ`V#IW+}ykdj{W0a`0jK4orz-U);GvhQT z2G@ZG_ii_DxcA96kMhV_@MSo~>l&x*Zbl*yl-15BzYOId{J8MxyxZS1a<>%}dpxuf z4eHFvwoOr^^ANsBZp$P=d#~bC=u>s7o%Jb_fuE^#Y*wqA_^=l6zwE1$&AKql-{krrKdB+XR1g4!^ri)GaA z2c>yA#K4jB_#gZB@B3UG$(_(Ily-(vWYq+ky1Hwe-FD%c4(AF+)#+&5T{|GNQ8Yfz3K( z@!RRq*AU3Y^PUCx3vp9LQl6)nQAD>hGhuGjW4Z;9V*(Qnkj$`Ava}IMin(#)_|ZJL zrW}a6(pQ*kp&&~k>gNe6ls79|6&SgJt+P;yAcTm) z&3~>mWSb_|pqf&%zB5Xr6;2nJ;uV9K?lqO}90+uZDY4z$qg0CHx~g=nY{O&>twjsB ze0*3~+6R}UCrmxccc+`$<1fZHluF8XT{(X5bvDdI7x)Qk0bo!;Pm*(h=(<1skJ^jepk$CTQ_5cRQyJ`f`3vt zc12mfou7N3=I>|*zeSh7192pkE=r{O3Y|M?(Qi`*I}DCz+C*J(^{b3vY3I01-)AmV zrz{iC1{t>JSYNDjl=u7T7q&DMU*`b1UN$y1=Q;wSDixJ22Qa`SHe&p$|8fQ>7F&v zlfEgF$M{pzqt1!rpwt7x<=?!|SNMpdyL9WfgF+Scz$~)=y-r|vtaRR5ePw>w~~t5OIThWduF4WK&}3}hK03Bs?+X5X&*Kl6?126_sv{Ce-xvIx z(OX%h4&2NDi$6bB9F#H7+Q`xSaHz*z+*q=H*o=vi#Awt+@(T_oKtxZcLdM1zJf*03 z6pW*Md-=+mKmAHoTM55TO#CYfcPA3w+t?YMsA_2m?jS(icq(iKA*b}+LcZgOa3R`Xio}f_QD0J_=>*v68rI$4>YC978BGAV(LNQWRYo<&H&y- z1TA#EjpJ%>r8@7Rc~Rx*foNMsDo%q%SD~^=-(ZYFvq|3oJ@)&%w)TE9%r84kLoQ|K zfZS?gouRex*4omTpQA`}>>F-idCP+E5VMNACmRdFVo_6Nge8v3-xtlSVE4B=X+dDV zi>cPxYR76~E8m?6$BKWeDTGsca}E>0*r?YTCt+gHxz&l)Vp2ld-qVE_&`z5D47LHI zgrg1gn|P;%C0i^!xyRaO1liX2fLPER`xx;W* zk>N>M8;YwEKV3+K=R4^V_w$i=Ooy!o@@@$Nu1{ zye&`qH5R`>iCsed5&^6^Pm1*F{Jl=w`iBMk?0Ps=F!NC)_FOb$M^x=OZe#AZr1N$+ z_1@(F(!?1<_FO>5cxJWp(sA(uEd2YQX?c%E|dHq7qo5x`J)0-S%S+yt+X zGGVzwU~i69Va`7w@|q(*Y3Jnl{?eTP=I-V0w6hPl(+;l{?~{12ozKe9JS2K z1h(y=NwTyE*>ox%uRXBnXCDDn7-t)W&E6~oDtFn*Qx1KCB8if6oFUa|DO%p6rKR<{ z&+N9t!I^i5rOE>vjR3(^$AR;W|c=#qBNuCy7dGgg`#N%5Ibn6I0 z`pf;gIVVf3(X~9HSIm)qJ)tlidwqiYvvm<-ArLRISPQQ6+;B{oaG0X56X97cL*Lb* zkSrF_luqz37O9X>X)4=j&JY(0myLtz1N&X!vykN1W6%>!hV}u2L-8#Oe&$z%oK8h- zXYuJE`#Z9x@J~c9U>&rj%-^n3lyKcdJ>+P5rH}Q>s6;bPb3&?|UENzzc^xk2)zr#w zkdBU$T1m*-q*UQU7CzG1rcb`kIu+`fML{M%*XmJ+!j`fx&{cx;Ax3M?mLAomwn}%t z^cr2xUZ)+fQzC(B+pu*dxN35>&5g_1yZ9IWmP z^V8qGF7WbnWk%Zb)1QiMlxn8H77-Y}Gn8rLwV`?uwx3D)MP^5a)n9TY4+%3q}<(;ZDO* zK4PBmP3#w1iOE*U11(KrZ~?I1se5cAvA%1mAK&@5w_c{>{aorJ<5Ax5o`ItK>;EVP z=|>Op8@KXpt8MVLpxRo_wwLt_QirCe*1vaETZ4Y5gHgj$Xd-GWv-1A*muew=oloQa z`(6Mau&r$81ev`f7!C~;oWlJQ4#v4S*TD1%awoWVQ8@qn*Z*fYnCDk@If?MLnt?L5 z%29TIz+)fUl%Cpa&^^RAqn4 zidtnsMRY^WqHJh;e}}NG%w}y-d$n4MgzHJ^kvD~QGV*3ZBeK&Mx+n{sMB0N-G~y0+ zsh_;Qp4j^9eLtwZGqF`5r+^kO;ul5-jlc33my>v(vsyqP7HAlRhUhz=LyRDpI9xE^ z0+=M6Of>Wv<8aLsnshY(bpeI_YL7ZZoCwFQ-0-xH9lt5&v2$JX$IFNxGk((`{=umU zgEtVLg7c0#c9s$vA5(M(^4sLJtPR})2cOiuJ{&i!^((w+cERA_24WUh?{^u%B;`uT zfGCUL%4?s56UnFIQUamAo(eCWt_6VZY4Bj7iL*Zs@7+~PTasfGs18knI<7SCx4>kG z6RD1B&H*pCUq6Mu;|=~!+wN1@bXJ$M5)!n_ZL0EY46zQAEKu2os`@rH?R7O7IUlQx zQ-_!n;JLMgs0~%r1q4)-2y1&vGYK@EMOAY_3d;c6X@XgbkMdxh2X&1Gr682BbFe0j zzBpj>=E8#h_yyu6Upc;B5k@rDPo?s=m>_>=Laih*_I#yb?fA{D;8K58U{YimI8$F4 zWwt9H#S*YVY;gIb2f}hYq{=x|`*q>90=eUvof?g&xD!xuLevXVt>``16ACBVuJYAo zFPNboB1T8j=Q(QpsSw*(m=6OV$8sZ1i;(U4HPD2K?I6a1hqpDIzh1Zjn8u}^u#3+V zyzsozXiy)&Jt!;8`v6*w5xP;f{{gO0u{V3@FZMps*$~_WUFxJN`ajS1f;BgUtj((z z|K}2aL7&M7AGFRGvA0<#+4mWT{$S}`W%J`G z@4ZYH`I1WPnyPmZ?9W64)5Im)WLJvH@8ufpQ5U&vsEbxRLy1(qaH5x=^dAGr8dmLE z0grbEA9o%av!3tGjjw@0*Xcd3BXVTw|LG~t1`9h<$ewJ48*d_?G1{ZuK8_m0JalR_ z7v;o5SXj$}N5Tx-YPfDO$A+#Sk;dY*W9;E7AHj&v<`>>hVM{=Mvyf>!plet(J@f5Q zpr$n6<|Y)w)=CzSVb(V^;C;wS7UcK&EGOg&1!=AgnE zRfKZ{{0bt{`7P%avm5hx{X>l8DA|*+;wp3nWBgl|mCw0l{<12i{%h0l-ogYMLrXEy zwatxgga%35?zwN<>T4v`fw9R4@&zv+S)*Hy6~xy3j+)qcILRQXO7r1&dg_Za|lyapGcqf5s);hy1|~cE@d|#XTpi*S`1UNRPv){3w)%&*k)N zZ)Ea`YY}}RJ;zve#4r*S_VPRMlkW;dj%lxIkDDENsFyE{1Xman&1t>SWD?3?UGu^e z_6Z|tLw@xT@t2(ej?m#YRk>YudJ{(?9?H6fiwH`7RLoLEc*S<6kB@6?Lgs#%&Oe|C7sr8`F)uEol#9mw z^qi3pVEMC+0;mRyMYs3B*f-g+8<4EBL-FvPuQqz-_=eid%jGI((0$@8Dq)9#Tw0BO zI|?fP#Ms9`F;9nVqVgx%J?jPGeKj+xVlF?EccyIXl|=mTHF@j)l5}#tAvxRLiSv(v z6y9=|w8JrRN~w-yac9+1$+sjPQCmv5VeUb3_i^p%nG#8Pi7M?6nO2Z}7+XVkJc=*& z;=MP;9N2?=K{KnXtIJIE!Vd0({AY=K`H$}1(R}0gcl|->y_@W{+u`<5d1Wg={uS)9 zyx5n)eBAQrXVULhbxTAUHyCC$hn}`>X!Ox z8}5LL>}e=K?(SmksqljkSKq7XKTN5vdE>6=W*&XLzDlM;1}3E8Sl22^@7N|g!4P?P z3%s0O$N{rZ{GZ1trscN5e(+H;Ku96HW5DLh$nS5>hbH5wo(-YViv(Ci-0)vpBgQzo zGQ5VT7nT}P+}B&&o0)j;j6OC#yqV8~;^eYTpe7whAULCIlQTYL4)K#6q>)a?A_w#5 zK<*UAZWnxpi*)$4KO_m3lmpbFLD~cjW6P)mrOV`LJL>i0I+btGSteOt3!fv-Si+2& zAI{&d>>g8_SA9$#U*G7qX({!DyKl6Dy7Z~aLF~BX1yNK)zrf*|qx=wP{WugmO374k zyvbLzgO^5L>U3dpVK-gdjL*g*Bv~0@0StZFX@VYEo99RpWxD%>f4*g^cg6^(`(6qKj_y+;n;qsSXMo>Lr?lLfLz5$b@_5sqx zm=aC)8xuY$HoF6D*R7hcXrC%$PGDxRHMH4aQ-Z&H9~r zxH2+9c2bRB;Oy|e=Eu(fqgui)?*8trxAnf-3SIO#5R)MvM{ch8JUH(7OMi~xHxBwxO^NkB&4bc#zuRMa93E@lE)i)MZ= zKMZ%n_eT9{c2INBX(Tz)M%aSvS?mH^#1X;K74|AU-eP$-=26-F=kC!HTZ8W&?*~{o zY>Gd8(0gX;8EK&w%;deO zUmS`kW67PZaHBE4j&3`CSFI>%Sl{B}>(t!6->$#I5$cMKmxZb-FuoFYxUxdJM(SU8 zKaZZ&x5M>0zo84GYbisUG*9${CRdYa0DJ~Yn1)gfF3kNX5^i21bEuttsz*_x==OAj zzQ*uW4tpi;Qn*COJb;~~n*A}sdoV?qH=cEK%91=85`B6s%+Ri5rw@jkT+y7LUw!Gq z>n!-P>N&z&&0$(Gh0QGR3tq<)E;2%w2z2c&t$fVVM#wy z6E=!_s+w11UQKHK-Ea%a$G?32mo^XEUU(m638Vq${9L~7$aO1pyaU9>BN~-to;oH6 z!d;0+PmsoZjPn51WBN{rTqwCfJ&~5vJ&NC3$sm#U zg&zEfIx8!7-i8*$D1gB=jX|wht_DRo>2`ERp!a!;1wU9<4-k5BbbtQt?4~T_ES@s+L!mI3Zz0l2JD|djN56>CC6cO;X#IL zE;e};eE@ZRMgWe!LZia{xjakDHF0-_lvWZdnP4&AwS@i^T=c9iM7qRCK?p{A6%Knm zdaL30vnB1H0P_=2!KAYv(UK%XgM+^QJrm~8BB*->WX$w!Mp<+` z*$Y2^a8nG?(2|!L_$?5a>dKh8SdEIxmKbywyQ!4~bPAZYV4l}m<)0&9Gds0;7H;^K z7cJ^5*t!QhdEs?;xch!{<-zB+#{E!ap?7N<0q7{1(V9?@F!oQn1s zn@#%T)4TsTcAyFLQfX0nN=jb|M5QS9Ou9cZAR;i#4 z+CoB%x=Ey>c^}P6u;_W{u2NDe)rz1nfP7}s@i}p(fA$G9yhQLt^^vt?ZY*kW-pDnn zI@9Dx99vPZL2v~H>8(VBQR7IdS$DDHGiYEJl+ zt%Gcb*?jK^)$EmEQZoZoK+MDQZo8|tU%pP0S>l9=TI14E%1r+zoVWi@!F=T*7d)T> z^jzj#2D#1Q;je7GAZv4DFb|F#k1y)XjAwBzirlP3#E{PKt*Ry8Es)pQey*JDBpev5 zDkZ#-pNiHeK-<$fw0PFFs#^)O#sj=FF$Udd@cvA~)6ghVfZw7W*??&IB5JT?+0sye zJRQ|g1YmAodadp8>B~SkKe4B4S^g{V(`XNCfy@STaf;da-CsZbl+^^q=A(GbHYkUe zfF*#m4C9*14X?q*tq3=$n)*&sN0c?^SLr1m)R($}?Pf}iWiOtoVB!q?Tk)3uZ+8Z} zTp}1aRYhVmhFg9j>&9>1Emv42Eo_6&>&CCn4Bh$tygqi8t&i`IwQ~;h=$-S88O-o- zzS>$|7{5a}q2YITzU0=IS#f9_KWZs&OhlR9iC~K z_C;Wz=KX<#4Pk@_5UVR|!v>)rCVKdv!r;TXT!}B!# z+ITU$y5N_?or`r%|Gz@SPefC?7&_)8&wOX^)YK$zy7f*Y}cVcVv6*hE3%I@u@RSd z?k*)?Dcf#XrvcPX*t?OGteh|IFEH4aK)xzOPkl)T>#q`kU@?4Sf zRDLZKq@~}5Un+~kJ4?NXrL9O)jQl1o<04r_!5YfQ2KY=sSWBL@`xMO^;Z|um$v%fD zWRSMCKR>jAP@2s6crAG6KO~brM?Ht~mi(OoClbFd;{9ye>*t%b)$fJ8Gm?N=^D&H) z4wqS^Z^`Niix7&da~(KQiL7(z-`d;mbe|cOJ_@`j=kE%!(|#de2`uUvf{5!=I7OEdlnd`p zJMgN2X!rZBd$ZO&xk9&JSf=cE@_AF)zcteUQq<32RJ1L>@EFf`00uXv$q+GjQ)QgQsWvl2Eh@Z&=YB>$Ii;`4mC__5(*CFG{FK&t<7SYK~+)8QZq%U2S&?J3 zEVg>%J)A+`m}Ra@q&J+{top`Bp(AWt+n}RDE>wDi)+iqSOf9{~xGxa6#lv`lrs2pv zRQMi}Qgi8b_E&41uo_m?WsZLR5OOW5a~h*A@LtxiWiQPcPuC&vRMX+OtmIF3FdQDm z;bL{3(Ca0*o8xk;*CYo6ll;@aoj_Rh%Zf9^iD33}qB*0fW^^CQDgMN0>m)%KA}QKkUoI6CsLA)d!&IhQO29Lg%$K>{-Z^?M(`8rlBTxF564503NY3EmBQmWUFMtH^DZV}hhsl)P1cQ{s-) zQ~21xcFZak=+i&onHk!uL4EyYFz|T>I6n&8&ZyVR#}9*Hl-n1YtoTiI_xscV!wb3Y zR?^+Z?e2@d0BL~dLm6$)6j%y-( z;x8ZI#NIafgm`M=NmKRoE-tN^VQkawGUx+e_%;=%)~5k4p^#aU`n_v6sg^ow=C6oQ z>!-KY7rWQpftc)LTKQZ8#!Qaq?5XdgOol_Zy(eMP;>}1GJLT ztX><+Y`9XS&pkMfd%vHaf8(5kA|pkVwnMKJb7zCpONXH=@m8*BLhpk!7ENB~)y{rc zr!OSQw*Ou@S@G2GbOqaZ#ay3%f6x9L!RjNSFNAwsPC68UzJfY79EF_mU(wV#zo_+hrv4CqJNXfbW9R~Iva<}=1dbDji{C$kLg4eG%(Y}% zt-o0E4NK28p=4iz3;|kR*Y%lp`=>2cI_XcQ4FcDd1_Ov1C)x8&6-SBs5jniLdA&;> z9)87|F-&SKSX@H+LRgX;)QTaOYZcg8syb3AOS>Nh#1v=IKpLHndXY5~_9;S@#lhH6 z_$YyU9kw|Q2eFniF7p;LT19ug{p`iNv*ms=XZC75MBK+en7tY^EV0|I3053+{n|64 z=BtWkGNh6nG)H0aI`;WgF7k7~9ETH}&Z_SP{(hOJ_!G^jbpreZuoMANy0j~;+;UVc z*?aBVf?}~ApHHgSV+go(!C7Z|2NOTn$=)yF`0UpB!(WePOIrJJb;eML`PtvE`p81T z+rP?KRilT09XX%#BjI<|S#V>QWw~C_-LD=2%qYF{U$l#WXu*B>i_?Mn?YJRG!E;xd z0fmjsRLJ<=dQ&W3y)~}HL-$d$fsbwLPxUM3chQbDWPT#48_dLNvj z0&eBI%@#~+>-*cEr*(8c7b;l=w^`tLTI%Ey@I`K`sy#_4tivm`Ty7^!>$czSF@=pC z)|!OcFc|pN9}dPz`N^EZscihu?T))-s>?RqpaHH)yMn2v9jJ`w=%vFQOQ@V(##-ZI2fWc& z&aY>!_tH8_1Q?6Ru~8lQN=|*YvHcSvkp9zdccK(_Gihu|LvqgVP`yaDiJwRgg9j4R zG;4-y_a-gqxjZ8xG|^{}oYd0iLs>jTLE4>iO$v6mO{#14M|JJG6cMD~U{xI=pVBS~ zp53KK!PDbB^xdwQn=s!OPfwzW+ib5uPhQ)X4Zr2^Ep9+O5k8t@VLhM5o1<=z@J^yZ zGueS{4Qj8#D=1?G#=8MY|L=z?3%lGIs-pQETNC70C00fglsv}Wp=5?H-m>xvXYleB zum+}Q1vd%b^T^=80IcvZt-jIElZux(ircZ5a5!n^IiwG>`rX!K5+328KWo1jlPhKU>LK7sd5|MBA2 z)0aYURQ|@G94d@8QzM!U`qqQhSnBC?C3vP4;Fa>_Nzrdc+Y&A6uBo852hvlY<2*y{ z(ls^lhtx+3QPl|4qEei!bmuiY5h5h!je+){N}-$@-Zd5%lCeDTPShJM?8N?xXdt*d zvo%>ka9BQzo52m&BDT8Y`PlC5ZqYrOt)uyMT4F1OL3Irr1SQAkxZOuT3XhNg@oz_b z2cs*X3Yj{bmN^!*+LK=e4EoGDSMhXxmO9i`kB7L^FPKAIK4RE}9nx(+geTkPt2!(W z+I>+uovi-A(u#m~HILFwF6S`#i7}NqS3|q~xt_89)0L){HO<`&Bw4(|TNTNAiNSXl zgV@}{RU0^|eEsZ8j6AyDi+9H01Irn_a^EsnPU$=whA*(IKiDv>$%sJ?+csp7tHoI| zMo`w4l|4VWBSl2f7N^3ea&`;Ql4P~$`PviYL_!=uh1z<~$37$*VFU<2-|H?V*VmGQ zy;`4Mwf4w-pM$gct$+uBdsvDFrDGj3AIYGVbDpD*La(Kt+%%4(5(dA=s(@^`;vo8P z7J7L`iqub-&=j;zk-r`0ZO{C+L`(TozCk5@?{u&j+s{Co?&DR{>$30e9Fi1SPbsT2 z!yJjpblz@P-a!tsk$1WcYF3FcQVb7Do_~XGor8g`@5mRMG8)N*K3yK z*0O}J%{QAbL_@(pA~}nDXNgz8ugU^29t?nFehR}ku@U;zJ_VC$XvgH5oemBB6T9a# zEkzr{J92#ULU#v`BqGp}>X92$W^P%&DweVypAxS&YVbH|E?TRxZyJJ11KX$aE|_dN zyk|Kv;;~Yc6v5~eMS&obZ+N5pA2p)Vs`X&(=8WzYxw9I!8%Y)15&4n@Mb)IDd}^Vj zOBfIo`ZdKDysY`cT^#{SS#Cv?X!CAvjTya)J7?DvA8*I5O*FVXoOWpNrOxl5J^ zl}2r~Eg&3>tep=fg`3r@-urXE`S-R*du#X=vZb@^yJc&!CA{q^0QS{}Nu_GDMi~k& z-&?c!pD5s7aJ5SJb*gFz#d62El1xNYV%8@su;+36vyEo~bGF5=z4J)ztvdwhALfiD z-}4wM?uB0FpGDq^e|o-gz)g#*>bm?`D9c~FAx}5MS-DBi>*?dcK>9hO@}ietoDYs& zoOV)d5ZMdjWWW;{n)PXQcS~*D9rcO{83FR~%&|uBsm8>;nL|#Rukr?h?V*3L6oscn zUQX+_+Y~> zvIl)G%3IKAErdNSW$4mJipYLT3%8-9WExM$Us`p2(8-J^6j_daIOys9Qr^u6Pojdb zQ2?Z0ND&N_OvER%vF`KFUUtJ_Nvm<bwxpZ9Ch8QZDd%f%`Bkp!*Anru+UhtKE__V!<#poYZyv(YQX z+ouU}?U&NY`pm+`7#v0<&t;shYRtGXNsYuX$qm@TDJC*2Xq?_b18c1B;Ue+PQD?*L zfuWXAPgYZ+RthV4JTfuKtIQ1{nUh$pGDT|AFC(fvmIpbfhk;b+=3`kWK!t3Yv!Wm^ zLyb9=c}ytuX`)VQl=Bt1T`D_?XY?c|gtr*${i`arGbLLc7ik=VZ$a8-G~a#WK4-K= zW`BCBNDq#PJbIgYK~^@Y8dICOp5pRJAUb<+Oda_v!Y(JIL$PQhTQ}__9OB zUI4v%HMv@_2??W<%3<~=%RqH_#Lp<~fc16~2_J2J8uI=P+iypk_JL&+uGBONl5PU?=aIb!`yRdnw<|V^2oH_NHY84jvYDMbi|Y$!oT=09_!^MRbHa!g_^ zrsuNFoeO_P-{TpCSnFps&i}+=_$G+R9pF&}bUNudc>j{;$J_1uG*8Xv-6)tTFUoDMZmohm zpkbV>_%&`)(yomB=?hNs^t%?K#>Ss?Rh>jw#cryRN;A_!#d3`ohM7--_=Glz(~{Lx z#UV(X4VAanu4jyrTTCmIcZf8WxMoj+<`+JGCx?5m53cRru1*_|XFGCnS*PZcPZ;eu zG&9MDtzC0$XU$8$Cuk4hlJTht3Go06l-Ft_(#AjW1G_&*Nc)H)o)#JMV%}yE%(&Cn z0_ACH4Pkd15CCkcNLhC+^rmf2OiHh54`B|U{$ktCUQUF z7h)C=%Tn|nMEgQY=Fu$!1xpmHJtfX;EN2WjABg@aVRuaN&XH?6Ck0@_N9Lqa7}Uz- zCs;i9%NodYhk2=ssU=1aNsWw_6IgR$lDu{f^?obTeL*x*wH8*XtXXWCxN&t^7o0o* zdK?_r6F3(JEdOMVihsi`C|zBds_>Qjs*iD9&!ZZjaslP!YxLao<+<>yB+Iw_j|jXO z+@^PnsLC={l23>E?c~P}cyEotGg+lhP+<{N*uUyJC@0vt4aB)Nfkg%C(MW`?eA;ni&1i zO;q&PxjtlDm;RQSMLc>6X%+agF0enAdE(`Ewk!Cgr}zr_jz5MwL={&=g) z&gWzDxHR9#2BG|Gwv)1_GWAL@c?fgqK)r!7U)C)eaXA&*TYz0M*t3#5lXQ%Yp^8={ z6*2Ei=t#U_?AyQ#qWZM`s3D3y0-VU$qm|Fz!adnoy}O%u`{L*Q-tyk5xEH+eqk^%? zSa6K?q{-u~GP!2nUG@cNi|DumEAKX0@2l3N=hubZqOEiC8HHX_$tX6QS-18HJx93 zLCOh8S~40HOd5M-I6V5B`G_$vDhn3TOU*evFsW&D$GsW_`psw(495i`FPccdT*OnC zG3U%#)!`nn$?6=Zx^nF(E)sJ0G=$K|1crrVEuv##5-s%#Cw2CTxPDq{>2TZBt{gOzgILD%>-19IGvSTv>R5(mQcz!UfQBenD;>4U~D}d+{G1@KR zJb`S&)XK>+4tKS_UapQ%&+hJA<`bzbk0sx~wRsg(F$QB;E{lHl9^@6=4=%kkqz+}v zGkU9M;=DpicsYF2af)?_^VW0GhY;i=TwEugC2x-U>Vq*Mf@6%dq=?p9JtK}xzC4lNBLAR;YDhje$> zLE;e7-Q6YK@b>wxdcXho?tSBpp^h^cN6zo;SZl61=i2+^d3JZ!y-9g9$04*qEWJo9eT&sQ=%*`iIq&<~I51ZaB^RuRi+HXP zK5D;^&+?Ks`pKO($dCOvRS88SodKJfO8zhofmNour9XbCbA^gNJC*kZs-$0&r72Hm z_adQsnZLRtgu-SZ=-j2GfV4MnNQF#+>F^GZ-g`plY2so|ma#(4i?_v$?WZ5E|j91KRpB%1F-B>tDp6exWLG7uMK*jUIRO zf?Bl2%#1IF;dQo4pVy&O%UPHvq^!=J9kU3`tJS|Mgfi%3i8 zYak};e3AIm%fgE~otbIm=^*xlfX^7lTVf4v2-%!XRYi5sYb;vOKKzb5mN=&Vu7Q>D;=|L@T=w|`AJhhx zvdnX&7ad;YLsA>9U#=~SR))1AUnGtjEgR9E>vq$ax#On56W znGvIv&_Vmq@tv^c1@BbK=eJDZD2Mlduy++`c^iDNNZMyfddzu|1rEx8vMOpxlXD{K z(X01XFR8K=64IB^*xR?OuUGrvgE1n}2nls8S6}gL-Q_s%Kk)^BkqE+6_5H-n0hEp^_#`1h>5@w^NoyTF&N3^UZZ1CNSS;3bVf-5X+r@+Tt6+Ha? zNs~@_WQ*eMJsd{|WuM3i*2`q~o-)BcL)R-*SEz{(&v;@FoJZ9_EK{bY&F1B~}0Fe$(HoPglV~2ZHZhT+v_jz*7&?ZXGOzk>?+sZ-?W58QVo1rsVyCiM?covG7RU z;cbkX?^OE`-IB;Xy4DAxvZV@RN^7%9dPAe~)Y@~N-(lpJ*Md5wl#y?f zbms9}@@eCp+uTD@jy^dwZko)w`gVPWUB)!wPBs`cZT~HA?AGMx!Vqa)5ppt-;((O z;MyXpx({N;Eu0Hx*BuQm-3{zUGtUaAEvMs~?hWcr9i)w%gq+!d3`Guu#%_By+3h!) zQRtSoUv;=>K0@E2XuOy@lx%QA^?A+qM8WmFXx2mLyZ-!w9kUc4Uus&m#ELoEq4OTc zUEmhE|1%PUD@NWlR9-d{&RY-lXLGQZDVbDnqgRJTg-vZi3UIH`p5`v09#Po)yJW49 z45sWn$wFQgOYNg%=6NR2kiINukIg85ffqPrHMW#2@gaHh`tK{{?+}} zE%z!(Po*?6rtYHi!)cx$PFoUPTJ%`qo#Q6>KQvqKDVGh|zqP2GU7H|k}gcCTA& zUS+)&CS}jTm3sF~>(%B*60ylheiLR!nlz0t?JN+PTsg95l?A$AOjR+{07-`Ay`X8>>>%^!$z_y@^(%{cB<+Q%B(XmC7Zcud~eBFM@nzJ1HlRHZ#!if)aNP@S1@&C&$3#4I`mug!<}t*uo~ z$%iG&uD!~45o;`}=X<%u;DXVbaO7K9vDKT4fMLn@klW2+Y`6yGLANZ<|>V{zn}Z{=>MI%lp620=R2phUANANCuT{b1Fq{ z>}M8=-}L>7r{BU^6_~q(hRXQ>`>Oqx{$Nh4u}Sst@i?Vdnc1!Pw%&PHgO{DNO&|Ki zi}S{wt(aeAX!|!t`b?}ElplQR>fWun$S-TWGz%{nN=jcveIah|OY8V$^;3pYdu=YArX8#OFKRo1ckrhV*X=~{CWOuR z8DIXeDhs%~tyU!YAWPFMq9Qjw`PK5Fnav|3%s01d%NmA6VjjC|h(d|Ugb{NjNe3`! z^0H-!#rljXa%o-qrSF+kPsuVdoe*r#Cs{1#Q~D}@!BpNn*&b{?ifhaz*-bc46_+QR zjpKYBI?|Rx)h#{H;4{EkVnB(N=t_ZeHlhL%Zb*NG%l&QmJpJzNft9l7ZjNQvzIy$4 z8h+Gdy&RT)x*>JZx9Za|#%9VnPGIDfeJCPgY(!?aZRyfl@N-l}sv1#|j|2qC!DS3Gt;a1huPE<^ex{%(X6-)U z2(&kkYbTt{e{ag>oPNFU5i(UlRQ7l@Zm8Bk~SrwtfNH3WUYh>cr zCQEaZ_~%u&UDNga8#jdV>9TY(P2BlPJu3e7e zZ-0wp%!#0=pZWH~w7l(dRQ*~tx4gmfL9i?I6&ccf0VOhlZM81{?dpS|sS~F9>rvur zoE#?kWM-xGI&A5GR&f8Nwu%ivP}$5KV~h^E6_Xbak0GvqwxIf$o3q`GECSXmuw20 za=ycShSoSZ-|SzRV|BbWK1k*`rmM%cmBjCC8(8c)R-|iHyIq0rdVP>|Q?*%8ThKZt z{^FmfDmfbiApAFJhv9DHY1gOWWksxw`2?5}efghzw|5PREhx_q3jIpfmdGUr6ZNk09 zj~b5ShE|bE${kDT0l*Z%$K9&+ZXWeytF)Ihb`ckb!he0bXqD)^Vl!z`8brcs@R9F) zEj9J&oSp=xhQqMZ4rsu0WibCC=`w2tTskxkxCKSuZ{fN#-e=xJl%R71isw$nq?KJ^ z-Tq6Se&vYSOnC~Yo2!%BV6vmg#gA0Mp#kKK`N$m8aiHD1X1DQX#{FnmWe40f^<>f? zNTp+z#jS(`_24o$E~_Ap5r|@jjOpuXf!(@;Dc;RID$e@r^Ua;A#o)kW0dSCgf#=P2 z`1Mht=e8d%8F5!E-$1pUapkbGdH_*X!|{aqeq~5`==zSuwDoh`?Vx))BhG3gn0F(KlE?s?m|Ze{=~o$_rJ4 zL+*QbR^qICq}chZ$Bp6)PnXC(bTO2EmPr$qmS(EAf9*qJRh6P{5GF=_)6F$&pX7e5 z7AE7)I&e{OzL7gBAy&V6xH($b=32V;EwF|_MWf%Fg7gxivv6jbuj`FU2th{2{k#%4 z$Zb>>DrvdXtry5h6JZ3A|2gTi=-Ly{6GNU?yHmpw6!5$%Bzmkb9EpZx&YBd7hC|J3 zk-uIjTKGd<44ZMRWT_U*Izl7nrTFclRD^3e=6{i_T`Bug1RSE=AO8&`@>-adhk7H< zh6_EGi*jmHQq(K0cG{^z?7@aAI_JX@wAxtE>z+4-eTVy}(AiSN7%L4^*}cpLB8UABTJ<+Hv|C{P{A-XX zLr2mmo+aO5z4)V$JVe12x`g--FAx@STs{7(u>g*FdZy+c$QBQd{k!gU*cg%$9Z|Oy zP_yAT8+wwfVBdN?bu#Ugw4@*TEZ#%aH#o8r^aKU%b32i!_lz1Th~#|{9mwAb0|Cp4 zHodhUE_mAY@X!)MF$0Fr8&6xA3}A@#W1crx(_;5E-zd?NE3@hw{*>d}UJ?Xb`&$BP z-uAJZJGca4X$+kiN{y9=0V@qK#_hd>q+GW+{=L)MFM}$yk!#Fbf86k^gLz@S<5a+c zvCu_K?LiOUG7(o-5AP-ePaAUWGkEX082!0yLAn-83?|>5hy45PRE#}8zG{1@1xpt% zN_w2s&USprCNzG6L;3gg{#SYBEe;RMRNm=tfB(=sN&+F+2n!;($iLN}Dxn_5JtG9> zWi3mhsi1ae1ME;M$ohXv#ocpWaHk3gBWlz`#2$UEE19a$Wo#G zq30Gd>6Os+slGI8qwJ)@&Wz4?T;=&*u35K%n?o*BVF5^WcdUZ3TC|?aGfs>HJ4z%eyZY}0*&eXd~kQFvwyL=v{eb1O*IqPvz zb^d_VC&us@7dx}531HmTTt*CCRq$~XGaA1ae8|S8qwtG1Vr$zKdx3$OOh?+6k zc&qMGdsqqO1#pWMYBlr7f)CH%Duc8U8B1c)XknA_4mG93;6)c0}BAFyW=oOi5aBo#R@6x^=(0zQ4Cv-U{cy*}gFZGanv`)dR zl!OzToL@EZ^`+y?yG0y-DMkm(v3{gR9B%WyI{p1lFeo&2+8_ah=Wvc2;5)V4k&q;c zuk|N$jGlCyv@}$~Ce=iAd{+SuR#z|a&}0+3*kW^9j#TOFC(gq1E4ly+245*#Ac98L z7T!Q%LCHwMX7rg4EVk)e^m%hd8>AHr*7#M6Cw>83bE=BM+9zZBc$Mvxfghh-C&Hlu zt1e2#2cvJTf%(vWec&-;XUqK0iXw1NuOaD0^ljZ9eQqIh+pS)qo6ChYc6Ue6?(6y^iPi9B zD$nttU~|)_{L%R#d1TVLwJvMV3-zbX1t>vx$UQG@jDy|RX6*-M14%E&&Jgh>=IZ2- znR+?C6!=q)}=bCw^Z?iKCG8Pe8lvj{W7}^w* z%)l5(ewr>cE_4wg`ri;~UZ)}A--hJ10${VkJdU{S1|U=RM{qO<_xFWBrX4w+<$L3I zs0d2*IR@(dT0m~UUK{f#-*VIqcK^xhy3&&nXG+7^yo4!}1$|9)OyXT+0}Fdd8xMIt zK8x*W2Q9#j9t%TD+jILq9@KFgofIORaP5))*W8%5@1~|y=r0f?TGkexs(1buEc`PC z;KjDDu>bME>*I*u^Onqx$PIxQvBbNN#d?5n>jXcM((Sgt53XTo2cPRpbBpQf+O3j6 zAG?9?;L;1rei!X|9a-e~5(i93Z04igIhigqe0WM{#ho`hEau$ie1is~WfyzEY9JY= zC}eqGZWrrW)x+)_vnj{GCzRssZ&J}$NgF3n_G9Fj+!v-c~V z>S34fk`p)@qRjMoi;He$=~x3@@~Tr58usBP9RV8%Vl?`x4%?9-Yjc;bFqL!tQZ(uO zQnn=+#W~Ysg0m{g^ZM+WPzpNhlV<7|@Za)7PQjUjaXbpt+B2c{?w%CGC0C0fLcuHg z^d4xxBl7XEG57r}sLyc&vS0YPBRTzx+<7ROpj(x6!hou###b-bFMeR9^2?6!&XgLRq(cuMsA9)vq7h{D^~!b+IgOJX^X-@fS{MqnsZWmx&>BaOb6mSLS6KVUUX!B ze8QhjYhdI`4aVsmUsr)CGR*YOEvV@XBH=2?vv9w~eD)Mao_|j?HcyFEX0J((XpM=6 zdg|R`Pzjqz4uYrafd120j)SsX9X6OW=sTc$KWU1&aS<_5iMh;6lNMLt@RhXk_9s{G zSEI8Mu*#|oKL<^%1Y(audH`05TReZ!57w^eDve@f zUs(p>HUN2-Kyssa+I-($C-?Nynm*sBkp)y-Zh1az%f*`Yw zpx1H5xDko}_RXCURsEnJxY#DHAp*yttZwtWNQsIT%^{8J7$A*g)`G?yYOU_Vnrp!v z(^{6&YiM!Es;~F&kn(?1!2~ZNS-JHlxLx4a%qAz`d*S1#+ciH$Tiy3UL?-Q7XfvMo zy-!HXOez9dibH-N$DQXVO5Qp5#4m5y2Y2ng+*(Bkf+ZqHyjA6tZJ=}|=s#+lORfmD zI!ocmCiPep#xE10cFP?I4Ww^FoUUfdJFdaB5T(t8X=VU2sT{}B=uV*GXMmk18 zpJA_GolWn0s2HHzlY5-bmz-1BK*k+LHSaJIC8rY0U3nv;^O9D2;l;N_BiJtl(AIzy zLuDre`*?@S>`MIy$>$DyXLJsaQAkVXql6`IG6Du21&NKJN;m#m<0SWr$A-_NWbb{9 z!TCJ2dh%L!QX-f%W?dvZm68=n=3gKh$s;Jih2@fX3CXWL^ePyEbO(|=p5w~BSd2tE zS<Jk%o}*DerHSv$yDeT4em0eQ$0J4vkaW!7d}( zrC`;CA)=Gs?i$31FC?Vn5iG80eB-6)Q9i63@$GWf-(K&Qqhh;|aRy!l8a&xb8x^cC zA>5mLg6;MabqXsE#Y-RIC2C$G)96=){mA{IO+>*+q{OfU$BFE4$<`9vUM8AJnOJtI z>$c~JG(@VZ9%svpbd967C*=6T1I+J!>YR}ztZ-0kCCy53SoRk7Shw#MJt9^8`p+vr zHdeSh^;FjK&Lq|E{E~GGk$Z8j-S>c8PD9-R4<<Oo_c#`fbxFrme7pQXW2zC>h;3p%1#fl7xh zXWuwLe&)hu1-RA3l<(}GWGOPE+{7Y4&j`A zp>#W6Ns(D~xPDRi4mf>VxPlNyw6Q|131}KRB~u&2#kQb`gBEkvyoS$a1L{Gx@xH;X zx`>lGOf_rYqCN*tN6%Ab+p%{SKGl+i8P^=3OmxfhN?CST?G`i%uGPvn1*l8j#v;?GoN@%;JXwN=xx{A=Ckro3fp zCMq6jdOuSy4pB;nXr)?m7wt9W$mN~;BIlJSEW#0YO>SWW??ThdYd`MeLAJt_!xVq= zPc`QFXQ0$!(^IXUzU`X4Rd>*12l7_RT5G+Ab^X!Hf(^!#x}njI_;pDE{4ra1K;0eX zO|$0AW1mZqJ5v)M3`jE>CI(W4@DEHwnsJEGZLfKt0(ndPT+5L?HC!d?7 zK-2h*$_P)Qw(nFZTYT~Isj|AIVK;Byb$)It4j)Fm%SQHV^oqg8)kIgl8o{v30z^pP zgyVE}^POJBY-s+4^y_R-@``+zK@i8NhT<2fw=2jxJQf|+^AYlb#aSBjYp~9n4BU5u z7O0#()=c;5$aGo2Wk9Mpo<1og4W)sw~>cQqw+GW%B< z47;zo?lV;Be3nj;<5OS8AUhseVuzSa%!*RGltEt~K?n#F(M(;aWdo$u!=2xpUehWTf2cV=`eLxCR<5C$yZvO_lpFX`$= zXKbHZOA|9c_51(3xY!Z%wtOSdC%KwUaU@8TyY*2$^?47vM8x7uNI0QU=}~WU;!Pq} z8Uf6Q^Wnwa01S+RqjhAhz=4J=c=*)MYSt)ud}dgSSt|x|Ot`D(KbB$=_W4^5fhDV+ z{UY-}tdA|OK*UogP+GiW_-=ry&O*j!ey1rIcg&U|_ljj5isbl4veNkmu+@2z1y0ir zU)7zLBOl|^-lT>IZtPF$8q={edsC4z%n7DrGd416v7{_yXC{8qY%WDzwID9?ZA8t} zXaJxvFwwW`S*=Xs=FzD3ESbw%$_^A6uZ&KqTF=3qSZ6UOw}7}5!j8U`clcSaM5boI z1>AFR59a=6f$CIN*C}v0N?pz)f;REpH^7if(hZm`RynQPP^`7XIXa*N_{$^}G+v)a zi<5eWfM;1e2}4^0fkE1L9B|{C%(~wzcIB62d9`FG2LtG$e}0*DPs_e&JN}pAAR7Zh zf1N?u?f4sFhG9TW2Me`@)H*(y+wqBj&5iC@j^ZD#3*(%He9%QL^~K>FgUu`qr{;Pld=>XjTlZg8-&OU zU6ollzYQJr5!?k*f^7@Z60_7Ipt!ycFPV@a(swaFBT~Bj-f7RBe&?qQ6%IOiypQL_ z|w=->f-bLy!_rDB*l*rXIT;B3QBRZP8*@nkN^%s zNKy~?BDS>hvP&0ZW!3D}juUGN#;yLD{}gXlb3CvDvgj|b6ut0C=&Qlydvq1eHhA^g z9%t!OC%*T!xKq(_r{Ft`raV8xZ83<;@dgxthVPLbc{=1jyaP$$gRg_h_YasmKb!Hs zc92$$&N*)8SJqfJoV9_b5^)-*`IB%onR_unw}~BQ*fSdsUZqQTW6&6Aw0@7|zbNhh z3On93G_MXms^gQito)g>4RM4XllYF$cnjFem>M>%1UpL+@;!MjzU<0q`gfmiWVLmU z{fFcR1^hC>NC`?0pUlIo-Kc95wBSK|l4Afb3!?H>2%9%%4oRIP^*24au@4?CAB;9$ zbCg=TX;nNjm0^&qO!I#>MZsM?>EeaYsd)!)W!(Uc|7+t}w>4|-bQ4<7wc%IIiuP&8|~=%FlnE0iu?nLJ-kdt&?>nAYa?>kY}J9 zjwvBcc2@d&1&C_9?fKfHMo_-oh9VjZI9|7-b8j3ps>pb$0=mxEoi2n5pN@g{Buh74Z~>Aye#_oM zAS80l+5hd8y7?7HF^+(8?F}g8@r`vGBp?sj1Z)dC<@#&nnD^9(Fy?c3TPz>9F;n$Ycv{(Qfz7~F@&NS?L+(;Jr(^nL zW87WRQLhBl8P6N{u1_lM+IHO>tSmG}=aNsg`g~jygsz)5MDAA{Q2>gTgv0EM;eAc% zaaHO!G^w1}oo^W9G$RDmEz2XP8tagTngx82YvVOfYd>JVJS9=2OD6Y7GC}sd>GC}! z>gpP*WEfW5@ZcJ9ltC%>nq|8t{8uvj5pxIy^<{Rwebg@w$b=UO@L0+0BcTMH!$q9C zr+I!oQ5pJSVuUv4`Q=f~1i7yBI=+f%xMbV`b6dMWa_r~ql^w^+`(mAqh))UBH#+(R z_{LLdMVwW2_ss{}%Sa>g>IYkI`^a+8aN>IF$OiWdGW9gBqmj|I15od*XUHXNfLmgG zz6GEe>ATkJeGLZ5Y#av=#()f$7<=@M7+=b)>z=2vKQ^E_8ZJnz>mF^7R#?%Y3#?%& z7cK7Irx}}5?BGclvF_L_?G>EdE*q57qr4W^_eVvU5|VifUCOgrY^w(3daYb9x2o;} z`gL4j0uf|4?wpH`EE}fcZsEC_;G_{kE|A7S+V732iv3sRC@YNs%Vnp|D;s#}kQ01! zqwa)ETd@PG$qC?ghJB9AQgf zkV>q~!Iaywn&(J+P+&Z>`H!F@SWI<&^9;5qc^j>BhWdCK-*r#pg?4Lskj{T>S9C?d zs1pH&C@r|0PF#tCeAz2_Vc|pKoe0OSO)hUEkfs{_F0bIwmP|08WoY0C%ecGxywicI_=C+f(4Gp#vbQQ!E7l1@7Vl#g1$t! zx}CVgdGYc;*sL!Dlxlbqf;}8Q%c(dH_lst)4##Dm7EDH8flVPer7J=FZ>*{{{1dI6;;;V7878GDKf;o<`qjA4=7(VD^e1~U zJa!pwMeiGd?V-e_QTpq%VD-bwahMMosGm*5k*LH!f9zQUcZL-ZF`6dGdcu`-kZ-LM z+6K-3`AcTq1^7P~CEV;cF5tNBHAsPb4FZ3YY#e8IQxghIPMal3Q)B7fd! zKpa>gm0!%kX}!~b5p>~W?T*=3tgUUEC-DnU=YK({*hms|uZaVdyG1`9x={R;YHJ8R z=@nca8oroHY>b>Sh?V{I_Fo(1&m#EiMPCMdDP&mD^1b-=zH(Zx;S0!qbFq+d4=EQ| zhVau0DuKjRBjSVBv2le<#tS*}*bY+&|7Q~7Px>YH1uHw~edNnu)|6z2+g^5_0Eg}u z6I`mM&w{I`MgcQjFkwwr8Kw+hPXGR=%m#^U;@!=d!0%!AetY+H4@QydL!o}8Z|;$| zX8g}d?!OnH7LxrI7n5g&+^=uS=;vkG9b$P_{E0$k>0bi#e?1aA6PVk$uR`koTrvOk zN!~_}neEqJIrR#hR4K{}3_I-3)T>$QF8=rEr(@bPBy?;1{wn|Vh<|?v@AJD=V9ck` zNGxvWQg~kO?Eql)as6c&U}lH%%SYZ)zSkdgoOO5N0!Ng+RNBz2=%9(K0yOc94LlOg zA6Mt2jgD}Yx&EkF&kzajWT*uQ`CA|8;++SGxGcKB+~E3b)d_&;_(`BKX#&-78fZ(j zwY&2l>w$dVKCk8i3LyF%_pM@BniWq9+|E+0532!rbpX!3?brQWYp^Yg{!I`BNdPp_ zrZ3(7-(U5AUT=Sag3nrwdTW+Fb^(^t@I-MVNcFt!>Ru6z#iSRYxKHDt8cn*|GPZ)7 zCKh2Ky-RGb$&dgg+1uI%t|hmIUGH}>?IyZxJRJpgmCrT@KiERH>WYAx2S3JXTmXpW zR=}3f-JTWzOeftATdLzqUKO{GXI!n}vUWAFfz0TZbb@1d6_x&GhuA3*ty*V4Lb79j zE$Ubw1g$xuKWq8#7s;F``?=MbvT@>>9+ps!QfwMee$W*yCU>P^qG3Bv?YM_>9GdnzgM>~)$Obv&km9%=A1t+&nyx|?!)eE>(Lt1WcJ^0{Il|e zU8x}59HUjhmNj_=z|0OXtV9xwZwHB(Z|;I3eA)+3Fj~#<2JT}4iB-L#!xL1bq%2 z8?_rf_M3QIHR@;UPW#FxwP$QNT_`Z_P2|XF z;jAPS8l5{mtfijVI?yUq{S2bYo2g_Jiy zj-O?$`}y4p>V>#4x7(lUpSNV)>FpQQPG^me=5{>oq7eRyClknK4xq(7kn#-X5w)6d#3+(;*A6J(Lz0*Kem;#Os zZ8+iawcHtS+krK&PCDreKbHV3rj@Y;=#e19bjo)yVirBoj2Ai3|16X7(6$ zNasNQtu*LA0whiQlG38U{XBV~+jl5jtXgcwE`o(iT_)_qjka z;4J^#Vxv%?ZdILL zyVpiSX3>StJ=5uak%%!`Q@_SWz)8Ws3x}rl)=lwmdW=P!9cuXs* zWL}#?*@}i2d2_Hid4N+45HOH&gNkK^f>EHK5XU$Gw1~0q83%W|Ih+8y03%*8}7?@cQOrQ~QPv|FPLXm+kM5{+%dIaFH?-9Amj#=*w~Z4**}Q zA(fW31!SG?c_TIe?L>wp%fYj6%{_YwfZ`Z0$+-fzQmPl zu-aV~7->XdqEuncM$Q7{88FCUoTJdV0#{4vAJVd;V;$fCCDkgwQPJ9l1d}A>50DUY5IzKY-Px3lM|kjKf}m1tP34U1Bgr^KcWarUiT zePq-6P`c7;dg&A6#ckjME*Wa|Lf`oQG(_ai#gL?=}wd~*yt;dT5K5VKT?-3tcv|`{QqhW_mcZRf>vq-NO3gml3$?bIk!s`yNYS5>sHjci_7E6ZkP>1{0r9gGih1*hR;O z`Za>vVNFY#Y)E{d&(H~f&S(zqA<%Tu|ArpI)1WBO=F<8C3QG7_^d^)>=yBuTVBu!f z{f;sUj?IjT2^l{-1u8rqC|0|L3_I`HH0WP=sH=rE-TtBFDWDuZf zm{aIQ(BvLq=Z*Z-#%<9w;yZVhbaN@uCC>j2w_gX-S< z3*U<%?+Dl3dX))w9;%)$>VKrgxOz8;l*(&0TMY_$b>mcKBFAh~ckJoJkJarYgIQk{wOdKxH(rtgG zlVzlt?fv7llI%sEZuv#0DI0&8hfrz6vIw=+y5Y$2*Q(t~t3kH_Fm!J# zPD*+h`g%eVv+h7>wYR2s*CR3D}M(Z4L8 z-pbMl$hW|L^-lm#B*tTJyvr9$#ATo2bCO`gW#*WJ5U zhF))i>?5hVEa-9h&W1t%A&PT48qdphvz$6`oFgp5%(Y@k)+4Y3I6|~(P}0;-CBy`6 ze>S1tx3s|=(Fw?ZP)xS;G&7#kZP4YxZML|%78nXre6tDS2^CJL5HM*kn|QHPoZQ7n zEE=Zid7sCg966#N|9`_#vgK{)hFx7;=qI zm?^ZOuP!@<%(qyaH_z~Qsra8gYQly>$I~&m{j*=!l_^Jhbv{j=>;jmla_W4>__{!U z!kt;;@yvd_qByR1K!Ce%FO%%J~%@crG_Rz9tCuY@5580U<4@w5<) zjVG9V+z5&t5XnD9;3!-{60NJ|{aDWs7>c$99N#q8R@9HC@5>UvTpu}-Jm7}tWJqnH z_-if@Qzg%uJ^+GItihl8PyPhPwvbXL=l24R5=HPKIn^0Pi*JO~G_QYVCv6;y?N#1t z4zu>bbL>7r?J-*OX(l=<-WIB$`JhjMkKTu-oG=NMYw)nk9P|!?Tg>TJ;40wP?FX_% zXz!VI3=q{d$ZcBglw8f1bUoVy%^E^Z0I{putlZksxeW2VY@XL>bAv7+sjK;1c!^}$ z>lgtD?@)w}xN4&3i?)M>{5t~vRS^kK(t42%cKVvtrOr>|^|v9M&hS zM2NY2y*#V&;k+!SB(PG;vVQTS*oUtbObOWF+B9VW+;2hVs9};9s-1EJzzw^y;?VET zpzTCqe@A&Qgx~B^5I*!FF6oYHb;vgEUicNLytMa_HR?*IKw4x8`nYWr)sgptQh>ezclg^Ai|;L)=oD_uJIeR5A~0s^ zgLzgSI9GPfBFhlMTxUU_$J>4&k;DqVp3UlqkBx1e`UD2e2W|U)F;iuw#W5l#G70GE zi)yMzITOs0zW};0-(yf2ZXdRlPX6^|+kpsL1uRuwc*?dq4gv*`5&a_pG$!Wu58hZ7 z{UO^Iw_o)tpEWy&Y#$dZAP3vD?0pYL3?I4OCxJDl)tG{qyJ`u!#Q-wwL^z!`XwKoX z7%QrJZdlsQ8KHFMtUDEFtNz*ww8^|CMV3kx5Bjvu{$7bi)$+YJK_0d4|87-5q_ge$ zQ4PE9w7*X{CeCoRB~&Z~b7CTe*< z_nZx>TC~BVph{V)u9h4EMbz8u;oIHv0LnCvMQS5Z)(yA(S?g4;0P*hxQt6AKASuM@ z8iznr(Vq>Mphv0(elze+e&viU=m+{lvJ|4$?070m1i3c}7=Z-SeK6p*#_=}dG=&oZ zmucQFC7!>jwcPmSC-Me*Xzk;Y3OSdLI{of3uI*))dwtkA1qR>*Dnc>1uBk~=9+vCTElzr z&eNZpl1&8;t7r=__O#f@7saNl6l%q|(frN6{Kx4R8wCaQO{<7S{&zKvI4{NxmU&*B zF`T?=WEwvp@orj8JB$)2{+uZW0$vT6GJ5?9u#e)UsKRCw$#zpgBZYIAhN8h-!a5kb zFL)EpqP{P1d0NQa5Bv88EjaT)g+xdY127Xq#vPvZv%id3IE@z3@m6A@3HE zX@!)7^f^2?#{8R!a$kUcyN7b_?PYDDiCiI4*f==rw_-sfo5~x1eJqAAc2z2|)L|o_ zG09R!sns0ZLtyYTJACHTE!96AF~8{Che#l?egh}TZhyn&_5-FP9VgJt1*=mC1DYCr zM3B@lh8`py6pqvZ^AqLE>Re!2v8Chdy8L@jU}bh-mt(Uv`S9M>Bz zSf?pc7y?$3>Y40aV19G-m2QTkQn*sUp1ZLJ?~|GaZ3RU25A=cA{BL~!zhq(`9fr;@ zs`7lZZV18Ia>W8n=dOXeIdl(^um~w5CB=NS;590Zz?)m^X-M2to5cIM4rb~U$gdx$ z>i_TNb3uZ*B?58%nLBz>e!n!&2}{cT+`c^*0dHJl;bA0Lj|9pU2Km!`eo$UWL5H`_1^OIA7V`42xf9O_OM5}1WB ziFYmAOHlc{{vIYN(L zj?R0f2=&52oBbhm4HLS4oujiUi3Lcs4`R|HNaB%n zOO*j{GNS)=LdW_e%#;^^uE7&fZ3+@2WbUaMCp@MOkZ|Ui zV0p+C2p$b7>R(0O_O|l`OvN+PPHj*whM%>I3PG&uec?u=o=n)rJ_uz>G{SRJ7y2Or zoV-Vmu#Dh6XiB*UN5OnM?cB$ER!8t;-6V1aVp8?aG;n%W&T37_CY-;M;Pe-C#EAyF zP2K(PUcmc4pb2S0-2wd;aTgo8g`_l879b4oe4;P-7(u>aVM0ddbpSR)RJQkt`dB~* zbtKaHCGeHJxoZQ6GbMhj9>-^@Ad@V&SJu6t_yiGc1~6asH7(Yk(t%hGL_9yXv_Be? zx3VyT53_#TXB)_d{zD{nalN8%8D9Bpuh9{Y7(s&LB6LuVeEl?y4`pcNY0^e>FoF>> z+UJ4%D@C6FJznW8AtLvljdE>*wDrh5nck?TQ@O$TCa?jl4mgFByq8t|`L*#fJx!lN zqDpCklL8;9NS2>;j&P2ljqo(`yZY!Xg39l+0IchmnP1m7=mIuv2N-W`yO0SXpKD_@(@cVovQQ@%7$8sxf91Mcn98@+wr89Vb^?_l{I z#f{Cld_!q8Q9^)Uw*uCX5L59#QJ6SH*uPCt_P>b!z7mWybydAC$-Gb5_goIw<~f1O z=t(%PhmKeWhyv7x55B0o?X{+A5I1e0VH2yp12KZ}?dB$UN>an(_vso?odFSavL$%S zo{E)uyRAdaZ&~3tv&)TAG3}^+0&h>0;)!nsOjV&PP$o9s6h8ehU+0f6L;>%wbyI## zJlP@y`eVh;mRXBZMbe#;ZIAZP06*!h;SOw*^oNWTyUx*d5CCc3Mrq8m-+-LR>MS{z zz~r#8_|g0LHiWtvUgY4v0o^f^=xWJweM&F^@$+ zj8H`1tx;8d94BBcZ_8(9G4b9;WqcSENrCa8g$M34G-ipNf_Gu`4z-M&f?k{kg5mRX zQ2w@*-m>XgdRDuoQ1YxNzU5^yG9Ldcv~MqOvHWS8`OD#+EQJYO$IN6_7HI?U^mB}xE`;x5*$ZQJo!%sHL)KgtL2KUIfONkq{_@pJdd z^KG+N_KTBLE6C}S+P(UUpamz~EbeOulT9@7kvrbw2ELKNA)vue05ZeO7SA%=i4G;M zi0mqWHZj`he(Pp)t)x^Wo7qCZ^@0hK%-TD!>dAH<(Nw}4$CN5Q{7!jZD0FwCjo1Gl z-rhQ_s=ezPRzw9T0t{)A(s%-XLKYg?OXOgew=D3TK`Zo(A?-|w`_fQRX5FAS zPZ-|mGaOJ4@H7qgvnJyPr5d8hzWFLx=xAQSlI;{*-p6CuJHpoywgGrROHHLzpToJz z@T2&$-r?>VwxQM3ohCwMl`q+q>n%vB$@dY!)5<%!?nk#%N&XAp@$kO<8Bg&NCW76@ zf7C6q(|CF0zPgE#UEcHj)*zEcd9&vT^4z-wGUPEDs2*I{ne?G-Ny}I=!dzxRfY9bp zDr|cOa^>Tj#Q@;9Ay65wi2BO9M@=l{)Fz*;YBy z^dR|`zbc}I0%*O*Cp9)pB~^x=0!Rw=KkOY*5;Th;BzG`}RmDuB*qp$o?_XCIMZ9#A za38AZ9lW#T{ZP46YIx(*dkJWWmYvUz%6YcmW89Vn_#!laS>+22a_tr2Z@~-@kUsP@uS@23X#s$x>lhXQ|Ff z970p0QOL+AA*heamv{Bm4UptHuGyQ3r50#@;by9!lE}Ebfu)V3yc?NMl0U#Y0jn|f zfe=pc->MjYEjoB(^aH(ArzU({IOdOCf-xKn6iRyd?^Q_S&5e{5Jkjx^B`-$#e7HKS z)knVxnu21h`o6<~F-oVF?9JSe<3jA>QDk{E81U>{3hI}AJ!>rR-+S}BYUD3ee^&|l z5B%btz~%I9|D$#1#$t)`CH6mWiEzbr7V8<=9f15fN>?pD)f{aPU+Q~6b{49a_P;6U z|B+)Bx*|O`mdACsL;vG1{kNdyuZ{Q5NRklQi3rE<#Qa&w{`V&N7m)GyH{Z5_Jutq# z%If?}#Q67L{p*u&{>U-@&#%h;uHyYKi15$<8XX{U%E3;Qz+ctP{~Wph^}SI8a!dnv zNb0Xex<3n(f31VR-oSl>#c<@@cJ=qffxixbH-$RLF;lS;-G4pZf4#MTyYWMU5XBE4 z3mD-1-yfsK3dcNIYyU9+e;$`VZ?;(?kR?t~W6_^AIJ4bdqE|S*as3{$wSJqpETvz1 zojuw|#sGM&ifle_s6-ll_2?d0-Fuza{IHb7{MH+FwvpDO9SwI>_2QEK>PEu6z^dc8 z8)|l=I1Rw;#ftv1G;251M%KQ2Nbfy7+|&PeFAqLh;aWF}YR6~g7``w5?_R=3^q=&O zR+F4=piy7@$5#NI2V_@mP-?R?@5@p(hw`WXt4~llEkAdOv=k~K5z8^?bV03tsUN_B5N$NJ~Qm#5z+>J>kTmw~!|Rzsqi9$4pf z2>I=}5bEXSK!5!H#H%IXGxa?!2#oC@!S-HVMIs)=!$K@|)i&?Lmk?CO9 z4yBU3f`VA5V`(I z^XLJY@OfFDlOM4g_haN}*aIUulxEf`Cf4nJCy z8AY9pzJyu9Wu>-1{&W@$X>RX87;vCbwAOKHSc$0LGEg{96N*Jx&)iT60-3)159<%o zl$Lj=L+s=E?K_OFo;?3Fdui<$GLI^gl`xb0AZE?H>NusXh=?8l?WTWt`HhQ&rpr<&Q;DxOU*B(S#Bv%f1HH{%nZVbtRT(TXhY=VE@6w+zX5ynt`#8s$kMRR!0)mx>Ed@VecnOz*CFn<7t#d(H@U`;}Abwm20RwIdcx1 ze6E?`TIMkNmE7U>$BVxiSs3wrJcN;~Q6}nYa^Ct-x`&%o&0p9KO}6OE$WVR@o$z%| z?`sF05|k-i#av@$89}O^*w<6Ca|>5i;V&zOB>;o`qGIA1**Z($7>OBga09m`-6iLC z53Ajhicc6-uwCHeEbTh)71%U7ib%sV@1yw-TxSPZAk^vzEMbRrb6CCB+ea`hVRE{A2B{DwgFxLi;x#jfgtZHZGcXx#36LF+1+bJ zt@dWKA)e26{bm<{m2x{!h~1qV0Wjyo^^C5PVRL@?SFv}KOfp`!#YxFIOeE%Qo0J{o zWPJPHb@ec^E5sL)Rp>5p?gSD{Kz%1J7PyK+@q016@-2qcL3kljWNv_SioI@}w5`oU zJJY2?>GJc7`KwD@KUFC~2uTEr90qRCB#A19Id@ditMz0_gj~%5KB`;yE=22n=73nO z9U#)_9UuxlS-T};f^Vj+=3}Z~7dD+`1OkiYAAK738ck6XZt!?WJ1&!wv8KrV004ag z{hcci2-`OhK6aRyw9QC7=7KFOtqVbR$hS7gQ`&ceR3Kv|I^b=@3-H0AysAu+-Ms=y zaa+jgK3LU{g-Q;9)w0lJ`;etonrDT0I~cH+;}uRlD6?W%!UdrM;?r&+_`N%(`NZe6ys7*SsMxp^Fl%X zV&2v$UpFpiq?QYT^igV%Dp%K7*(XE)GYN9k1sNfRVX{o3&wXrvGI*1mHdSX*`Sf`U z_(PiP$2jVEK9MRqnB!?aSDkL$x||z33zkernfHXB0xRW601#NwhY5r;uCp4SoypDt zH9&P8#1Z=k5sEz8bt}9Z)!atkKG(c*R6yLTT!GAkY51@vz`2Q?VhA$UI7>|j@oAL( z%|=wZ;)fB$L#7F(W|IA5JY0gp_Tj+W9^1w+lFn_ZMr%ynQm-FjEy^V!D1yd*C`3&L zSe<;m@^xM^?Haa~S87!M7%jjg*oUbpyTa^P`2wL$co?4E2`o^iMy@Yq^xaLsI<5d@}NQ3b5FQ6Nle`>d-`W6*4S zO>q<67ed3+A?!FW)y)CyHLhy%DN>P?}4L4qT6ua6D!~x7_z0Fq=MuJy`El zufR`9|3aNn?`(I1fU`lMWkup!XRsGK)T~_jge}@$>f7vaA;@IH2A}lM2>JH;M7anm z_7>`DY6l&RHmSx9S5Q1C&!0hY1XK9hVYFj>m+}t1zU?XT(JC z!n!I^s$XU}vIl4Ag~Qja?GbAQX2~@DgB#v!;At}#>lKE<;HWI}fx@?%M}+k}-!#F! z9&g1F(~D0?@VD#irMmbdUEAWX*7OISK6Yqg5zKZYFBQ=qZ`5}o_gbE&xU0z$r&4=B zTKpVQWmMwS?VQ}o%(|@nBQZYNvb{j&i6z5y{HqUl->i{XfksA8{gqW5!c|4+oV@de z&vuGT`k8d}yyhhegalUy&Ntm3VD8=Zc;Y}2S75~%N2TTS=@?1|DG3n@@fqQVmZvN! zX9XI`SIH9UuKQFhEOf#g`{ba{aWDJ#fv+UZr$$l}(Immw<)RJ+9QQs8`?fdvO49Fl zi&A(}Q=gTZHc7&x?g8yuj6#uWYu|THzo$U~`xrWUl%I~T6=IWe+%O?K*qomhbM2Ts zv>-jBGO49#L_n+KaAN`ASb^(w1o0L=rhjOhfiL?Nif6M=PIzVm**kuAoDoHPLW#@X zUV2XCiF#D=Gy_r|luV}Y64Lu@Ipf#pN6j%q=40!?*r7}59#M{PIR&L-t;;Aej-}4& z;&08LAEaF8tk1jOBauZba;Fdh^jogsu9Vm=V8FTfV4~g)dYr!+MYXVDN-ZDDBg;$9 z4Y+_|+V@IN1@#^u6BX_(;wKPUKGdla&Geb>7<#_r&w7`#cCQ4ch98ma0LiLPkesI> z`^pYz^HyoX%@P@98i-$yiMp=nyR@+p@j~P?+udlf8#gEzQ6?Tgt#y~c@x}t#`Ao7& z!R6>rT;b11{PzPf9Xv_yIdF+6jt@zz7RYf8)~~I|vBh1tNY`VdLC)XIgUbU#A^A3p^5Mrt>i-zX!Q+16`e}4rx41vB3Sd7b30|{PRkKdw@VkD!*M>>_ zdJ0MBaF3Y?CG|DDY78a}{$vMODm&Q@Us_|z=`PBezPC(nq)REr>=K;}tG+rJHs{)s zD#rc5TNfkc%FTdp;^OF{Pgl;O-I(v*V69gTXcH%QHK)$>bNp0eF)wajlyn2SYIT>m_YlsP9a12*>w{FI;c0 z^b=vxHkFNH@py1pq>=zN8^Qe;ThSp1D#Lm&(l7$}@O*Wrv!5lqjPT-YQsd3fjj0qU zeN6hm1IY>or^p*i`0_ko3@avhj;LR95}*x+s8Z-%d?)li{i~(J0DCi6l17wHY`R{Y zxt6?*Kz|jm5O1nwTWVUK`@~fV>@mUG!Fs_))+WoP$%M(CF9H`Xt@SlhTy)Hd?mP8i z4Aj9)r1qOX0Lb0SBH>qI+UX{0TgTCH9Kczj?>J|tVA%|GB^ejW37cBBj zE=>|#Ihz$teU0GQI;;Ghj(Qja6H=!Mx4#t4guC5N+|ScLATXWq`BXytmEe2DbvG=? zeS+0*EX1<*o;u<@Pr18}%`r=HJ96>OPNF{bHBQ0Cjdo`)^AvI+mT9-OmG%g;>to(7 z#SpiQr0*i-m29E{kt;;b3JnEp1P^jM7==!YEk!1l<@`3&b;Q>io4((ZhE{)erkRVz z37Wdh*?E%AY;k?@-tZfe@YMGE+%~e1@+qc#2EoZ1r0dyc*b#J>MlsP2fAp&xyXXo> z9mxw{Tl}q?KE#|hbKmCPLLr4+4W;kZcK!}6SeNT~7r3|>gE;Jy+6LwzY; z{4HKdn_D-x@7>E{S4=$eEt}*`9!41;I&^U|KVcj%-VT9@7iaPxy><#LD{7A#=Km_I z_ah3^LF&iHVISO(Zco4cw`J`s)%UtSDJ!!MH!rSL zIEHp5&W3Fs*{!Nfy)Y~a5-rl=5{2kY%utj-XqSp3a6i7RuaMz+&>Xx8VQ1qzg zdaGBEM$#q<08YuCv{S2(B4!`H7DEu=d6TZtL#a^anZWZfj1Nli*zu&u1gJHxU$$5$oIZ->Y)-$$};zoS6bp=kf-^)AHt~`uqk{n{FOzB8o|BPTG z^LXQXeElB!5yXE<_YkT;UqSW&kG?ERby(oTH6UO=vuWwy>vzs&gzGs>?Lo_R=&NzNAO6Y3gDwB-t1zm{W|5R4oq)87WdT|%CzprJgmul zyl+W?_s=OUL;~shXxf_&l{ZC~V;UL=hZ74$D~m9kluj;>25p?uxilpvr^1^nwZDp} z(I=N)NJ@~}u-AX*Xywx6n!w!dm3S%DOa8}0po*!Jco#Y zPdT8Pb;t{Fi=w&f5N8|Z-FZB@1~w;mhD1xbCmDuFvT$#r4o8Tm^Z95ripB1PiBZQ+ z-*sTlh;`GBc$v7x!5XU@D3zL%j;R}`A~eARtn;4MiOtI;O6(omOtg$uBoFM~b}ebh z#1D35cAs0Z&rzlDH`a~Wke{3LIwN1aLpXJ(k@$PON#}{)O;%kjKaP6?I1i4~!f)HS z!_8O4epYMYM)k^rWL4r=TK~?g?F7k|J4zoH(-UIoA&8$LS;YDco<@D2;BoJ?Uu=;J0ER*KVh-je8ki;NpMyR$3 zlYPCt zxKnLIXs9m`Bnj!x3|1QBVEvwh7)G>#wrKV8g-A%=pB6^X6yg!8lL#%og-r;kU35^6 z-cEjdMZQWYcpgW3(5^RJN;qSe-O)(qcY(_lOf-xH`gof+2axe*`bLXp+Rb-0ARZ`> zx{`^`q^0Ia@$FLG^WJ@U2+(OT`el>bFEwrqq+zNlSNF7vJfI|cGgD1qR`T$`*w%~T zLVPZX^@)p_3g<*nV{-n<)bs`?+xOZ8X6f#I6!_EiO7!2m0yPrx|uUlcq z)GCrV&(i9Fxz$@GW2C^OZ)I$+6fI3D1k3>&ASI}N*J^T}0zro4g0$j6Ed!PIOhnSH z-mgU>!gvQoFExk=f3uc7hUkSk1gX(#7r-Lr2Tcn1kP(f~z>7Sjm}reqnHCO5_O_Gg za->9!iKG3rWXl(k6b)q-{BFQ>va%DLdD}|LXm5t#nkx>$>a5s%7H??GEE-biba8GJff3&g8U{!#P?Y%j~ysf`bO zv)9Q_-FW=q%63W*$|%pQ(c>ip*UXDL=LJxCXW=ykLmDgAuCIoxI!b%rT0v%1KPe*M zH-OV;YOzyz22=HZ$05P&+@OlBa>cSJwnua27(yJ}7(29v4QD#}(^U>&A3*zOLL-@P=L+?xgv&Ib@FM zgxc;PBEyULwnsOl+AiJvz9%rV%ti`lI=vH6b4~ie`(2){GpXE$VaexIIBGDbDkY|i zA&+dd|4~FCuX(rLnMxOjo+N>Bs0_l`io%_GfQH{Z9$sfu&zL^X1=Mr@RQ1n(ixBbb zJiz}x+_caF)LzyYKChhxkSD2*-sF4w1^umm&CBP(F?7E}D>(0-Qq%@yYfGdSku3hW z+?}Ri4-Ha-w|42ag<;JfUsx&tW2W?mQ~2DvNWEQV ze*{3TiP_4k2^O`Ooz*NuJaDV9f-$+W&seLZVWLDCfxot`XjUdFOtYz)dYT*;ccweg z*$QlH_*36wFp}GA*PE+&+Y%#);_-9XAA>1_i>=EN6(@z1=gtQ+5>F#OAt%$4GcV^c z!D3)x*aDdMV&_fXSlYa1%en(>ko?x#>u)Sh6{jYnR#^F3}r8RmXd2Lw0=F40JggeuZV2{l7 zlgEX<>HGOwUtY$D=9HU6z3@CDJ(socJ7o7=7lzI1*yt9R!6UGLFvqs2bW>2eYXL-_ zgU=wHt4vMdG7Pq%emd=qe@4gFjAZJKUypCDu}&YzrM`=~)*k^us<=32F$$*=L`;uz zLU0NFMd@06&dSy6YjUv;bvOb^+N_uAek`IiDpu=xBYR1zgJ5~M#2(=OpaleY{II3| z7&p6}>Qr8^G2@$r6v$$QtW+k*Q~jD75GhhRac89z-YR568T>#LsnQYziJnl3cf4pS zn&`d2W|Pz&-(jE-Pi~L9ySvA+0e8er{I$!fVdGbK(?BwArXg1(n_{W+rkD^A{pdDy zil!jOz_dSO{s*{!k&_UxB;+Ez_&Pk#wYno?i1GSTJs)sPpyQ@4rv;xrb58W7WuJdO zx#%vU2Xw_G&)?r!O`MyvZl>~aP)ytzM5v95bb(m(XPSXBss6)%DU(USEj(;}MA2{B zXU9{&>)JHhpk?h%j`bxS9m}zMl|;L;lh%0C>~m1Sl3~b=xAl3UwALT*oD$*xoBc;v z3gZq)I?TZY^5^UKz_yF4016Yu#>yM3Xbd@OLA=AZ{1p8Xwq8bv6d)`>h=1{`I4^er zE1Czmmy_9o>g{XGQ=OT`ft!OE=7*iw)ari4v}_Rsy@es0gJ2fuAF~Ul=c0>6c_OjI zM53-Vl5f^t%qe&ZUc9>ia94U4hv*1UygOH$-lItbfVb;l+O~r9yqq3$Fo`w?PxL7| zxtND3DgP zH6rx|kzYI2`s_1s6|nG4TFkAfCaNr>EZhLP&em|CqtfB8tV?d3)c)PQIl58rFy43hk|LfVpUMW0C!(RPjHG_{ydMj1?8MX zl-UHhtYInGbAIKS7U8V)*g)ICojrT(J0x!ULzO!7)u=@AGH(F)JXAD-$d%I1oIjoP ziNu{n&&7H?J7;&V%)BmcHr6SYG0tEL-~>dR5trEUUMh@k?6fwVOVCGM*)DT-auS2K zs7aLmIDE}=;@O0|jU=lT>g4WO)jAR39Bvlr)6wkrz6nkF(wMSael@(?RS{NO+1jRf zj+JuucB-^Cxo2Zl8E8h?;|yx2)pGgKuP^<_3?UGHM{x3*aU!2j?AM5Bf$+2pr$#Pc zk*~eCudLECentS^&45#1HAjGGxrmra?XFaoz!ag82;RiT^d&jO3u@Oi5;dpx)GlI8 z#Ty4I5V@Ef(hnZ*Fc?`g#<{USZwP<-Yb(;a#R49?CCQuKM=*9)J>4 zD}2G|f=G^-$m5%#`DY|vnBX zBC=vwiCyfs-&)gWo6W<9Wu(CaA$>rM0r~TLa%SvBXgB*Eaa0I6b-jhy<6L97 z%t$e(ipL`=CFm3q$o-~xPI-#3P6zH#bucs#qwIKc9CK6UKKz7Kq7+IN4Pp;|Yhjt)McBbNZ zN@+&ZT?fn@bsJJsPq|d4YtlkrOUE%z%2FqA7T?A9`Gls0YmbBeU_!F@1d2aHD}l6% z$93M9Hlp#^uo*_K1$++ldfkioilMIJ33b?2@33cJlk?A=N7Gjl!qt3UvCNftbZ!0+ zq%Jm9$*!Mn_2H^QU7@Y42V+bf=6d~sVlpuv@c9n#2n#V3Y~yh4KUA}Vx= zwz>&3fwlbT5}sfd!O40#j(3`bztTNyg;yRc@CkdPq+Ik?3SB~H%rk4Mt=i~=m-v_? zj?9)g*B6LH3fNGpSrD%)-MO_fmi`FIz3!)Bsw|W$wS9Sf5#!FM z$rptK80Q*@H-u+(jXRBvZB>RGL?u*@(I+JMG?R>h`i#%)V1VzoQ6tjJ{%j%U5L-CL zFzFiJp`RczG7wSPZEpr<~0#iD9I0QgGFRk-Eh3oL|2FvvZB-rf5XS zTh)U$q-r`Yck(U^vW#!OyZg$eXk(_`x{cy#;A^fp3@yK z@Fo0}$gC6C^-uRVdHDG5n6A<$6@lT|)$xm|=+_?E$0&lSMc>ATbvJj*19#H?WGAim z%9-TlnFJO>(VU3IqpTdku8wY;m9v3JyN}BKQ0D1{p$EoI&?JUbOnV228EH8RCN!&f z)RTDQCQ6mG3kf@f{nG^=JH1Gki^xo3)bqm13(eoZuy-7!qzdVFMfL8ih4npO&ZR(0 zIX=mS2U&+}&!d0E9WA0Sn9pC{Zbg3*`3vBXUV?s$s=RWnHaj$!>W%?ErAbmWGSd}( z19NR?DumU%J*18Kr&z7^Hy-<13U+?)RlV`O=4b7NUP9(Mp6Qp(TQOX59Pm^ties)j zTsJ6Wja_4+Bdv(?HI382idUn78e=vNc^%$qpI8w_%|@@MX+5)(vUfK*lI(_fX6@S$ z?Ds9wy{ zYx-YbG4x2j_iH{M>UC;7-^Y^8+pxMP%w*~HAHL(17sRx<@Ny0i++1S*9d}r=Q8}aiX6Mu_czRw%O_cQff z;o4(r%h+Bi1aVU-#GJruarcc>SE6(d*p|G%`b(I%PW$4WrYY93OK!$GoaR>IsU*6M zjIc&B)^1Z^csi!&m250byK}cBiO%Bj{%_x&KSP(>jT>wmV<; z>i8psqW{j)v2Od(N;w|8`(WQS2n%i_2Ft1wEFnaB_%R{}ags;TrHlhz8 zWZAsIHaG=@FFFLyhK>N^1n;$rF>u{a*h*UB=U0658t=IVB}36v);5=gXrK zL&Z}khaNacoc&4FBS?T73B0D3#XE#o{`HFdE# zr&+KgQ}nw`_OBpQm=H~``8C3_5Y9orf+~8~@;k=GUx)qq=4~R}{&n_#!e6G}Ke2?6 z&#xJxUEa19PkH&v4Ef!U_ub%_R+ReV-w<*B>+T^_BMwt?07Vjx_3IA*=gI#stUcj9 zIEGosT0`+~kL!QnJ;4=O(OuIPGJJUJx229>uI>kd2_ClDiv9PX|K|kw+xzBdxY==v z8Snl+t^e_p|8EEA2i>Ouoq#g%yeb9iSnfz<-`17OxbBzEAXk-s5lu!}oV^F@|8R-B z?r6|#$o5bhiTv^DB^sQ$fG2Z$h z3&o=E(eG9UOr`7J^7Unym6XWuaxyKI7@6QK%^;7qSeSLo$|*cYJAFsjpN0k z1>3qELQ4~}g?y#G3~LBTdq+2C*Y^mrg53pe8K3EM4`{tI8u=Ocoo4AdbRo!r-7KS- zm>%QAhuq3HR>-{?an)JatO(t?)NxaVXGZDt+zSkbm+-V?e#xz&@$Vh#Z*v$~q#ot6 zT7a`y0|2n45ykyH!5JdnMJxNB?PnOWz_=`W*4u)YatE@fTPY#@D?Bwg+KD%2V_5Kq zGY+6O^3hM`p=o^YTJC^%{|E2aev>=n?jcT|XWHM$94k!Kt$jzln-B*SqP-6<)gbCf ze~f;-)6<`6p27ahD*XG8TOOYgWaGmC5rfKv8xVWc=DPrzB+=kbxJ%7-{Oz*qPz?lpmRpg7LV`e}j*@zI@$w(}>Dz*ZpoVoO22nE}Bc zZy&fU0@SpOpamr$y({N38{(O{Mn}OuKbz<{L$U)WM8BO+VCzW;DEN#yg5zxr<$~AC z3J6Cnt!F#7TpfU-TbsmLj!m?WbbpiRp9h~NXdqQFewEui&KW^`bO)7vZ4eaA^)$tAO?oN{dBUrh$b( z>CHO^q@Wy`S4+Y(|6#0VzF=Ih_Tu7IV;mTnm=4s(Poh_HZ< zDyKJhAUBsYmF}w2Sy=~N7~h9Gy4Dk)J=`dh48fA;a39WaR$0xMmf3B4dAQ$_q5a%N z;)xH52uWBXc^}~BC15DuD1qi6yAObPS{C@=_K(44qlc!`1G*u$fw@S7Lk%G79lL_} z54}*;=~db>dc?`N&Id<9FRVUM=6h>6>#4^bx@unE)C!Fzu|bk5RKkb)29tU?P zZUZ&l>`V_EFOA7jx^BK6hM?)l@Zuotn^bx#+Y_Y32K+Z@V-R0Zr3TFJTsyNO^VprE#9EiWl69{9zFPnRPQu?FF(}gPzT)=@oc5+?sZfGb=P3=DMu1 zOj`I#=wcrCV{JrW8O%dt)zGc}rGLxo=h%ImRp}fG(qMv~_6=Y`td>F6-^EIJ&q%w%B5Tsk6LlHQ#mH-ndF*ze z%viS53x-&5~%J1YLQVt5Db!RXBg?bKLZ1Uayhfmkmi|6 z1Ytf6v1S65iX~+mk9>_)kw__msgd+fmlF8zkK7xx)>igH=vzQG6`HQx6~D1y&2*#_ z7)9t*gS+~59TzU__0oC08J0TC+mv>8PctHb;o$p`&4*yvBi#)+w|bQl%@;)umNz_| z6~qb14Z5%!_hW+fcHSTPvsq#m+2~oWy!6Te_hkOmqCee47F_KT7 z=U!Gzx^ie1{eH&=v zhQ5}^!hR=tJqs8{qN}=ynUDPI1X^={lc7T6>)TWpsg7NNI^3zxfEDfBmdD<~L&N8- zHJ+H(>&`ye8Y=-OplTk0&#nTWHE>zl znTw4yQva13o7eg2c2NzbH5@kh_T@^@0}Z=|;H%FBJMP(%*>?rP6gSj@P0V;m5NF=PAw|O%47asW<8e62?(! z0his}H>w!EhMdU@k!zIuV;>)7&iU&_uuK!LVhhrvI$>U7SH|0kW{4r7WGdRppNozYELH?*;YtV%MnB zli%cDw-QbzT}~pdy*?iuT`(-_N*eOc;VW&V=+P&Rrq1Aj7KB0Kw@*%e*%mq|ksRQP z;oa`a3gweL`#k!TASB@=$r zY>i$4d}XG7TD%ePaLr@xuzndz%+2=Tn!3)NI;p8o@ed*sFOdgDU9D}r;tCx-(x~Y% z!N~P>0F^lun?{M+y^vm!;jCsD-F{J*!0cGzwbo1C1IL<)`+DBHu?BjSpHI`xR})&- zTy@=49G9RTVTRn{WbDGQ zrq^-1FqkDN+Gayau>)y#sMLMM&sDqJggOLiZ*c2KUZ&lwmUHUXhCKb?;oe4-aMR%& z@vLk#TSG39wVCE!#-lnFQBnDPFy3o5Oqw88Wo|yDD2<51M-V=Arf5Ahm^)zqD8a)4 zfy=BZr=@CSVi00Rk*BaZ*r*0QDlfNQUl!dJ1pE2J_Y0-os;6QY197=oNm-BK8>Bvt z$;I(>8q#Ys9e|oaFz+J2-zl7U!rGS&Bh2~6KO@^664?z{+2oVAXrEB@Qq*BmyR0A_ zM8v#**I2NP%8Sp!dxDq!f|yhNl7;`7+jZ+uJ=^RM&DzM3Y6-mGydgt(I#$}#o;3Kh zR3pz2a6#G0qiU^S{&!e{coZkIzZ|271b~6Xwrr()|5^dkcTb^IkJ-Oc3OW6x{~t)@ z(ay8|KfM6{%t>}nHcNe}59d&tH!k`rtf5P01gnT(*&ZX}{B)&&kzhOY+oH+_6+{rP z1thU%2uTev7h)$hmT@iL<0%Issp}>$S{pFu!fqv3E+v!L0PfI`a>;&Z1-dRRUw29U40SoWcZ&#%nQcNNoTkR#m z#sHUD8F5zfnl|DC%NGlq8Nqli29BXvIuSQ#F+xP_J>kNa@Q zV#}Rod(8jH={+hje^Q#n;@@5kvwit%;?ZM`HgnjvLwAh(toC8qR^d@+}2FhHd(A&V)4&T=`fjP1fb5L%wkf+tkS}9cG%<5Rd0Xd$U*FP&-Ft} zE%qpMU#Nffln1}m0IH9h>0#TEj@vGbIcg^l_O4D9Kfe2|w>OXCtUK1QFRrEje9DFt z;2g05Xs)Id z1#4p9c}q?rQZULEI<|-OENb z={on`hi$>&S%^s1H&bi~SJ|?VChv-Tdk(RdFQ*H-5~44V(Rii8QwvQ6 z*MYTZs_~5cqk*SiG(HxkEGAjX4-^jm)EF2_-k z>nbg#%(r2IwoMWcSEqlK93rkaIQ}J3S~i;*p+&Gg-yHhun~KrXsgPH3=FVC>CViCL?56YXLNK6w%+A z1X;&(7%PQmPWI?{huy!Ka3(XiTt8WfM%DncC zWW>|5I-Ylu z9=FduFe9FFpyC1v-yBCi?se5P`S}`J+#jw$4XOZrYIRG7?+e7d_3bj+WP8#hK!egv ziJyZ}(W8zM3iT5xEA4b&9T1Fwhh`RynE*iv34fr6Y2(|AlT|4CDS;F`>&nb1HIzWEdM)ScB@mKy*)`!ENltCL6AOh40)zwH3q05C2zQ zqv!M0^<>(gdR*JD*_0u{*8hPP{k-fy2^!01CzlGV)h%ggCs2$OcBbv_5)q;13d zvh50+Zt(&lPlsf&_5f2NyAT!Yu%}+4320CH4HDrPPLnHUKDWuA=mv4tYLF}zjfMm^ z8r?&G_8Q_t6TE6B#($N~Hqe)v(!%?fqb&3G66MsRl-H?thM8k-vz-a#`UOq7qKYPS z=(y+A9l1QMte)mt|6)~1vuX{8c`5cA0Y#7WtY+(acHO9y*}ayHB}Lw(+bKR`52 zp*yf55PY`K9rBMy@>DW)5FwBS-6JP`)AEQnub$tCGR|UsqU4>a;#ZzWp_5wB-(men zJFBPc8EqTOV!A6Erh1wE!E0>;yISONF6&=|=a$CZPe%QBX&+$4f%LeV(oU=17q3{Y zkCvMK0R@Y>7O9n-6Svn7Ke}00J5VG)`ihNkxkO2>AS&!8yC^f7Nck&HHDfT})AtM0 zT@n;m?wJjOYruT`1Bd1f7?d8w+b2g&NGQr}=BFHdWE1Z2@^xT!oT?mme7*|X z`wBq8RtRHOJX(a?VXX8dd2p6QzjAx7lSY?eny1nEdTg(qP=1jdB<^v5phboi=g|IZ zR@k8(`rtjuu{2n?S4Q((gY#A5U4Tc;sdOUMEJvGfC_P{%NU} zeej4VNBC3{h9!WD39 zk-#trq+y`mK7r8osVB9XKrAb&xc#}DD63*qGCBR3GcL`kTHTD--ufglOx

beRx@ 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®eAFlh28D~2yrpVrFf9sY1YUg* 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 u*WmA*+2Ewv-My!&VC)G Need help with the tutorial? [Join our Discord server](https://discord.gg/spacetimedb)! +> A completed version of the game we'll create in this tutorial is available at: +> +> https://github.com/ClockworkLabs/Blackholio + ## Prepare Project Structure -This project is separated into two sub-projects; +This project is separated into two subdirectories; 1. Server (module) code 2. Client code @@ -14,8 +18,8 @@ This project is separated into two sub-projects; First, we'll create a project root directory (you can choose the name): ```bash -mkdir SpacetimeDBUnityTutorial -cd SpacetimeDBUnityTutorial +mkdir blackholio +cd blackholio ``` We'll start by populating the client directory. @@ -28,96 +32,57 @@ In this section, we will guide you through the process of setting up a Unity Pro Open Unity and create a new project by selecting "New" from the Unity Hub or going to **File -> New Project**. -![UnityHub-NewProject](/images/unity-tutorial/UnityHub-NewProject.JPG) + -**⚠️ Important: Ensure `3D (URP)` is selected** to properly render the materials in the scene! +**⚠️ Important: Choose the `Universal 2D`** template to select a template which uses the Unity Universal Render Pipeline. -For Project Name use `client`. For Project Location make sure that you use your `SpacetimeDBUnityTutorial` directory. This is the directory that we created in a previous step. +For `Project Name` use `client`. For Project Location make sure that you use your `blackholio` directory. This is the directory that we created in a previous step. -![UnityHub-3DURP](/images/unity-tutorial/UnityHub-3DURP.JPG) + Click "Create" to generate the blank project. -### Step 2: Adding Required Packages - -To work with SpacetimeDB and ensure compatibility, we need to add some essential packages to our Unity project. Follow these steps: - -1. Open the Unity Package Manager by going to **Window -> Package Manager**. -2. In the Package Manager window, select the "Unity Registry" tab to view unity packages. -3. Search for and install the following package: - - **Input System**: Enables the use of Unity's new Input system used by this project. - -![PackageManager-InputSystem](/images/unity-tutorial/PackageManager-InputSystem.JPG) - -4. You may need to restart the Unity Editor to switch to the new Input system. - -![PackageManager-Restart](/images/unity-tutorial/PackageManager-Restart.JPG) - -### Step 3: Importing the Tutorial Package - -In this step, we will import the provided Unity tutorial package that contains the basic single-player game setup. Follow these instructions: - -1. Download the tutorial package from the releases page on GitHub: [https://github.com/clockworklabs/SpacetimeDBUnityTutorial/releases/latest](https://github.com/clockworklabs/SpacetimeDBUnityTutorial/releases/latest) -2. In Unity, go to **Assets -> Import Package -> Custom Package**. - -![Unity-ImportCustomPackageB](/images/unity-tutorial/Unity-ImportCustomPackageB.JPG) - -3. Browse and select the downloaded tutorial package file. -4. Unity will prompt you with an import settings dialog. Ensure that all the files are selected and click "Import" to import the package into your project. -5. At this point in the project, you shouldn't have any errors. - -![Unity-ImportCustomPackage2](/images/unity-tutorial/Unity-ImportCustomPackage2.JPG) +### Import the SpacetimeDB Unity SDK -### Step 4: Running the Project +Add the SpacetimeDB Unity Package using the Package Manager. Open the Package Manager window by clicking on Window -> Package Manager. Click on the + button in the top left corner of the window and select "Add package from git URL". Enter the following URL and click Add. -Now that we have everything set up, let's run the project and see it in action: - -1. Open the scene named "Main" in the Scenes folder provided in the project hierarchy by double-clicking it. - -![Unity-OpenSceneMain](/images/unity-tutorial/Unity-OpenSceneMain.JPG) - -**NOTE:** When you open the scene you may get a message saying you need to import TMP Essentials. When it appears, click the "Import TMP Essentials" button. - -🧹 Clear any false-positive TMPro errors that may show. - -![Unity Import TMP Essentials](/images/unity-tutorial/Unity-ImportTMPEssentials.JPG) - -2. Press the **Play** button located at the top of the Unity Editor. - -![Unity-Play](/images/unity-tutorial/Unity-Play.JPG) +```bash +https://github.com/clockworklabs/com.clockworklabs.spacetimedbsdk.git +``` -3. Enter any name and click "Continue" +The SpacetimeDB Unity SDK provides helpful tools for integrating SpacetimeDB into Unity, including a network manager which will synchronize your Unity client's state with your SpacetimeDB database in accordance with your subscription queries. -4. You should see a character loaded in the scene, and you can use the keyboard or mouse controls to move the character around. +### Create the GameManager Script -Congratulations! You have successfully set up the basic single-player game project. In the next section, we will start integrating SpacetimeDB functionality to enable multiplayer features. +1. In the Unity **Project** window, go to the folder where you want to keep your scripts (e.g., `Scripts` folder). +2. **Right-click** in the folder, then select `Create > C# Script` or in Unity 6 `MonoBehavior Script`. +3. Name the script `GameManager`. -## Writing our SpacetimeDB Server Module +The `GameManager` script will be where we will put the high level initialization and coordination logic for our game. -At this point you should have the single player game working. In your CLI, your current working directory should be within your `SpacetimeDBUnityTutorial` directory that we created in a previous step. +### Add the GameManager to the Scene -### Create the Module +1. **Create an Empty GameObject**: + - Go to the top menu and select **GameObject > Create Empty**. + - Alternatively, right-click in the **Hierarchy** window and select **Create Empty**. -1. It is important that you already have the SpacetimeDB CLI tool [installed](/install). +2. **Rename the GameObject**: + - In the **Inspector**, click on the GameObject’s name at the top and rename it to `GameManager`. -2. Run SpacetimeDB locally using the installed CLI. In a **new** terminal or command window, run the following command: +3. **Attach the GameManager Script**: + - Drag and drop the `GameManager` script from the **Project** window onto the `GameManager` GameObject in the **Hierarchy** window. + - Alternatively, in the **Inspector**, click **Add Component**, search for `GameManager`, and select it. -```bash -spacetime start -``` +### Add the SpacetimeDB Network Manager -💡 Standalone mode will run in the foreground. -💡 Below examples Rust language, [but you may also use C#](../modules/c-sharp). +The `SpacetimeDBNetworkManager` is a simple script which hooks into the Unity `Update` loop in order to drive the sending and processing of messages between your client and SpacetimeDB. You don't have to interact with this script, but it must be present on a single GameObject which is in the scene in order for it to facilitate the processing of messages. -### The Entity Component Systems (ECS) +When you build a new connection to SpacetimeDB, that connection will be added to and managed by the `SpacetimeDBNetworkManager` automatically. -Before we continue to creating the server module, it's important to understand the basics of the ECS. This is a game development architecture that separates game objects into components for better flexibility and performance. You can read more about the ECS design pattern [here](https://en.wikipedia.org/wiki/Entity_component_system). +Click on the `GameManager` object in the scene and click **Add Component**. Search for and select the `SpacetimeDBNetworkManager` to add it to your `GameManager` object. -We chose ECS for this example project because it promotes scalability, modularity, and efficient data management, making it ideal for building multiplayer games with SpacetimeDB. +Our Unity project is all set up! If you press play, it will show a blank screen, but it should start the game without any errors. Now we're ready to get started on our SpacetimeDB server module, so we have something to connect to! ### Create the Server Module -From here, the tutorial continues with your favorite server module language of choice: - -- [Rust](part-2a-rust) -- [C#](part-2b-c-sharp) +We've now got the very basics set up. In [part 2](part-2) you'll learn the basics of how how to create a SpacetimeDB server module and how to connect to it from your client. \ No newline at end of file diff --git a/docs/unity/part-2.md b/docs/unity/part-2.md new file mode 100644 index 00000000..ab82604e --- /dev/null +++ b/docs/unity/part-2.md @@ -0,0 +1,414 @@ +# Unity Tutorial - Part 2 - Connecting to SpacetimeDB + +Need help with the tutorial? [Join our Discord server](https://discord.gg/spacetimedb)! + +This progressive tutorial is continued from [part 1](/docs/unity/part-1). + +## Create a Server Module + +Run the following command to initialize the SpacetimeDB server module project with Rust as the language: + +```bash +spacetime init --lang=rust rust-server +``` + +This command creates a new folder named "rust-server" within your Unity project directory and sets up the SpacetimeDB server project with Rust as the programming language. + +### SpacetimeDB Tables + +In this section we'll be making some edits to the file `server/src/lib.rs`. We recommend you open up this file in an IDE like VSCode or RustRover. + +**Important: Open the `server/src/lib.rs` file and delete its contents. We will be writing it from scratch here.** + +First we need to add some imports at the top of the file. Some will remain unused for now. + +**Copy and paste into lib.rs:** + +```rust +use std::time::Duration; +use spacetimedb::{rand::Rng, Identity, SpacetimeType, ReducerContext, ScheduleAt, Table, Timestamp}; +``` + +We are going to start by defining a SpacetimeDB *table*. A *table* in SpacetimeDB is a relational database table which stores rows, similar to something you might find in SQL. SpacetimeDB tables differ from normal relational database tables in that they are stored fully in memory, are blazing fast to access, and are defined in your module code, rather than in SQL. + +Each row in a SpacetimeDB table is associated with a `struct` type in Rust. + +Let's start by defining the `Config` table. This is a simple table which will store some metadata about our game's state. Add the following code to `lib.rs`. + +```rust +// We're using this table as a singleton, so in this table +// there only be one element where the `id` is 0. +#[spacetimedb::table(name = config, public)] +pub struct Config { + #[primary_key] + pub id: u32, + pub world_size: u64, +} +``` + +Let's break down this code. This defines a normal Rust `struct` with two fields: `id` and `world_size`. We have decorated the struct with the `spacetimedb::table` macro. This procedural Rust macro signals to SpacetimeDB that it should create a new SpacetimeDB table with the row type defined by the `Config` type's fields. + +> NOTE: It is possible to have two different tables with different table names share the same type. + +The `spacetimedb::table` macro takes two parameters, a `name` which is the name of the table and what you will use to query the table in SQL, and a `public` visibility modifier which ensures that the rows of this table are visible to everyone. + +The `#[primary_key]` attribute, specifies that the `id` field should be used as the primary key of the table. + +> NOTE: The primary key of a row defines the "identity" of the row. A change to a row which doesn't modify the primary key is considered an update, but if you change the primary key, then you have deleted the old row and inserted a new one. + +You can learn more the `table` macro in our [Rust module reference](/docs/modules/rust). + +### Creating Entities + +Next, we're going to define a new `SpacetimeType` called `DbVector2` which we're going to use to store positions. The difference between a `#[derive(SpacetimeType)]` and a `#[spacetimedb(table)]` is that tables actually store data, whereas the deriving `SpacetimeType` just allows you to create a new column of that type in a SpacetimeDB table. Therefore, `DbVector2` is only a type, and does not define a table. + +**Append to the bottom of lib.rs:** + +```rust +// This allows us to store 2D points in tables. +#[derive(SpacetimeType, Clone, Debug)] +pub struct DbVector2 { + pub x: f32, + pub y: f32, +} +``` + +Let's create a few tables to represent entities in our game. + +```rust +#[spacetimedb::table(name = entity, public)] +#[derive(Debug, Clone)] +pub struct Entity { + // The `auto_inc` attribute indicates to SpacetimeDB that + // this value should be determined by SpacetimeDB on insert. + #[auto_inc] + #[primary_key] + pub entity_id: u32, + pub position: DbVector2, + pub mass: u32, +} + +#[spacetimedb::table(name = circle, public)] +pub struct Circle { + #[primary_key] + pub entity_id: u32, + #[index(btree)] + pub player_id: u32, + pub direction: DbVector2, + pub speed: f32, + pub last_split_time: Timestamp, +} + +#[spacetimedb::table(name = food, public)] +pub struct Food { + #[primary_key] + pub entity_id: u32, +} +``` + +The first table we defined is the `entity` table. An entity represents an object in our game world. We have decided, for convenience, that all entities in our game should share some common fields, namely `position` and `mass`. + +We can create different types of entities with additional data by creating a new tables with additional fields that have an `entity_id` which references a row in the `entity` table. + +We've created two types of entities in our game world: `Food`s and `Circle`s. `Food` does not have any additional fields beyond the attributes in the `entity` table, so the `food` table simply represents the set of `entity_id`s that we want to recognize as food. + +The `Circle` table, however, represents an entity that is controlled by a player. We've added a few additional fields to a `Circle` like `player_id` so that we know which player that circle belongs to. + +### Representing Players + +Next, let's create a table to store our player data. + +```rust +#[spacetimedb::table(name = player, public)] +#[derive(Debug, Clone)] +pub struct Player { + #[primary_key] + identity: Identity, + #[unique] + #[auto_inc] + player_id: u32, + name: String, +} +``` + +There's a few new concepts we should touch on. First of all, we are using the `#[unique]` attribute on the `player_id` field. This attribute adds a constraint to the table that ensures that only one row in the player table has a particular `player_id`. + +We also have an `identity` field which uses the `Identity` type. The `Identity` type is a identifier that SpacetimeDB uses to uniquely assign and authenticate SpacetimeDB users. + +### Writing a Reducer + +Next, we write our very first reducer. A reducer is a module function which can be called by clients. Let's write a simple debug reducer to see how they work. + +```rust +#[spacetimedb::reducer] +pub fn debug(ctx: &ReducerContext) -> Result<(), String> { + log::debug!("This reducer was called by {}.", ctx.sender); + Ok(()) +} +``` + +This reducer doesn't update any tables, it just prints out the `Identity` of the client that called it. + +--- + +**SpacetimeDB Reducers** + +"Reducer" is a term coined by Clockwork Labs that refers to a function which when executed "reduces" a set of inserts and deletes into the database state. The term derives from functional programming and is closely related to [similarly named concepts](https://redux.js.org/tutorials/fundamentals/part-2-concepts-data-flow#reducers) in other frameworks like React Redux. Reducers can be called remotely using the CLI, client SDK or can be scheduled to be called at some future time from another reducer call. + +All reducers execute *transactionally* and *atomically*, meaning that from within the reducer it will appear as though all changes are being applied to the database immediately, however from the outside changes made in a reducer will only be applied to the database once the reducer completes successfully. If you return an error from a reducer or panic within a reducer, all changes made to the database will be rolled back, as if the function had never been called. If you're unfamiliar with atomic transactions, it may not be obvious yet just how useful and important this feature is, but once you build a somewhat complex application it will become clear just how invaluable this feature is. + +--- + +### Publishing the Module + +Now that we have some basic functionality, let's publish the module to SpacetimeDB and call our debug reducer. + +In a new terminal window, run a local version of SpacetimeDB with the command: + +```sh +spacetime start +``` + +This following log output indicates that SpacetimeDB is successfully running on your machine. + +``` +Starting SpacetimeDB listening on 127.0.0.1:3000 +``` + +Now that SpacetimeDB is running we can publish our module to the SpacetimeDB host. In a separate terminal window, navigate to the `blackholio/server-rust` directory and run `spacetime publish --server local blackholio`. This will publish our Blackholio server logic to SpacetimeDB. + +If the publish completed successfully, you will see something like the following in the logs: + +``` +Build finished successfully. +Uploading to local => http://127.0.0.1:3000 +Publishing module... +Created new database with name: blackholio, identity: c200d2c69b4524292b91822afac8ab016c15968ac993c28711f68c6bc40b89d5 +``` + +Next, use the `spacetime` command to call our newly defined `debug` reducer: + +```sh +spacetime call blackholio debug +``` + +If the call completed successfully, that command will have no output, but we can see the debug logs by running: + +```sh +spacetime logs blackholio +``` + +You should see something like the following output: + +```sh +2025-01-09T16:08:38.144299Z INFO: spacetimedb: Creating table `circle` +2025-01-09T16:08:38.144438Z INFO: spacetimedb: Creating table `config` +2025-01-09T16:08:38.144451Z INFO: spacetimedb: Creating table `entity` +2025-01-09T16:08:38.144470Z INFO: spacetimedb: Creating table `food` +2025-01-09T16:08:38.144479Z INFO: spacetimedb: Creating table `player` +2025-01-09T16:08:38.144841Z INFO: spacetimedb: Database initialized +2025-01-09T16:08:47.306823Z INFO: src/lib.rs:68: This reducer was called by c200e1a6494dbeeb0bbf49590b8778abf94fae4ea26faf9769c9a8d69a3ec348. +``` + +### Connecting our Client + +Next let's connect our client to our module. Let's start by modifying our `debug` reducer. Rename the reducer to be called `connect` and add `client_connected` in parentheses after `spacetimedb::reducer`. The end result should look like this: + +```rust +#[spacetimedb::reducer(client_connected)] +pub fn connect(ctx: &ReducerContext) -> Result<(), String> { + log::debug!("{} just connected.", ctx.sender); + Ok(()) +} +``` + +The `client_connected` argument to the `spacetimedb::reducer` macro indicates to SpacetimeDB that this is a special reducer. This reducer is only every called by SpacetimeDB itself when a client connects to your module. + +> SpacetimeDB gives you the ability to define custom reducers that automatically trigger when certain events occur. +> +> - `init` - Called the first time you publish your module and anytime you clear the database with `spacetime publish --delete-data`. +> - `client_connected` - Called when a user connects to the SpacetimeDB module. Their identity can be found in the `sender` value of the `ReducerContext`. +> - `client_disconnected` - Called when a user disconnects from the SpacetimeDB module. + + +Publish your module again by running: + +```sh +spacetime publish --server local blackholio +``` + +### Generating the Client + +The `spacetime` CLI has built in functionality to let us generate C# types that correspond to our tables, types, and reducers that we can use from our Unity client. + +Let's generate our types for our module. In the `blackholio/server-rust` directory run the following command: + +```sh +spacetime generate --lang csharp --out-dir ../client/Assets/autogen # you can call this anything, I have chosen `autogen` +``` + +This will generate a set of files in the `client/Assets/autogen` directory which contain the code generated types and reducer functions that are defined in your module, but usable on the client. + +```sh +ls ../client/Assets/autogen/*.cs +../client/Assets/autogen/Circle.cs ../client/Assets/autogen/DbVector2.cs ../client/Assets/autogen/Food.cs +../client/Assets/autogen/Config.cs ../client/Assets/autogen/Entity.cs ../client/Assets/autogen/Player.cs +``` + +This will also generate a file in the `client/Assets/autogen/_Globals` directory with a type aware `DbConnection` class. We will use this class to connect to your module from Unity. + +> IMPORTANT! At this point there will be an error in your Unity project. Due to a [known issue](https://docs.unity3d.com/6000.0/Documentation/Manual/csharp-compiler.html) with Unity and C# 9 you need to insert the following code into your Unity project. +> +> ```csharp +> namespace System.Runtime.CompilerServices +> { +> internal static class IsExternalInit { } +> } +> ``` +> +> Add this snippet to the bottom of your `GameManager.cs` file in your Unity project. This will hopefully be resolved in Unity soon. + +### Connecting to the Module + +At this point we can set up Unity to connect your Unity client to the server. Replace your imports at the top of the `GameManager.cs` file with: + +```cs +using System; +using System.Collections; +using System.Collections.Generic; +using SpacetimeDB; +using SpacetimeDB.Types; +using UnityEngine; +``` + +Replace the implementation of the `GameManager` class with the following. + +```cs +public class GameManager : MonoBehaviour +{ + const string SERVER_URL = "http://127.0.0.1:3000"; + const string MODULE_NAME = "blackholio"; + + public static event Action OnConnected; + public static event Action OnSubscriptionApplied; + + public float borderThickness = 2; + public Material borderMaterial; + + public static GameManager Instance { get; private set; } + public static Identity LocalIdentity { get; private set; } + public static DbConnection Conn { get; private set; } + + private void Start() + { + Instance = this; + Application.targetFrameRate = 60; + + // In order to build a connection to SpacetimeDB we need to register + // our callbacks and specify a SpacetimeDB server URI and module name. + var builder = DbConnection.Builder() + .OnConnect(HandleConnect) + .OnConnectError(HandleConnectError) + .OnDisconnect(HandleDisconnect) + .WithUri(SERVER_URL) + .WithModuleName(MODULE_NAME); + + // If the user has a SpacetimeDB auth token stored in the Unity PlayerPrefs, + // we can use it to authenticate the connection. + if (PlayerPrefs.HasKey(AuthToken.GetTokenKey())) + { + builder = builder.WithCredentials((default, AuthToken.Token)); + } + + // Building the connection will establish a connection to the SpacetimeDB + // server. + Conn = builder.Build(); + } + + // Called when we connect to SpacetimeDB and receive our client identity + void HandleConnect(DbConnection _conn, Identity identity, string token) + { + Debug.Log("Connected."); + AuthToken.SaveToken(token); + LocalIdentity = identity; + + OnConnected?.Invoke(); + + // Request all tables + Conn.SubscriptionBuilder() + .OnApplied(HandleSubscriptionApplied) + .Subscribe("SELECT * FROM *"); + } + + void HandleConnectError(Exception ex) + { + Debug.LogError($"Connection error: {ex}"); + } + + void HandleDisconnect(DbConnection _conn, Exception ex) + { + Debug.Log("Disconnected."); + if (ex != null) + { + Debug.LogException(ex); + } + } + + private void HandleSubscriptionApplied(EventContext ctx) + { + Debug.Log("Subscription applied!"); + OnSubscriptionApplied?.Invoke(); + } + + + public static bool IsConnected() + { + return Conn != null && Conn.IsActive; + } + + public void Disconnect() + { + Conn.Disconnect(); + Conn = null; + } +} +``` + +Here we configure the connection to the database, by passing it some callbacks in addition to providing the `SERVER_URI` and `MODULE_NAME` to the connection. When the client connects, the SpacetimeDB SDK will call the `HandleConnect` method, allowing us to start up the game. + +In our `HandleConnect` callback we building a subscription and are calling `Subscribe` and subscribing to all data in the database. This will cause SpacetimeDB to synchronize the state of all your tables with your Unity client's SpacetimeDB SDK's "client cache". You can also subscribe to specific tables using SQL syntax, e.g. `SELECT * FROM my_table`. Our [SQL documentation](/docs/sql) enumerates the operations that are accepted in our SQL syntax. + +--- + +**SDK Client Cache** + +The "SDK client cache" is a client-side view of the database defined by the supplied queries to the `Subscribe` function. SpacetimeDB ensures that the results of subscription queries are automatically updated and pushed to the client cache as they change which allows efficient access without unnecessary server queries. + +--- + +Now we're ready to connect the client and server. Press the play button in Unity. + +If all went well you should see the below output in your Unity logs. + +``` +SpacetimeDBClient: Connecting to ws://127.0.0.1:3000 blackholio +Connected. +Subscription applied! +``` + +Subscription applied indicates that the SpacetimeDB SDK has evaluated your subscription queries and synchronized your local cache with your database's tables. + +We can also see that the server has logged the connection as well. + +```sh +spacetime logs blackholio +... +2025-01-10T03:51:02.078700Z DEBUG: src/lib.rs:63: c200fb5be9524bfb8289c351516a1d9ea800f70a17a9a6937f11c0ed3854087d just connected. +``` + +### Next Steps + +You've learned how to setup a Unity project with the SpacetimeDB SDK, write a basic SpacetimeDB server module, and how to connect your Unity client to SpacetimeDB. That's pretty much all there is to the setup. You're now ready to start building the game. + +In the [next part](/docs/unity/part-3), we'll build out the functionality of the game and you'll learn how to access your table data and call reducers in Unity. + diff --git a/docs/unity/part-2a-rust.md b/docs/unity/part-2a-rust.md deleted file mode 100644 index 58523f57..00000000 --- a/docs/unity/part-2a-rust.md +++ /dev/null @@ -1,314 +0,0 @@ -# Unity Tutorial - Basic Multiplayer - Part 2a - Server Module (Rust) - -Need help with the tutorial? [Join our Discord server](https://discord.gg/spacetimedb)! - -This progressive tutorial is continued from the [Part 1 Tutorial](/docs/unity/part-1) - -## Create a Server Module - -Run the following command to initialize the SpacetimeDB server module project with Rust as the language: - -```bash -spacetime init --lang=rust server -``` - -This command creates a new folder named "server" within your Unity project directory and sets up the SpacetimeDB server project with Rust as the programming language. - -### SpacetimeDB Tables - -In this section we'll be making some edits to the file `server/src/lib.rs`. We recommend you open up this file in an IDE like VSCode or RustRover. - -**Important: Open the `server/src/lib.rs` file and delete its contents. We will be writing it from scratch here.** - -First we need to add some imports at the top of the file. - -**Copy and paste into lib.rs:** - -```rust -use spacetimedb::{spacetimedb, Identity, SpacetimeType, ReducerContext}; -use log; -``` - -Then we are going to start by adding the global `Config` table. Right now it only contains the "message of the day" but it can be extended to store other configuration variables. This also uses a couple of macros, like `#[spacetimedb(table)]` which you can learn more about in our [Rust module reference](/docs/modules/rust) (including making your tables `private`!). Simply put, this just tells SpacetimeDB to create a table which uses this struct as the schema for the table. - -**Append to the bottom of lib.rs:** - -```rust -// We're using this table as a singleton, so there should typically only be one element where the version is 0. -#[spacetimedb(table(public))] -#[derive(Clone)] -pub struct Config { - #[primarykey] - pub version: u32, - pub message_of_the_day: String, -} -``` - -Next, we're going to define a new `SpacetimeType` called `StdbVector3` which we're going to use to store positions. The difference between a `#[derive(SpacetimeType)]` and a `#[spacetimedb(table)]` is that tables actually store data, whereas the deriving `SpacetimeType` just allows you to create a new column of that type in a SpacetimeDB table. Therefore, `StdbVector3` is not, itself, a table. - -**Append to the bottom of lib.rs:** - -```rust -// This allows us to store 3D points in tables. -#[derive(SpacetimeType, Clone)] -pub struct StdbVector3 { - pub x: f32, - pub y: f32, - pub z: f32, -} -``` - -Now we're going to create a table which actually uses the `StdbVector3` that we just defined. The `EntityComponent` is associated with all entities in the world, including players. - -```rust -// This stores information related to all entities in our game. In this tutorial -// all entities must at least have an entity_id, a position, a direction and they -// must specify whether or not they are moving. -#[spacetimedb(table(public))] -#[derive(Clone)] -pub struct EntityComponent { - #[primarykey] - // The autoinc macro here just means every time we insert into this table - // we will receive a new row where this value will be increased by one. This - // allows us to easily get rows where `entity_id` is unique. - #[autoinc] - pub entity_id: u64, - pub position: StdbVector3, - pub direction: f32, - pub moving: bool, -} -``` - -Next, we will define the `PlayerComponent` table. The `PlayerComponent` table is used to store information related to players. Each player will have a row in this table, and will also have a row in the `EntityComponent` table with a matching `entity_id`. You'll see how this works later in the `create_player` reducer. - -**Append to the bottom of lib.rs:** - -```rust -// All players have this component and it associates an entity with the user's -// Identity. It also stores their username and whether or not they're logged in. -#[derive(Clone)] -#[spacetimedb(table(public))] -pub struct PlayerComponent { - // An entity_id that matches an entity_id in the `EntityComponent` table. - #[primarykey] - pub entity_id: u64, - - // The user's identity, which is unique to each player - #[unique] - pub owner_id: Identity, - pub username: String, - pub logged_in: bool, -} -``` - -Next, we write our very first reducer, `create_player`. From the client we will call this reducer when we create a new player: - -**Append to the bottom of lib.rs:** - -```rust -// This reducer is called when the user logs in for the first time and -// enters a username -#[spacetimedb(reducer)] -pub fn create_player(ctx: ReducerContext, username: String) -> Result<(), String> { - // Get the Identity of the client who called this reducer - let owner_id = ctx.sender; - - // Make sure we don't already have a player with this identity - if PlayerComponent::find_by_owner_id(&owner_id).is_some() { - log::info!("Player already exists"); - return Err("Player already exists".to_string()); - } - - // Create a new entity for this player and get a unique `entity_id`. - let entity_id = EntityComponent::insert(EntityComponent - { - entity_id: 0, - position: StdbVector3 { x: 0.0, y: 0.0, z: 0.0 }, - direction: 0.0, - moving: false, - }).expect("Failed to create a unique PlayerComponent.").entity_id; - - // The PlayerComponent uses the same entity_id and stores the identity of - // the owner, username, and whether or not they are logged in. - PlayerComponent::insert(PlayerComponent { - entity_id, - owner_id, - username: username.clone(), - logged_in: true, - }).expect("Failed to insert player component."); - - log::info!("Player created: {}({})", username, entity_id); - - Ok(()) -} -``` - ---- - -**SpacetimeDB Reducers** - -"Reducer" is a term coined by Clockwork Labs that refers to a function which when executed "reduces" into a list of inserts and deletes, which is then packed into a single database transaction. Reducers can be called remotely using the CLI, client SDK or can be scheduled to be called at some future time from another reducer call. - ---- - -SpacetimeDB gives you the ability to define custom reducers that automatically trigger when certain events occur. - -- `init` - Called the first time you publish your module and anytime you clear the database. We'll learn about publishing later. -- `connect` - Called when a user connects to the SpacetimeDB module. Their identity can be found in the `sender` value of the `ReducerContext`. -- `disconnect` - Called when a user disconnects from the SpacetimeDB module. - -Next, we are going to write a custom `Init` reducer that inserts the default message of the day into our `Config` table. - -**Append to the bottom of lib.rs:** - -```rust -// Called when the module is initially published -#[spacetimedb(init)] -pub fn init() { - Config::insert(Config { - version: 0, - message_of_the_day: "Hello, World!".to_string(), - }).expect("Failed to insert config."); -} -``` - -We use the `connect` and `disconnect` reducers to update the logged in state of the player. The `update_player_login_state` helper function looks up the `PlayerComponent` row using the user's identity and if it exists, it updates the `logged_in` variable and calls the auto-generated `update` function on `PlayerComponent` to update the row. - -**Append to the bottom of lib.rs:** - -```rust -// Called when the client connects, we update the logged_in state to true -#[spacetimedb(connect)] -pub fn client_connected(ctx: ReducerContext) { - update_player_login_state(ctx, true); -} -``` - -```rust -// Called when the client disconnects, we update the logged_in state to false -#[spacetimedb(disconnect)] -pub fn client_disconnected(ctx: ReducerContext) { - update_player_login_state(ctx, false); -} -``` - -```rust -// This helper function gets the PlayerComponent, sets the logged -// in variable and updates the PlayerComponent table row. -pub fn update_player_login_state(ctx: ReducerContext, logged_in: bool) { - if let Some(player) = PlayerComponent::find_by_owner_id(&ctx.sender) { - // We clone the PlayerComponent so we can edit it and pass it back. - let mut player = player.clone(); - player.logged_in = logged_in; - PlayerComponent::update_by_entity_id(&player.entity_id.clone(), player); - } -} -``` - -Our final reducer handles player movement. In `update_player_position` we look up the `PlayerComponent` using the user's Identity. If we don't find one, we return an error because the client should not be sending moves without calling `create_player` first. - -Using the `entity_id` in the `PlayerComponent` we retrieved, we can lookup the `EntityComponent` that stores the entity's locations in the world. We update the values passed in from the client and call the auto-generated `update` function. - -**Append to the bottom of lib.rs:** - -```rust -// Updates the position of a player. This is also called when the player stops moving. -#[spacetimedb(reducer)] -pub fn update_player_position( - ctx: ReducerContext, - position: StdbVector3, - direction: f32, - moving: bool, -) -> Result<(), String> { - // First, look up the player using the sender identity, then use that - // entity_id to retrieve and update the EntityComponent - if let Some(player) = PlayerComponent::find_by_owner_id(&ctx.sender) { - if let Some(mut entity) = EntityComponent::find_by_entity_id(&player.entity_id) { - entity.position = position; - entity.direction = direction; - entity.moving = moving; - EntityComponent::update_by_entity_id(&player.entity_id, entity); - return Ok(()); - } - } - - // If we can not find the PlayerComponent or EntityComponent for - // this player then something went wrong. - return Err("Player not found".to_string()); -} -``` - ---- - -**Server Validation** - -In a fully developed game, the server would typically perform server-side validation on player movements to ensure they comply with game boundaries, rules, and mechanics. This validation, which we omit for simplicity in this tutorial, is essential for maintaining game integrity, preventing cheating, and ensuring a fair gaming experience. Remember to incorporate appropriate server-side validation in your game's development to ensure a secure and fair gameplay environment. - ---- - -### Publishing a Module to SpacetimeDB - -Now that we've written the code for our server module and reached a clean checkpoint, we need to publish it to SpacetimeDB. This will create the database and call the init reducer. In your terminal or command window, run the following commands. - -```bash -cd server -spacetime publish -c unity-tutorial -``` - -### Finally, Add Chat Support - -The client project has a chat window, but so far, all it's used for is the message of the day. We are going to add the ability for players to send chat messages to each other. - -First lets add a new `ChatMessage` table to the SpacetimeDB module. Add the following code to `lib.rs`. - -**Append to the bottom of server/src/lib.rs:** - -```rust -#[spacetimedb(table(public))] -pub struct ChatMessage { - // The primary key for this table will be auto-incremented - #[primarykey] - #[autoinc] - pub message_id: u64, - - // The entity id of the player that sent the message - pub sender_id: u64, - // Message contents - pub text: String, -} -``` - -Now we need to add a reducer to handle inserting new chat messages. - -**Append to the bottom of server/src/lib.rs:** - -```rust -// Adds a chat entry to the ChatMessage table -#[spacetimedb(reducer)] -pub fn send_chat_message(ctx: ReducerContext, text: String) -> Result<(), String> { - if let Some(player) = PlayerComponent::find_by_owner_id(&ctx.sender) { - // Now that we have the player we can insert the chat message using the player entity id. - ChatMessage::insert(ChatMessage { - // this column auto-increments so we can set it to 0 - message_id: 0, - sender_id: player.entity_id, - text, - }) - .unwrap(); - - return Ok(()); - } - - Err("Player not found".into()) -} -``` - -## Wrapping Up - -Now that we added chat support, let's publish the latest module version to SpacetimeDB, assuming we're still in the `server` dir: - -```bash -spacetime publish -c unity-tutorial -``` - -From here, the [next tutorial](/docs/unity/part-3) continues with a Client (Unity) focus. diff --git a/docs/unity/part-2b-c-sharp.md b/docs/unity/part-2b-c-sharp.md deleted file mode 100644 index b1d50e8b..00000000 --- a/docs/unity/part-2b-c-sharp.md +++ /dev/null @@ -1,339 +0,0 @@ -# Unity Tutorial - Basic Multiplayer - Part 2a - Server Module (C#) - -Need help with the tutorial? [Join our Discord server](https://discord.gg/spacetimedb)! - -This progressive tutorial is continued from the [Part 1 Tutorial](/docs/unity/part-1) - -## Create a Server Module - -Run the following command to initialize the SpacetimeDB server module project with C# as the language: - -```bash -spacetime init --lang=csharp server -``` - -This command creates a new folder named "server" within your Unity project directory and sets up the SpacetimeDB server project with C# as the programming language. - -### SpacetimeDB Tables - -In this section we'll be making some edits to the file `server/src/lib.cs`. We recommend you open up this file in an IDE like VSCode. - -**Important: Open the `server/src/lib.cs` file and delete its contents. We will be writing it from scratch here.** - -First we need to add some imports at the top of the file. - -**Copy and paste into lib.cs:** - -```csharp -// using SpacetimeDB; // Uncomment to omit `SpacetimeDB` attribute prefixes -using SpacetimeDB.Module; -using static SpacetimeDB.Runtime; -``` - -Then we are going to start by adding the global `Config` table. Right now it only contains the "message of the day" but it can be extended to store other configuration variables. This also uses a couple of attributes, like `[SpacetimeDB.Table]` which you can learn more about in our [C# module reference](/docs/modules/c-sharp). Simply put, this just tells SpacetimeDB to create a table which uses this struct as the schema for the table. - -**Append to the bottom of lib.cs:** - -```csharp -/// We're using this table as a singleton, -/// so there should typically only be one element where the version is 0. -[SpacetimeDB.Table(Public = true)] -public partial class Config -{ - [SpacetimeDB.Column(ColumnAttrs.PrimaryKey)] - public uint Version; - public string? MessageOfTheDay; -} -``` - -Next, we're going to define a new `SpacetimeType` called `StdbVector3` which we're going to use to store positions. The difference between a `[SpacetimeDB.Type]` and a `[SpacetimeDB.Table]` is that tables actually store data, whereas the deriving `SpacetimeType` just allows you to create a new column of that type in a SpacetimeDB table. Therefore, `StdbVector3` is not, itself, a table. - -**Append to the bottom of lib.cs:** - -```csharp -/// This allows us to store 3D points in tables. -[SpacetimeDB.Type] -public partial class StdbVector3 -{ - public float X; - public float Y; - public float Z; -} -``` - -Now we're going to create a table which actually uses the `StdbVector3` that we just defined. The `EntityComponent` is associated with all entities in the world, including players. - -```csharp -/// This stores information related to all entities in our game. In this tutorial -/// all entities must at least have an entity_id, a position, a direction and they -/// must specify whether or not they are moving. -[SpacetimeDB.Table(Public = true)] -public partial class EntityComponent -{ - [SpacetimeDB.Column(ColumnAttrs.PrimaryKeyAuto)] - public ulong EntityId; - public StdbVector3 Position; - public float Direction; - public bool Moving; -} -``` - -Next, we will define the `PlayerComponent` table. The `PlayerComponent` table is used to store information related to players. Each player will have a row in this table, and will also have a row in the `EntityComponent` table with a matching `EntityId`. You'll see how this works later in the `CreatePlayer` reducer. - -**Append to the bottom of lib.cs:** - -```csharp -/// All players have this component and it associates an entity with the user's -/// Identity. It also stores their username and whether or not they're logged in. -[SpacetimeDB.Table(Public = true)] -public partial class PlayerComponent -{ - // An EntityId that matches an EntityId in the `EntityComponent` table. - [SpacetimeDB.Column(ColumnAttrs.PrimaryKey)] - public ulong EntityId; - - // The user's identity, which is unique to each player - [SpacetimeDB.Column(ColumnAttrs.Unique)] - public Identity Identity; - public string? Username; - public bool LoggedIn; -} -``` - -Next, we write our very first reducer, `CreatePlayer`. From the client we will call this reducer when we create a new player: - -**Append to the bottom of lib.cs:** - -```csharp -/// This reducer is called when the user logs in for the first time and -/// enters a username. -[SpacetimeDB.Reducer] -public static void CreatePlayer(ReducerContext ctx, string username) -{ - // Get the Identity of the client who called this reducer - Identity sender = ctx.Sender; - - PlayerComponent? existingPlayer = PlayerComponent.FindByIdentity(sender); - if (existingPlayer != null) - { - throw new InvalidOperationException($"Player already exists for identity: {sender}"); - } - - // Create a new entity for this player - try - { - new EntityComponent - { - // EntityId = 0, // 0 is the same as leaving null to get a new, unique Id - Position = new StdbVector3 { X = 0, Y = 0, Z = 0 }, - Direction = 0, - Moving = false, - }.Insert(); - } - catch - { - Log("Error: Failed to create a unique EntityComponent", LogLevel.Error); - throw; - } - - // The PlayerComponent uses the same entity_id and stores the identity of - // the owner, username, and whether or not they are logged in. - try - { - new PlayerComponent - { - // EntityId = 0, // 0 is the same as leaving null to get a new, unique Id - Identity = ctx.Sender, - Username = username, - LoggedIn = true, - }.Insert(); - } - catch - { - Log("Error: Failed to insert PlayerComponent", LogLevel.Error); - throw; - } - Log($"Player created: {username}"); -} -``` - ---- - -**SpacetimeDB Reducers** - -"Reducer" is a term coined by Clockwork Labs that refers to a function which when executed "reduces" into a list of inserts and deletes, which is then packed into a single database transaction. Reducers can be called remotely using the CLI, client SDK or can be scheduled to be called at some future time from another reducer call. - ---- - -SpacetimeDB gives you the ability to define custom reducers that automatically trigger when certain events occur. - -- `Init` - Called the first time you publish your module and anytime you clear the database. We'll learn about publishing later. -- `Connect` - Called when a user connects to the SpacetimeDB module. Their identity can be found in the `Sender` value of the `ReducerContext`. -- `Disconnect` - Called when a user disconnects from the SpacetimeDB module. - -Next, we are going to write a custom `Init` reducer that inserts the default message of the day into our `Config` table. - -**Append to the bottom of lib.cs:** - -```csharp -/// Called when the module is initially published -[SpacetimeDB.Reducer(ReducerKind.Init)] -public static void OnInit() -{ - try - { - new Config - { - Version = 0, - MessageOfTheDay = "Hello, World!", - }.Insert(); - } - catch - { - Log("Error: Failed to insert Config", LogLevel.Error); - throw; - } -} -``` - -We use the `Connect` and `Disconnect` reducers to update the logged in state of the player. The `UpdatePlayerLoginState` helper function we are about to define looks up the `PlayerComponent` row using the user's identity and if it exists, it updates the `LoggedIn` variable and calls the auto-generated `Update` function on `PlayerComponent` to update the row. - -**Append to the bottom of lib.cs:** - -```csharp -/// Called when the client connects, we update the LoggedIn state to true -[SpacetimeDB.Reducer(ReducerKind.Init)] -public static void ClientConnected(ReducerContext ctx) => - UpdatePlayerLoginState(ctx, loggedIn:true); -``` - -```csharp -/// Called when the client disconnects, we update the logged_in state to false -[SpacetimeDB.Reducer(ReducerKind.Disconnect)] -public static void ClientDisonnected(ReducerContext ctx) => - UpdatePlayerLoginState(ctx, loggedIn:false); -``` - -```csharp -/// This helper function gets the PlayerComponent, sets the LoggedIn -/// variable and updates the PlayerComponent table row. -private static void UpdatePlayerLoginState(ReducerContext ctx, bool loggedIn) -{ - PlayerComponent? player = PlayerComponent.FindByIdentity(ctx.Sender); - if (player is null) - { - throw new ArgumentException("Player not found"); - } - - player.LoggedIn = loggedIn; - PlayerComponent.UpdateByIdentity(ctx.Sender, player); -} -``` - -Our final reducer handles player movement. In `UpdatePlayerPosition` we look up the `PlayerComponent` using the user's Identity. If we don't find one, we return an error because the client should not be sending moves without calling `CreatePlayer` first. - -Using the `EntityId` in the `PlayerComponent` we retrieved, we can lookup the `EntityComponent` that stores the entity's locations in the world. We update the values passed in from the client and call the auto-generated `Update` function. - -**Append to the bottom of lib.cs:** - -```csharp -/// Updates the position of a player. This is also called when the player stops moving. -[SpacetimeDB.Reducer] -private static void UpdatePlayerPosition( - ReducerContext ctx, - StdbVector3 position, - float direction, - bool moving) -{ - // First, look up the player using the sender identity - PlayerComponent? player = PlayerComponent.FindByIdentity(ctx.Sender); - if (player is null) - { - throw new ArgumentException("Player not found"); - } - // Use the Player's EntityId to retrieve and update the EntityComponent - ulong playerEntityId = player.EntityId; - EntityComponent? entity = EntityComponent.FindByEntityId(playerEntityId); - if (entity is null) - { - throw new ArgumentException($"Player Entity '{playerEntityId}' not found"); - } - - entity.Position = position; - entity.Direction = direction; - entity.Moving = moving; - EntityComponent.UpdateByEntityId(playerEntityId, entity); -} -``` - ---- - -**Server Validation** - -In a fully developed game, the server would typically perform server-side validation on player movements to ensure they comply with game boundaries, rules, and mechanics. This validation, which we omit for simplicity in this tutorial, is essential for maintaining game integrity, preventing cheating, and ensuring a fair gaming experience. Remember to incorporate appropriate server-side validation in your game's development to ensure a secure and fair gameplay environment. - ---- - -### Finally, Add Chat Support - -The client project has a chat window, but so far, all it's used for is the message of the day. We are going to add the ability for players to send chat messages to each other. - -First lets add a new `ChatMessage` table to the SpacetimeDB module. Add the following code to `lib.cs`. - -**Append to the bottom of server/src/lib.cs:** - -```csharp -[SpacetimeDB.Table(Public = true)] -public partial class ChatMessage -{ - // The primary key for this table will be auto-incremented - [SpacetimeDB.Column(ColumnAttrs.PrimaryKeyAuto)] - - // The entity id of the player that sent the message - public ulong SenderId; - - // Message contents - public string? Text; -} -``` - -Now we need to add a reducer to handle inserting new chat messages. - -**Append to the bottom of server/src/lib.cs:** - -```csharp -/// Adds a chat entry to the ChatMessage table -[SpacetimeDB.Reducer] -public static void SendChatMessage(ReducerContext ctx, string text) -{ - // Get the player's entity id - PlayerComponent? player = PlayerComponent.FindByIdentity(ctx.Sender); - if (player is null) - { - throw new ArgumentException("Player not found"); - } - - - // Insert the chat message - new ChatMessage - { - SenderId = player.EntityId, - Text = text, - }.Insert(); -} -``` - -## Wrapping Up - -### Publishing a Module to SpacetimeDB - -💡View the [entire lib.cs file](https://gist.github.com/dylanh724/68067b4e843ea6e99fbd297fe1a87c49) - -Now that we've written the code for our server module and reached a clean checkpoint, we need to publish it to SpacetimeDB. This will create the database and call the init reducer. In your terminal or command window, run the following commands. - -```bash -cd server -spacetime publish -c unity-tutorial -``` - -From here, the [next tutorial](/docs/unity/part-3) continues with a Client (Unity) focus. diff --git a/docs/unity/part-3-player-on-screen.png b/docs/unity/part-3-player-on-screen.png new file mode 100644 index 0000000000000000000000000000000000000000..16c23dd0c6886e144a0974b683ec3a0acc2891be GIT binary patch literal 64962 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 J Result<(), String> { + log::info!("Initializing..."); + ctx.db.config().try_insert(Config { + id: 0, + world_size: 1000, + })?; + Ok(()) +} +``` -1. Add the SpacetimeDB Unity Package using the Package Manager. Open the Package Manager window by clicking on Window -> Package Manager. Click on the + button in the top left corner of the window and select "Add package from git URL". Enter the following URL and click Add. +This reducer also demonstrates how to insert new rows into a table. Here we are adding a single `Config` row to the `config` table with the `try_insert` function. `try_insert` returns an error if inserting the row into the table would violate any constraints, like unique constraints, on the table. You can also use `insert` which panics on constraint violations if you know for sure that you will not violate any constraints. -```bash -https://github.com/clockworklabs/com.clockworklabs.spacetimedbsdk.git -``` +Now that we've ensured that our database always has a valid `world_size` let's spawn some food into the map. Add the following code to the end of the file. -![Unity-PackageManager](/images/unity-tutorial/Unity-PackageManager.JPG) +```rust +const FOOD_MASS_MIN: u32 = 2; +const FOOD_MASS_MAX: u32 = 4; +const TARGET_FOOD_COUNT: usize = 600; -3. The next step is to generate the module specific client files using the SpacetimeDB CLI. The files created by this command provide an interface for retrieving values from the local client cache of the database and for registering for callbacks to events. In your terminal or command window, run the following commands. +fn mass_to_radius(mass: u32) -> f32 { + (mass as f32).sqrt() +} -```bash -mkdir -p ../client/Assets/module_bindings -spacetime generate --out-dir ../client/Assets/module_bindings --lang=csharp +#[spacetimedb::reducer] +pub fn spawn_food(ctx: &ReducerContext) -> Result<(), String> { + if ctx.db.player().count() == 0 { + // Are there no logged in players? Skip food spawn. + return Ok(()); + } + + let world_size = ctx + .db + .config() + .id() + .find(0) + .ok_or("Config not found")? + .world_size; + + let mut rng = ctx.rng(); + let mut food_count = ctx.db.food().count(); + while food_count < TARGET_FOOD_COUNT as u64 { + let food_mass = rng.gen_range(FOOD_MASS_MIN..FOOD_MASS_MAX); + let food_radius = mass_to_radius(food_mass); + let x = rng.gen_range(food_radius..world_size as f32 - food_radius); + let y = rng.gen_range(food_radius..world_size as f32 - food_radius); + let entity = ctx.db.entity().try_insert(Entity { + entity_id: 0, + position: DbVector2 { x, y }, + mass: food_mass, + })?; + ctx.db.food().try_insert(Food { + entity_id: entity.entity_id, + })?; + food_count += 1; + log::info!("Spawned food! {}", entity.entity_id); + } + + Ok(()) +} ``` -### Connect to Your SpacetimeDB Module +In this reducer, we are using the `world_size` we configured along with the `ReducerContext`'s random number generator `.rng()` function to place 600 food uniformly randomly throughout the map. We've also chosen the `mass` of the food to be a random number between 2 and 4 inclusive. -The Unity SpacetimeDB SDK relies on there being a `NetworkManager` somewhere in the scene. Click on the GameManager object in the scene, and in the inspector, add the `NetworkManager` component. +Although, we've written the reducer to spawn food, no food will actually be spawned until we call the function while players are logged in. This raises the question, who should call this function and when? -![Unity-AddNetworkManager](/images/unity-tutorial/Unity-AddNetworkManager.JPG) +We would like for this function to be called periodically to "top up" the amount of food on the map so that it never falls very far below our target amount of food. SpacetimeDB has built in functionality for exactly this. With SpacetimeDB you can schedule your module to call itself in the future or repeatedly with reducers. -Next we are going to connect to our SpacetimeDB module. Open `Assets/_Project/Game/BitcraftMiniGameManager.cs` in your editor of choice and add the following code at the top of the file: +In order to schedule a reducer to be called we have to create a new table which specifies when an how a reducer should be called. Add this new table to the top of the file. -**Append to the top of BitcraftMiniGameManager.cs** +```rust +#[spacetimedb::table(name = spawn_food_timer, scheduled(spawn_food))] +pub struct SpawnFoodTimer { + #[primary_key] + #[auto_inc] + scheduled_id: u64, + scheduled_at: spacetimedb::ScheduleAt, +} +``` -```csharp -using SpacetimeDB; -using SpacetimeDB.Types; -using System.Linq; +Note the `scheduled(spawn_food)` parameter in the table macro. This tells SpacetimeDB that the rows in this table specify a schedule for when the `spawn_food` reducer should be called. Each scheduled table requires a `scheduled_id` and a `scheduled_at` field so that SpacetimeDB can call your reducer, however you can also add your own fields to these rows as well. + +You can create, delete, or change a schedule by inserting, deleting, or updating rows in this table. + +You will see an error telling you that the `spawn_food` reducer needs to take two arguments, but currently only takes one. This is because the schedule row must be passed in to all scheduled reducers. Modify your `spawn_food` reducer to take the scheduled row as an argument. + +```rust +#[spacetimedb::reducer] +pub fn spawn_food(ctx: &ReducerContext, _timer: SpawnFoodTimer) -> Result<(), String> { + // ... +} ``` -At the top of the class definition add the following members: +In our case we aren't interested in the data on the row, so we name the argument `_timer`. + +Let's modify our `init` reducer to schedule our `spawn_food` reducer to be called every 500 milliseconds. + +```rust +#[spacetimedb::reducer(init)] +pub fn init(ctx: &ReducerContext) -> Result<(), String> { + log::info!("Initializing..."); + ctx.db.config().try_insert(Config { + id: 0, + world_size: 1000, + })?; + ctx.db.spawn_food_timer().try_insert(SpawnFoodTimer { + scheduled_id: 0, + scheduled_at: ScheduleAt::Interval(Duration::from_millis(500).as_micros() as u64), + })?; + Ok(()) +} +``` -**Append to the top of BitcraftMiniGameManager class inside of BitcraftMiniGameManager.cs** +> You can use `ScheduleAt::Interval` to schedule a reducer call at an interval like we're doing here. SpacetimeDB will continue to call the reducer at this interval until you remove the row. You can also use `ScheduleAt::Time()` to specify a specific at which to call a reducer once. SpacetimeDB will remove that row automatically after the reducer has been called. -```csharp -// These are connection variables that are exposed on the GameManager -// inspector. -[SerializeField] private string moduleAddress = "unity-tutorial"; -[SerializeField] private string hostName = "localhost:3000"; +### Logging Players In -// This is the identity for this player that is automatically generated -// the first time you log in. We set this variable when the -// onIdentityReceived callback is triggered by the SDK after connecting -private Identity local_identity; +Let's continue building out our server module by modifying it to log in a player when they connect to the database, or to create a new player if they've never connected before. + +Let's add a second table to our `Player` struct. Modify the `Player` struct by adding this above the struct: + +```rust +#[spacetimedb::table(name = logged_out_player)] ``` -The first three fields will appear in your Inspector so you can update your connection details without editing the code. The `moduleAddress` should be set to the domain you used in the publish command. You should not need to change `hostName` if you are using SpacetimeDB locally. +Your struct should now look like this: + +```rust +#[spacetimedb::table(name = player, public)] +#[spacetimedb::table(name = logged_out_player)] +#[derive(Debug, Clone)] +pub struct Player { + #[primary_key] + identity: Identity, + #[unique] + #[auto_inc] + player_id: u32, + name: String, +} +``` -Now add the following code to the `Start()` function. For clarity, replace your entire `Start()` function with the function below. +This line creates an additional tabled called `logged_out_player` whose rows share the same `Player` type as in the `player` table. + +> IMPORTANT! Note that this new table is not marked `public`. This means that it can only be accessed by the database owner (which is almost always the database creator). In order to prevent any unintended data access, all SpacetimeDB tables are private by default. +> +> If your client isn't syncing rows from the server, check that your table is not accidentally marked private. + +Next, modify your `connect` reducer and add a new `disconnect` reducer below it: + +```rust +#[spacetimedb::reducer(client_connected)] +pub fn connect(ctx: &ReducerContext) -> Result<(), String> { + if let Some(player) = ctx.db.logged_out_player().identity().find(&ctx.sender) { + ctx.db.player().insert(player.clone()); + ctx.db.logged_out_player().delete(player); + } else { + ctx.db.player().try_insert(Player { + identity: ctx.sender, + player_id: 0, + name: String::new(), + })?; + } + Ok(()) +} -**REPLACE the Start() function in BitcraftMiniGameManager.cs** +#[spacetimedb::reducer(client_disconnected)] +pub fn disconnect(ctx: &ReducerContext) -> Result<(), String> { + let player = ctx + .db + .player() + .identity() + .find(&ctx.sender) + .ok_or("Player not found")?; + ctx.db.logged_out_player().insert(player); + ctx.db.player().identity().delete(&ctx.sender); + Ok(()) +} +``` -```csharp -// Start is called before the first frame update -void Start() -{ - instance = this; +Now when a client connects, if the player corresponding to the client is in the `logged_out_player` table, we will move them into the `player` table, thus indicating that they are logged in and connected. For any new unrecognized client connects we will create a `Player` and insert it into the `player` table. - Application.runInBackground = true; +When a player disconnects, we will transfer their player row from the `player` table to the `logged_out_player` table to indicate they're offline. - SpacetimeDBClient.instance.onConnect += () => - { - Debug.Log("Connected."); +> Note that we could have added a `logged_in` boolean to the `Player` type to indicated whether the player is logged in. There's nothing incorrect about that approach, however for several reasons we recommend this two table approach: +> - We can iterate over all logged in players without any `if` statements or branching +> - The `Player` type now uses less program memory improving cache efficiency +> - We can easily check whether a player is logged in, based on whether their row exists in the `player` table +> +> This approach is more generally referred to as [existence based processing](https://www.dataorienteddesign.com/dodmain/node4.html) and it is a common technique in data-oriented design. - // Request all tables - SpacetimeDBClient.instance.Subscribe(new List() - { - "SELECT * FROM *", - }); - }; +### Spawning Player Circles - // Called when we have an error connecting to SpacetimeDB - SpacetimeDBClient.instance.onConnectError += (error, message) => - { - Debug.LogError($"Connection error: {error} - {message}"); - }; +Now that we've got our food spawning and our players set up, let's create a match and spawn player circle entities into it. The first thing we should do before spawning a player into a match is give them a name. - // Called when we are disconnected from SpacetimeDB - SpacetimeDBClient.instance.onDisconnect += (closeStatus, error) => - { - Debug.Log("Disconnected."); - }; +Add the following to the bottom of your file. - // Called when we receive the client identity from SpacetimeDB - SpacetimeDBClient.instance.onIdentityReceived += (token, identity, address) => { - AuthToken.SaveToken(token); - local_identity = identity; - }; +```rust +const START_PLAYER_MASS: u32 = 15; - // Called after our local cache is populated from a Subscribe call - SpacetimeDBClient.instance.onSubscriptionApplied += OnSubscriptionApplied; +#[spacetimedb::reducer] +pub fn enter_game(ctx: &ReducerContext, name: String) -> Result<(), String> { + log::info!("Creating player with name {}", name); + let mut player: Player = ctx.db.player().identity().find(ctx.sender).ok_or("")?; + let player_id = player.player_id; + player.name = name; + ctx.db.player().identity().update(player); + spawn_player_initial_circle(ctx, player_id)?; - // Now that we’ve registered all our callbacks, lets connect to spacetimedb - SpacetimeDBClient.instance.Connect(AuthToken.Token, hostName, moduleAddress); + Ok(()) +} + +fn spawn_player_initial_circle(ctx: &ReducerContext, player_id: u32) -> Result { + let mut rng = ctx.rng(); + let world_size = ctx + .db + .config() + .id() + .find(&0) + .ok_or("Config not found")? + .world_size; + let player_start_radius = mass_to_radius(START_PLAYER_MASS); + let x = rng.gen_range(player_start_radius..(world_size as f32 - player_start_radius)); + let y = rng.gen_range(player_start_radius..(world_size as f32 - player_start_radius)); + spawn_circle_at( + ctx, + player_id, + START_PLAYER_MASS, + DbVector2 { x, y }, + ctx.timestamp, + ) +} + +fn spawn_circle_at( + ctx: &ReducerContext, + player_id: u32, + mass: u32, + position: DbVector2, + timestamp: Timestamp, +) -> Result { + let entity = ctx.db.entity().try_insert(Entity { + entity_id: 0, + position, + mass, + })?; + + ctx.db.circle().try_insert(Circle { + entity_id: entity.entity_id, + player_id, + direction: DbVector2 { x: 0.0, y: 1.0 }, + speed: 0.0, + last_split_time: timestamp, + })?; + Ok(entity) } ``` -In our `onConnect` callback we are calling `Subscribe` and subscribing to all data in the database. You can also subscribe to specific tables using SQL syntax like `SELECT * FROM MyTable`. Our SQL documentation enumerates the operations that are accepted in our SQL syntax. +The `enter_game` reducer takes one argument, the player's `name`. We can use this name to display as a label for the player in the match, by storing the name on the player's row. We are also spawning some circles for the player to control now that they are entering the game. To do this, we choose a random position within the bounds of the arena and create a new entity and corresponding circle row. + +Let's also modify our `disconnect` reducer to remove the circles from the arena when the player disconnects from the server. + +```rust +#[spacetimedb::reducer(client_disconnected)] +pub fn disconnect(ctx: &ReducerContext) -> Result<(), String> { + let player = ctx + .db + .player() + .identity() + .find(&ctx.sender) + .ok_or("Player not found")?; + let player_id = player.player_id; + ctx.db.logged_out_player().insert(player); + ctx.db.player().identity().delete(&ctx.sender); + + // Remove any circles from the arena + for circle in ctx.db.circle().player_id().filter(&player_id) { + ctx.db.entity().entity_id().delete(&circle.entity_id); + ctx.db.circle().entity_id().delete(&circle.entity_id); + } -Subscribing to tables tells SpacetimeDB what rows we want in our local client cache. We will also not get row update callbacks or event callbacks for any reducer that does not modify a row that matches at least one of our queries. This means that events can happen on the server and the client won't be notified unless they are subscribed to at least 1 row in the change. + Ok(()) +} +``` ---- +Finally publish the new module to SpacetimeDB with this command: -**Local Client Cache** +```sh +spacetime publish --server local blackholio --delete-data +``` -The "local client cache" is a client-side view of the database defined by the supplied queries to the `Subscribe` function. It contains the requested data which allows efficient access without unnecessary server queries. Accessing data from the client cache is done using the auto-generated `Iter`, `FilterBy`, and `FindBy` functions for each table, and it ensures that update and event callbacks are limited to the subscribed rows. +Deleting the data is optional in this case, but in case you've been messing around with the module we can just start fresh. ---- +### Creating the Arena -Next we write the `OnSubscriptionApplied` callback. When this event occurs for the first time, it signifies that our local client cache is fully populated. At this point, we can verify if a player entity already exists for the corresponding user. If we do not have a player entity, we need to show the `UserNameChooser` dialog so the user can enter a username. We also put the message of the day into the chat window. Finally we unsubscribe from the callback since we only need to do this once. +Now that we've set up our server logic to spawn food and players, let's continue developing our Unity client to display what we have so far. -**Append after the Start() function in BitcraftMiniGameManager.cs** +Start by adding `SetupArena` and `CreateBorderCube` methods to your `GameManager` class: -```csharp -void OnSubscriptionApplied() -{ - // If we don't have any data for our player, then we are creating a - // new one. Let's show the username dialog, which will then call the - // create player reducer - var player = PlayerComponent.FindByOwnerId(local_identity); - if (player == null) +```cs + private void SetupArena(float worldSize) { - // Show username selection - UIUsernameChooser.instance.Show(); + CreateBorderCube(new Vector2(worldSize / 2.0f, worldSize + borderThickness / 2), + new Vector2(worldSize + borderThickness * 2.0f, borderThickness)); //North + CreateBorderCube(new Vector2(worldSize / 2.0f, -borderThickness / 2), + new Vector2(worldSize + borderThickness * 2.0f, borderThickness)); //South + CreateBorderCube(new Vector2(worldSize + borderThickness / 2, worldSize / 2.0f), + new Vector2(borderThickness, worldSize + borderThickness * 2.0f)); //East + CreateBorderCube(new Vector2(-borderThickness / 2, worldSize / 2.0f), + new Vector2(borderThickness, worldSize + borderThickness * 2.0f)); //West } - // Show the Message of the Day in our Config table of the Client Cache - UIChatController.instance.OnChatMessageReceived("Message of the Day: " + Config.FindByVersion(0).MessageOfTheDay); + private void CreateBorderCube(Vector2 position, Vector2 scale) + { + var cube = GameObject.CreatePrimitive(PrimitiveType.Cube); + cube.name = "Border"; + cube.transform.localScale = new Vector3(scale.x, scale.y, 1); + cube.transform.position = new Vector3(position.x, position.y, 1); + cube.GetComponent().material = borderMaterial; + } +``` - // Now that we've done this work we can unregister this callback - SpacetimeDBClient.instance.onSubscriptionApplied -= OnSubscriptionApplied; -} +In your `HandleSubscriptionApplied` let's now call `SetupArea` method. Modify your `HandleSubscriptionApplied` method as in the below. + +```cs + private void HandleSubscriptionApplied(EventContext ctx) + { + Debug.Log("Subscription applied!"); + OnSubscriptionApplied?.Invoke(); + + // Once we have the initial subscription sync'd to the client cache + // Get the world size from the config table and set up the arena + var worldSize = Conn.Db.Config.Id.Find(0).WorldSize; + SetupArena(worldSize); + } ``` -### Adding the Multiplayer Functionality +The `OnApplied` callback will be called after the server synchronizes the initial state of your tables with your client. Once the sync has happened, we can look up the world size from the `config` table and use it to set up our arena. -Now we have to change what happens when you press the "Continue" button in the name dialog window. Instead of calling start game like we did in the single player version, we call the `create_player` reducer on the SpacetimeDB module using the auto-generated code. Open `Assets/_Project/Username/UIUsernameChooser.cs`. +In the scene view, select the `GameManager` object. Click on the `Border Material` property and choose `Sprites-Default`. -**Append to the top of UIUsernameChooser.cs** +### Creating GameObjects -```csharp -using SpacetimeDB.Types; +Now that we have our arena all set up, we need to take the row data that SpacetimeDB syncs with our client and use it to create and draw `GameObject`s on the screen. + +Let's start by making some controller scripts for each of the game objects we'd like to have in our scene. In the project window, right-click and select `Create > C# Script`. Name the new script `PlayerController.cs`. Repeat that process for `CircleController.cs` and `FoodController.cs`. We'll modify the contents of these files later. + +Now let's make some prefabs for our game objects. In the scene hierarchy window, create a new `GameObject` by right-clicking and selecting: + +``` +2D Object > Sprites > Circle ``` -Then we're doing a modification to the `ButtonPressed()` function: +Rename the new game object in the scene to `CirclePrefab`. Next in the `Inspector` window click the `Add Component` button and add the `Circle Controller` script component that we just created. Finally drag the object into the `Project` folder. Once the prefab file is created, delete the `CirclePrefab` object from the scene. We'll use this prefab to draw the circles that a player controllers. -**Modify the ButtonPressed function in UIUsernameChooser.cs** +Next repeat that same process for the `FoodPrefab` and `Food Controller` component. -```csharp -public void ButtonPressed() -{ - CameraController.RemoveDisabler(GetHashCode()); - _panel.SetActive(false); +In the `Project` view, double click the `CirclePrefab` to bring it up in the scene view. Right-click anywhere in the hierarchy and navigate to: - // Call the SpacetimeDB CreatePlayer reducer - Reducer.CreatePlayer(_usernameField.text); -} +``` +UI > Text - Text Mesh Pro +``` + +This will add a label to the circle prefab. You may need to import "TextMeshPro Essential Resources" into Unity in order to add the TextMeshPro element. Your logs will say "[TMP Essential Resources] have been imported." if it has worked correctly. Don't forget to set the transform position of the label to `Pos X: 0, Pos Y: 0, Pos Z: 0`. + +Finally we need to make the `PlayerPrefab`. In the hierarchy window, create a new `GameObject` by right-clicking and selecting: + +``` +Create Empty ``` -We need to create a `RemotePlayer` script that we attach to remote player objects. In the same folder as `Assets/_Project/Player/LocalPlayer.cs`, create a new C# script called `RemotePlayer`. In the start function, we will register an OnUpdate callback for the `EntityComponent` and query the local cache to get the player’s initial position. **Make sure you include a `using SpacetimeDB.Types;`** at the top of the file. +Rename the game object to `PlayerPrefab`. Next in the `Inspector` window click the `Add Component` button and add the `Player Controller` script component that we just created. Next drag the object into the `Project` folder. Once the prefab file is created, delete the `PlayerPrefab` object from the scene. -First append this using to the top of `RemotePlayer.cs` +#### EntityController -**Create file RemotePlayer.cs, then replace its contents:** +Let's also create an `EntityController` script which will serve as a base class for both our `CircleController` and `FoodController` classes since both `Circle`s and `Food` are entities. -```csharp +Create a new file called `EntityController.cs` and replace its contents with: + +```cs +using SpacetimeDB.Types; +using System; using System.Collections; using System.Collections.Generic; +using System.Linq; +using Unity.VisualScripting; using UnityEngine; -using SpacetimeDB.Types; -using TMPro; -public class RemotePlayer : MonoBehaviour +public abstract class EntityController : MonoBehaviour { - public ulong EntityId; - - public TMP_Text UsernameElement; - - public string Username { set { UsernameElement.text = value; } } + const float LERP_DURATION_SEC = 0.1f; + + private static readonly int ShaderColorProperty = Shader.PropertyToID("_Color"); + + [DoNotSerialize] public uint EntityId; + + protected float LerpTime; + protected Vector3 LerpStartPosition; + protected Vector3 LerpTargetPositio; + protected Vector3 TargetScale; + + protected virtual void Spawn(uint entityId) + { + EntityId = entityId; + + var entity = GameManager.Conn.Db.Entity.EntityId.Find(entityId); + LerpStartPosition = LerpTargetPositio = transform.position = (Vector2)entity.Position; + transform.localScale = Vector3.one; + TargetScale = MassToScale(entity.Mass); + } + + public void SetColor(Color color) + { + GetComponent().material.SetColor(ShaderColorProperty, color); + } + + public virtual void OnEntityUpdated(Entity newVal) + { + LerpTime = 0.0f; + LerpStartPosition = transform.position; + LerpTargetPositio = (Vector2)newVal.Position; + TargetScale = MassToScale(newVal.Mass); + } + + public virtual void OnDelete(EventContext context) + { + Destroy(gameObject); + } + + public virtual void Update() + { + // Interpolate position and scale + LerpTime = Mathf.Min(LerpTime + Time.deltaTime, LERP_DURATION_SEC); + transform.position = Vector3.Lerp(LerpStartPosition, LerpTargetPositio, LerpTime / LERP_DURATION_SEC); + transform.localScale = Vector3.Lerp(transform.localScale, TargetScale, Time.deltaTime * 8); + } + + public static Vector3 MassToScale(uint mass) + { + var diameter = MassToDiameter(mass); + return new Vector3(diameter, diameter, 1); + } + + public static float MassToRadius(uint mass) => Mathf.Sqrt(mass); + public static float MassToDiameter(uint mass) => MassToRadius(mass) * 2; +} +``` - void Start() - { - // Initialize overhead name - UsernameElement = GetComponentInChildren(); - var canvas = GetComponentInChildren(); - canvas.worldCamera = Camera.main; - - // Get the username from the PlayerComponent for this object and set it in the UI - PlayerComponent? playerComp = PlayerComponent.FindByEntityId(EntityId); - if (playerComp is null) - { - string inputUsername = UsernameElement.text; - Debug.Log($"PlayerComponent not found - Creating a new player ({inputUsername})"); - Reducer.CreatePlayer(inputUsername); +The `EntityController` script just provides some helper functions and basic functionality to manage our game objects based on entity updates. - // Try again, optimistically assuming success for simplicity - PlayerComponent? playerComp = PlayerComponent.FindByEntityId(EntityId); - } +> One notable feature is that we linearly interpolate (lerp) between the position where the server says the entity is, and where we actually draw it. This is a common technique which provides for smoother movement. +> +> If you're interested in learning more checkout [this demo](https://gabrielgambetta.com/client-side-prediction-live-demo.html) from Gabriel Gambetta. - Username = playerComp.Username; +Let's also create a new `Extensions.cs` script and replace the contents with: - // Get the last location for this player and set the initial position - EntityComponent entity = EntityComponent.FindByEntityId(EntityId); - transform.position = new Vector3(entity.Position.X, entity.Position.Y, entity.Position.Z); +```cs +using SpacetimeDB.Types; +using UnityEngine; - // Register for a callback that is called when the client gets an - // update for a row in the EntityComponent table - EntityComponent.OnUpdate += EntityComponent_OnUpdate; - } +namespace SpacetimeDB.Types +{ + public partial class DbVector2 + { + public static implicit operator Vector2(DbVector2 vec) + { + return new Vector2(vec.X, vec.Y); + } + + public static implicit operator DbVector2(Vector2 vec) + { + return new DbVector2(vec.x, vec.y); + } + } } ``` -We now write the `EntityComponent_OnUpdate` callback which sets the movement direction in the `MovementController` for this player. We also set the target position to the current location in the latest update. +This just allows us to implicitly convert between our `DbVector2` type and the Unity `Vector2` type. -**Append to bottom of RemotePlayer class in RemotePlayer.cs:** +#### CircleController -```csharp -private void EntityComponent_OnUpdate(EntityComponent oldObj, EntityComponent obj, ReducerEvent callInfo) +Now open the `CircleController` script and modify the contents of the `CircleController` script to be: + +```cs +using System; +using System.Collections.Generic; +using SpacetimeDB; +using SpacetimeDB.Types; +using UnityEngine; + +public class CircleController : EntityController { - // If the update was made to this object - if(obj.EntityId == EntityId) + public static Color[] ColorPalette = new[] + { + //Yellow + (Color)new Color32(175, 159, 49, 255), + (Color)new Color32(175, 116, 49, 255), + + //Purple + (Color)new Color32(112, 47, 252, 255), + (Color)new Color32(51, 91, 252, 255), + + //Red + (Color)new Color32(176, 54, 54, 255), + (Color)new Color32(176, 109, 54, 255), + (Color)new Color32(141, 43, 99, 255), + + //Blue + (Color)new Color32(2, 188, 250, 255), + (Color)new Color32(7, 50, 251, 255), + (Color)new Color32(2, 28, 146, 255), + }; + + private PlayerController Owner; + + public void Spawn(Circle circle, PlayerController owner) { - var movementController = GetComponent(); + base.Spawn(circle.EntityId); + SetColor(ColorPalette[circle.PlayerId % ColorPalette.Length]); - // Update target position, rotation, etc. - movementController.RemoteTargetPosition = new Vector3(obj.Position.X, obj.Position.Y, obj.Position.Z); - movementController.RemoteTargetRotation = obj.Direction; - movementController.SetMoving(obj.Moving); + this.Owner = owner; + GetComponentInChildren().text = owner.Username; } + + public override void OnDelete(EventContext context) + { + base.OnDelete(context); + Owner.OnCircleDeleted(this); + } } ``` -Next we need to handle what happens when a `PlayerComponent` is added to our local cache. We will handle it differently based on if it’s our local player entity or a remote player. We are going to register for the `OnInsert` event for our `PlayerComponent` table. Add the following code to the `Start` function in `TutorialGameManager`. +At the top, we're just defining some possible colors for our circle. We've also created a spawn function which takes a `Circle` (same type that's in our `circle` table) and a `PlayerController` which sets the color based on the circle's player ID, as well as setting the text of the Cricle to be the player's username. + +Note that the `CircleController` inherits from the `EntityController`, not `MonoBehavior`. + +#### FoodController -**Append to bottom of Start() function in BitcraftMiniGameManager.cs:** +Next open the `FoodController.cs` file and replace the contents with: -```csharp -PlayerComponent.OnInsert += PlayerComponent_OnInsert; +```cs +using SpacetimeDB.Types; +using Unity.VisualScripting; +using UnityEngine; + +public class FoodController : EntityController +{ + public static Color[] ColorPalette = new[] + { + (Color)new Color32(119, 252, 173, 255), + (Color)new Color32(76, 250, 146, 255), + (Color)new Color32(35, 246, 120, 255), + + (Color)new Color32(119, 251, 201, 255), + (Color)new Color32(76, 249, 184, 255), + (Color)new Color32(35, 245, 165, 255), + }; + + public void Spawn(Food food) + { + base.Spawn(food.EntityId); + SetColor(ColorPalette[EntityId % ColorPalette.Length]); + } +} ``` -Create the `PlayerComponent_OnInsert` function which does something different depending on if it's the component for the local player or a remote player. If it's the local player, we set the local player object's initial position and call `StartGame`. If it's a remote player, we instantiate a `PlayerPrefab` with the `RemotePlayer` component. The start function of `RemotePlayer` handles initializing the player position. +#### PlayerController -**Append to bottom of TutorialGameManager class in BitcraftMiniGameManager.cs:** +Open the `PlayerController` script and modify the contents of the `PlayerController` script to be: -```csharp -private void PlayerComponent_OnInsert(PlayerComponent obj, ReducerEvent callInfo) +```cs +using System.Collections.Generic; +using System.Linq; +using SpacetimeDB; +using SpacetimeDB.Types; +using UnityEngine; + +public class PlayerController : MonoBehaviour { - // If the identity of the PlayerComponent matches our user identity then this is the local player - if(obj.Identity == local_identity) + const int SEND_UPDATES_PER_SEC = 20; + const float SEND_UPDATES_FREQUENCY = 1f / SEND_UPDATES_PER_SEC; + + public static PlayerController Local { get; private set; } + + private uint PlayerId; + private float LastMovementSendTimestamp; + private Vector2? LockInputPosition; + private List OwnedCircles = new List(); + + public string Username => GameManager.Conn.Db.Player.PlayerId.Find(PlayerId).Name; + public int NumberOfOwnedCircles => OwnedCircles.Count; + public bool IsLocalPlayer => this == Local; + + public void Initialize(Player player) { - // Now that we have our initial position we can start the game - StartGame(); + PlayerId = player.PlayerId; + if (player.Identity == GameManager.LocalIdentity) + { + Local = this; + } + } + + private void OnDestroy() + { + // If we have any circles, destroy them + foreach (var circle in OwnedCircles) + { + if (circle != null) + { + Destroy(circle.gameObject); + } + } + OwnedCircles.Clear(); } - else + + public void OnCircleSpawned(CircleController circle) { - // Spawn the player object and attach the RemotePlayer component - var remotePlayer = Instantiate(PlayerPrefab); + OwnedCircles.Add(circle); + } - // Lookup and apply the position for this new player - var entity = EntityComponent.FindByEntityId(obj.EntityId); - var position = new Vector3(entity.Position.X, entity.Position.Y, entity.Position.Z); - remotePlayer.transform.position = position; + public void OnCircleDeleted(CircleController deletedCircle) + { + // This means we got eaten + if (OwnedCircles.Remove(deletedCircle) && IsLocalPlayer && OwnedCircles.Count == 0) + { + // DeathScreen.Instance.SetVisible(true); + } + } - var movementController = remotePlayer.GetComponent(); - movementController.RemoteTargetPosition = position; - movementController.RemoteTargetRotation = entity.Direction; + public uint TotalMass() + { + return (uint)OwnedCircles + .Select(circle => GameManager.Conn.Db.Entity.EntityId.Find(circle.EntityId)) + .Sum(e => e?.Mass ?? 0); //If this entity is being deleted on the same frame that we're moving, we can have a null entity here. + } - remotePlayer.AddComponent().EntityId = obj.EntityId; - } -} -``` + public Vector2? CenterOfMass() + { + if (OwnedCircles.Count == 0) + { + return null; + } + + Vector2 totalPos = Vector2.zero; + float totalMass = 0; + foreach (var circle in OwnedCircles) + { + var entity = GameManager.Conn.Db.Entity.EntityId.Find(circle.EntityId); + var position = circle.transform.position; + totalPos += (Vector2)position * entity.Mass; + totalMass += entity.Mass; + } -Next, we will add a `FixedUpdate()` function to the `LocalPlayer` class so that we can send the local player's position to SpacetimeDB. We will do this by calling the auto-generated reducer function `Reducer.UpdatePlayerPosition(...)`. When we invoke this reducer from the client, a request is sent to SpacetimeDB and the reducer `update_player_position(...)` (Rust) or `UpdatePlayerPosition(...)` (C#) is executed on the server and a transaction is produced. All clients connected to SpacetimeDB will start receiving the results of these transactions. + return totalPos / totalMass; + } -**Append to the top of LocalPlayer.cs** + private void OnGUI() + { + if (!IsLocalPlayer || !GameManager.IsConnected()) + { + return; + } -```csharp -using SpacetimeDB.Types; -using SpacetimeDB; -``` + GUI.Label(new Rect(0, 0, 100, 50), $"Total Mass: {TotalMass()}"); + } -**Append to the bottom of LocalPlayer class in LocalPlayer.cs** + //Automated testing members + private bool testInputEnabled; + private Vector2 testInput; -```csharp -private float? lastUpdateTime; -private void FixedUpdate() -{ - float? deltaTime = Time.time - lastUpdateTime; - bool hasUpdatedRecently = deltaTime.HasValue && deltaTime.Value < 1.0f / movementUpdateSpeed; - bool isConnected = SpacetimeDBClient.instance.IsConnected(); - - if (hasUpdatedRecently || !isConnected) - { - return; - } - - lastUpdateTime = Time.time; - var p = PlayerMovementController.Local.GetModelPosition(); - - Reducer.UpdatePlayerPosition(new StdbVector3 - { - X = p.x, - Y = p.y, - Z = p.z, - }, - PlayerMovementController.Local.GetModelRotation(), - PlayerMovementController.Local.IsMoving()); + public void SetTestInput(Vector2 input) => testInput = input; + public void EnableTestInput() => testInputEnabled = true; } ``` -Finally, we need to update our connection settings in the inspector for our GameManager object in the scene. Click on the GameManager in the Hierarchy tab. The the inspector tab you should now see fields for `Module Address` and `Host Name`. Set the `Module Address` to the name you used when you ran `spacetime publish`. This is likely `unity-tutorial`. If you don't remember, you can go back to your terminal and run `spacetime publish` again from the `server` folder. +Let's also add a new `PrefabManager.cs` script which we can use as a factory for creating prefabs. Replace the contents of the file with: -![GameManager-Inspector2](/images/unity-tutorial/GameManager-Inspector2.JPG) - -### Play the Game! +```cs +using SpacetimeDB.Types; +using System.Collections; +using System.Collections.Generic; +using UnityEngine; -Go to File -> Build Settings... Replace the SampleScene with the Main scene we have been working in. +public class PrefabManager : MonoBehaviour +{ + private static PrefabManager Instance; + + public CircleController CirclePrefab; + public FoodController FoodPrefab; + public PlayerController PlayerPrefab; + + private void Awake() + { + Instance = this; + } + + public static CircleController SpawnCircle(Circle circle, PlayerController owner) + { + var entityController = Instantiate(Instance.CirclePrefab); + entityController.name = $"Circle - {circle.EntityId}"; + entityController.Spawn(circle, owner); + owner.OnCircleSpawned(entityController); + return entityController; + } + + public static FoodController SpawnFood(Food food) + { + var entityController = Instantiate(Instance.FoodPrefab); + entityController.name = $"Food - {food.EntityId}"; + entityController.Spawn(food); + return entityController; + } + + public static PlayerController SpawnPlayer(Player player) + { + var playerController = Instantiate(Instance.PlayerPrefab); + playerController.name = $"PlayerController - {player.Name}"; + playerController.Initialize(player); + return playerController; + } +} +``` -![Unity-AddOpenScenes](/images/unity-tutorial/Unity-AddOpenScenes.JPG) +In the scene hierarchy, select the `GameManager` object and add the `Prefab Manager` script as a component to the `GameManager` object. Drag the corresponding `CirclePrefab`, `FoodPrefab`, and `PlayerPrefab` prefabs we created earlier from the project view into their respective slots in the `Prefab Manager`. Save the scene. -When you hit the `Build` button, it will kick off a build of the game which will use a different identity than the Unity Editor. Create your character in the build and in the Unity Editor by entering a name and clicking `Continue`. Now you can see each other in game running around the map. +### Hooking up the Data -### Implement Player Logout +We've now prepared our Unity project so that we can hook up the data from our tables to the Unity game objects and have them drawn on the screen. -So far we have not handled the `logged_in` variable of the `PlayerComponent`. This means that remote players will not despawn on your screen when they disconnect. To fix this we need to handle the `OnUpdate` event for the `PlayerComponent` table in addition to `OnInsert`. We are going to use a common function that handles any time the `PlayerComponent` changes. +Add a couple dictionaries at the top of your `GameManager` class which we'll use to hold onto the game objects we create for our scene. Add these two lines just below your `DbConnection` like so: -**Append to the bottom of Start() function in TutorialGameManager.cs** +```cs + public static DbConnection Conn { get; private set; } -```csharp -PlayerComponent.OnUpdate += PlayerComponent_OnUpdate; + public static Dictionary Entities = new Dictionary(); + public static Dictionary Players = new Dictionary(); ``` -We are going to add a check to determine if the player is logged for remote players. If the player is not logged in, we search for the `RemotePlayer` object with the corresponding `EntityId` and destroy it. +Next lets add some callbacks when rows change in the database. Modify the `HandleConnect` method as below. -Next we'll be updating some of the code in `PlayerComponent_OnInsert`. For simplicity, just replace the entire function. +```cs + // Called when we connect to SpacetimeDB and receive our client identity + void HandleConnect(DbConnection conn, Identity identity, string token) + { + Debug.Log("Connected."); + AuthToken.SaveToken(token); + LocalIdentity = identity; -**REPLACE PlayerComponent_OnInsert in TutorialGameManager.cs** + conn.Db.Circle.OnInsert += CircleOnInsert; + conn.Db.Entity.OnUpdate += EntityOnUpdate; + conn.Db.Entity.OnDelete += EntityOnDelete; + conn.Db.Food.OnInsert += FoodOnInsert; + conn.Db.Player.OnInsert += PlayerOnInsert; + conn.Db.Player.OnDelete += PlayerOnDelete; -```csharp -private void PlayerComponent_OnUpdate(PlayerComponent oldValue, PlayerComponent newValue, ReducerEvent dbEvent) -{ - OnPlayerComponentChanged(newValue); -} + OnConnected?.Invoke(); -private void PlayerComponent_OnInsert(PlayerComponent obj, ReducerEvent dbEvent) -{ - OnPlayerComponentChanged(obj); -} + // Request all tables + Conn.SubscriptionBuilder() + .OnApplied(HandleSubscriptionApplied) + .Subscribe("SELECT * FROM *"); + } +``` -private void OnPlayerComponentChanged(PlayerComponent obj) -{ - // If the identity of the PlayerComponent matches our user identity then this is the local player - if(obj.OwnerId == local_identity) +Next add the following implementations for those callbacks to the `GameManager` class. + +```cs + private static void CircleOnInsert(EventContext context, Circle insertedValue) { - // Now that we have our initial position we can start the game - StartGame(); + var player = GetOrCreatePlayer(insertedValue.PlayerId); + var entityController = PrefabManager.SpawnCircle(insertedValue, player); + Entities.Add(insertedValue.EntityId, entityController); } - else + + private static void EntityOnUpdate(EventContext context, Entity oldEntity, Entity newEntity) { - // otherwise we need to look for the remote player object in the scene (if it exists) and destroy it - var existingPlayer = FindObjectsOfType().FirstOrDefault(item => item.EntityId == obj.EntityId); - if (obj.LoggedIn) + if (!Entities.TryGetValue(newEntity.EntityId, out var entityController)) { - // Only spawn remote players who aren't already spawned - if (existingPlayer == null) - { - // Spawn the player object and attach the RemotePlayer component - var remotePlayer = Instantiate(PlayerPrefab); + return; + } + entityController.OnEntityUpdated(newEntity); + } - // Lookup and apply the position for this new player - var entity = EntityComponent.FindByEntityId(obj.EntityId); - var position = new Vector3(entity.Position.X, entity.Position.Y, entity.Position.Z); - remotePlayer.transform.position = position; + private static void EntityOnDelete(EventContext context, Entity oldEntity) + { + if (Entities.Remove(oldEntity.EntityId, out var entityController)) + { + entityController.OnDelete(context); + } + } - var movementController = remotePlayer.GetComponent(); - movementController.RemoteTargetPosition = position; - movementController.RemoteTargetRotation = entity.Direction; + private static void FoodOnInsert(EventContext context, Food insertedValue) + { + var entityController = PrefabManager.SpawnFood(insertedValue); + Entities.Add(insertedValue.EntityId, entityController); + } - remotePlayer.AddComponent().EntityId = obj.EntityId; - } + private static void PlayerOnInsert(EventContext context, Player insertedPlayer) + { + GetOrCreatePlayer(insertedPlayer.PlayerId); + } + + private static void PlayerOnDelete(EventContext context, Player deletedvalue) + { + if (Players.Remove(deletedvalue.PlayerId, out var playerController)) + { + GameObject.Destroy(playerController.gameObject); } - else + } + + private static PlayerController GetOrCreatePlayer(uint playerId) + { + if (!Players.TryGetValue(playerId, out var playerController)) { - if (existingPlayer != null) - { - Destroy(existingPlayer.gameObject); - } + var player = Conn.Db.Player.PlayerId.Find(playerId); + playerController = PrefabManager.SpawnPlayer(player); + Players.Add(playerId, playerController); } + + return playerController; } -} ``` -Now you when you play the game you should see remote players disappear when they log out. - -Before updating the client, let's generate the client files and update publish our module. +### Camera Controller -**Execute commands in the server/ directory** +One of the last steps is to create a camera controller to make sure the camera moves around with the player. Create a script called `CameraController.cs` and add it to your project. Replace the contents of the file with this: -```bash -spacetime generate --out-dir ../client/Assets/module_bindings --lang=csharp -spacetime publish -c unity-tutorial -``` +```cs +using System.Collections; +using System.Collections.Generic; +using UnityEngine; -On the client, let's add code to send the message when the chat button or enter is pressed. Update the `OnChatButtonPress` function in `UIChatController.cs`. +public class CameraController : MonoBehaviour +{ + public static float WorldSize = 0.0f; -**Append to the top of UIChatController.cs:** + private void LateUpdate() + { + var arenaCenterTransform = new Vector3(WorldSize / 2, WorldSize / 2, -10.0f); + if (PlayerController.Local == null || !GameManager.IsConnected()) + { + // Set the camera to be in middle of the arena if we are not connected or + // there is no local player + transform.position = arenaCenterTransform; + return; + } -```csharp -using SpacetimeDB.Types; -``` + var centerOfMass = PlayerController.Local.CenterOfMass(); + if (centerOfMass.HasValue) + { + // Set the camera to be the center of mass of the local player + // if the local player has one + transform.position = new Vector3 + { + x = centerOfMass.Value.x, + y = centerOfMass.Value.y, + z = transform.position.z + }; + } else { + transform.position = arenaCenterTransform; + } -**REPLACE the OnChatButtonPress function in UIChatController.cs:** + float targetCameraSize = CalculateCameraSize(PlayerController.Local); + Camera.main.orthographicSize = Mathf.Lerp(Camera.main.orthographicSize, targetCameraSize, Time.deltaTime * 2); + } -```csharp -public void OnChatButtonPress() -{ - Reducer.SendChatMessage(_chatInput.text); - _chatInput.text = ""; + private float CalculateCameraSize(PlayerController player) + { + return 50f + //Base size + Mathf.Min(50, player.TotalMass() / 5) + //Increase camera size with mass + Mathf.Min(player.NumberOfOwnedCircles - 1, 1) * 30; //Zoom out when player splits + } } ``` -Now we need to add a reducer to handle inserting new chat messages. First register for the ChatMessage reducer in the `Start()` function using the auto-generated function: +Add the `CameraController` as a component to the `Main Camera` object in the scene. -**Append to the bottom of the Start() function in TutorialGameManager.cs:** +Lastly modify the `GameManager.SetupArea` method to set the `WorldSize` on the `CameraController`. -```csharp -Reducer.OnSendChatMessageEvent += OnSendChatMessageEvent; +```cs + private void SetupArena(float worldSize) + { + CreateBorderCube(new Vector2(worldSize / 2.0f, worldSize + borderThickness / 2), + new Vector2(worldSize + borderThickness * 2.0f, borderThickness)); //North + CreateBorderCube(new Vector2(worldSize / 2.0f, -borderThickness / 2), + new Vector2(worldSize + borderThickness * 2.0f, borderThickness)); //South + CreateBorderCube(new Vector2(worldSize + borderThickness / 2, worldSize / 2.0f), + new Vector2(borderThickness, worldSize + borderThickness * 2.0f)); //East + CreateBorderCube(new Vector2(-borderThickness / 2, worldSize / 2.0f), + new Vector2(borderThickness, worldSize + borderThickness * 2.0f)); //West + + // Set the world size for the camera controller + CameraController.WorldSize = worldSize; + } ``` -Now we write the `OnSendChatMessageEvent` function. We can find the `PlayerComponent` for the player who sent the message using the `Identity` of the sender. Then we get the `Username` and prepend it to the message before sending it to the chat window. +### Entering the Game -**Append after the Start() function in TutorialGameManager.cs** +The last step is to call the `enter_game` reducer on the server, passing in a username for our player, which will spawn a circle for our player. For the sake of simplicity, let's call the `enter_game` reducer from the `HandleSubscriptionApplied` callback with the name "3Blave". -```csharp -private void OnSendChatMessageEvent(ReducerEvent dbEvent, string message) -{ - var player = PlayerComponent.FindByOwnerId(dbEvent.Identity); - if (player != null) +> You may need to regenerate your bindings the following command from the `server-rust` directory. +> +> ```sh +> spacetime generate --lang csharp --out-dir ../client/Assets/autogen +> ``` +> +> **BUG WORKAROUND NOTE**: There is currently a bug in the C# code generation that requires you to delete `autogen/LoggedOutPlayer.cs` after running this command. + +```cs + private void HandleSubscriptionApplied(EventContext ctx) { - UIChatController.instance.OnChatMessageReceived(player.Username + ": " + message); - } -} -``` + Debug.Log("Subscription applied!"); + OnSubscriptionApplied?.Invoke(); -Now when you run the game you should be able to send chat messages to other players. Be sure to make a new Unity client build and run it in a separate window so you can test chat between two clients. + // Once we have the initial subscription sync'd to the client cache + // Get the world size from the config table and set up the arena + var worldSize = Conn.Db.Config.Id.Find(0).WorldSize; + SetupArena(worldSize); -## Conclusion + // Call enter game with the player name 3Blave + ctx.Reducers.EnterGame("3Blave"); + } +``` -This concludes the SpacetimeDB basic multiplayer tutorial, where we learned how to create a multiplayer game. In the next Unity tutorial, we will add resource nodes to the game and learn about _scheduled_ reducers: +### Trying it out -From here, the tutorial continues with more-advanced topics: The [next tutorial](/docs/unity/part-4) introduces Resources & Scheduling. +At this point, after publishing our module we can press the play button to see the fruits of our labor! You should be able to see your player's circle, with its username label, surrounded by food. ---- + ### Troubleshooting -- If you get an error when running the generate command, make sure you have an empty subfolder in your Unity project Assets folder called `module_bindings` +- If you get an error when running the generate command, make sure you have an empty subfolder in your Unity project Assets folder called `autogen` -- If you get this exception when running the project: +- If you get an error in your Unity console when starting the game, double check that you have published your module and you have the correct module name specified in your `GameManager`. -``` -NullReferenceException: Object reference not set to an instance of an object -TutorialGameManager.Start () (at Assets/_Project/Game/TutorialGameManager.cs:26) -``` - -Check to see if your GameManager object in the Scene has the NetworkManager component attached. +### Next Steps -- If you get an error in your Unity console when starting the game, double check your connection settings in the Inspector for the `GameManager` object in the scene. - -``` -Connection error: Unable to connect to the remote server -``` +It's pretty cool to see our player in game surrounded by food, but there's a problem! We can't move yet. In the next part, we'll explore how to get your player moving and interacting with food and other objects. \ No newline at end of file diff --git a/docs/unity/part-4.md b/docs/unity/part-4.md index 029fbe13..8d89e7be 100644 --- a/docs/unity/part-4.md +++ b/docs/unity/part-4.md @@ -1,272 +1,409 @@ -# Unity Tutorial - Advanced - Part 4 - Resources and Scheduling +# Unity Tutorial - Part 4 - Moving and Colliding Need help with the tutorial? [Join our Discord server](https://discord.gg/spacetimedb)! -This progressive tutorial is continued from the [Part 3](/docs/unity/part-3) Tutorial. +This progressive tutorial is continued from [part 3](/docs/unity/part-3). -**Oct 14th, 2023: This tutorial has not yet been updated for the recent 0.7.0 release, it will be updated asap!** +### Moving the player -In this second part of the lesson, we'll add resource nodes to our project and learn about scheduled reducers. Then we will spawn the nodes on the client so they are visible to the player. +At this point, we're very close to having a working game. All we have to do is modify our server to allow the player to move around, and to simulate the physics and collisions of the game. -## Add Resource Node Spawner +Let's start by building out a simple math library to help us do collision calculations. Create a new `math.rs` file in the `server-rust/src` directory and add the following contents. Let's also move the `DbVector2` type from `lib.rs` into this file. -In this section we will add functionality to our server to spawn the resource nodes. +```rust +use spacetimedb::SpacetimeType; -### Step 1: Add the SpacetimeDB Tables for Resource Nodes +// This allows us to store 2D points in tables. +#[derive(SpacetimeType, Debug, Clone, Copy)] +pub struct DbVector2 { + pub x: f32, + pub y: f32, +} -1. Before we start adding code to the server, we need to add the ability to use the rand crate in our SpacetimeDB module so we can generate random numbers. Open the `Cargo.toml` file in the `Server` directory and add the following line to the `[dependencies]` section. +impl std::ops::Add<&DbVector2> for DbVector2 { + type Output = DbVector2; -```toml -rand = "0.8.5" -``` + fn add(self, other: &DbVector2) -> DbVector2 { + DbVector2 { + x: self.x + other.x, + y: self.y + other.y, + } + } +} -We also need to add the `getrandom` feature to our SpacetimeDB crate. Update the `spacetimedb` line to: +impl std::ops::Add for DbVector2 { + type Output = DbVector2; -```toml -spacetimedb = { "0.5", features = ["getrandom"] } -``` + fn add(self, other: DbVector2) -> DbVector2 { + DbVector2 { + x: self.x + other.x, + y: self.y + other.y, + } + } +} -2. The first entity component we are adding, `ResourceNodeComponent`, stores the resource type. We'll define an enum to describe a `ResourceNodeComponent`'s type. For now, we'll just have one resource type: Iron. In the future, though, we'll add more resources by adding variants to the `ResourceNodeType` enum. Since we are using a custom enum, we need to mark it with the `SpacetimeType` attribute. Add the following code to lib.rs. +impl std::ops::AddAssign for DbVector2 { + fn add_assign(&mut self, rhs: DbVector2) { + self.x += rhs.x; + self.y += rhs.y; + } +} -```rust -#[derive(SpacetimeType, Clone)] -pub enum ResourceNodeType { - Iron, +impl std::iter::Sum for DbVector2 { + fn sum>(iter: I) -> Self { + let mut r = DbVector2::new(0.0, 0.0); + for val in iter { + r += val; + } + r + } } -#[spacetimedb(table(public))] -#[derive(Clone)] -pub struct ResourceNodeComponent { - #[primarykey] - pub entity_id: u64, +impl std::ops::Sub<&DbVector2> for DbVector2 { + type Output = DbVector2; - // Resource type of this resource node - pub resource_type: ResourceNodeType, + fn sub(self, other: &DbVector2) -> DbVector2 { + DbVector2 { + x: self.x - other.x, + y: self.y - other.y, + } + } } -``` -Because resource nodes never move, the `MobileEntityComponent` is overkill. Instead, we will add a new entity component named `StaticLocationComponent` that only stores the position and rotation. +impl std::ops::Sub for DbVector2 { + type Output = DbVector2; -```rust -#[spacetimedb(table(public))] -#[derive(Clone)] -pub struct StaticLocationComponent { - #[primarykey] - pub entity_id: u64, - - pub location: StdbVector2, - pub rotation: f32, + fn sub(self, other: DbVector2) -> DbVector2 { + DbVector2 { + x: self.x - other.x, + y: self.y - other.y, + } + } +} + +impl std::ops::SubAssign for DbVector2 { + fn sub_assign(&mut self, rhs: DbVector2) { + self.x -= rhs.x; + self.y -= rhs.y; + } +} + +impl std::ops::Mul for DbVector2 { + type Output = DbVector2; + + fn mul(self, other: f32) -> DbVector2 { + DbVector2 { + x: self.x * other, + y: self.y * other, + } + } +} + +impl std::ops::Div for DbVector2 { + type Output = DbVector2; + + fn div(self, other: f32) -> DbVector2 { + if other != 0.0 { + DbVector2 { + x: self.x / other, + y: self.y / other, + } + } else { + DbVector2 { x: 0.0, y: 0.0 } + } + } +} + +impl DbVector2 { + pub fn new(x: f32, y: f32) -> Self { + Self { x, y } + } + + pub fn sqr_magnitude(&self) -> f32 { + self.x * self.x + self.y * self.y + } + + pub fn magnitude(&self) -> f32 { + (self.x * self.x + self.y * self.y).sqrt() + } + + pub fn normalized(self) -> DbVector2 { + self / self.magnitude() + } } ``` -3. We are also going to add a couple of additional column to our Config table. `map_extents` let's our spawner know where it can spawn the nodes. `num_resource_nodes` is the maximum number of nodes to spawn on the map. Update the config table in lib.rs. +At the very top of `lib.rs` add the following lines to import the moved `DbVector2` from the `math` module. ```rust -#[spacetimedb(table(public))] -pub struct Config { - // Config is a global table with a single row. This table will be used to - // store configuration or global variables - - #[primarykey] - // always 0 - // having a table with a primarykey field which is always zero is a way to store singleton global state - pub version: u32, - - pub message_of_the_day: String, - - // new variables for resource node spawner - // X and Z range of the map (-map_extents to map_extents) - pub map_extents: u32, - // maximum number of resource nodes to spawn on the map - pub num_resource_nodes: u32, -} +pub mod math; + +use math::DbVector2; +// ... ``` -4. In the `init` reducer, we need to set the initial values of our two new variables. Update the following code: +Next, add the following table to your `lib.rs` file. ```rust - Config::insert(Config { - version: 0, - message_of_the_day: "Hello, World!".to_string(), - - // new variables for resource node spawner - map_extents: 25, - num_resource_nodes: 10, - }) - .expect("Failed to insert config."); +#[spacetimedb::reducer] +pub fn update_player_input(ctx: &ReducerContext, direction: DbVector2) -> Result<(), String> { + let player = ctx + .db + .player() + .identity() + .find(&ctx.sender) + .ok_or("Player not found")?; + for mut circle in ctx.db.circle().player_id().filter(&player.player_id) { + circle.direction = direction.normalized(); + circle.speed = direction.magnitude().clamp(0.0, 1.0); + ctx.db.circle().entity_id().update(circle); + } + Ok(()) +} ``` -### Step 2: Write our Resource Spawner Repeating Reducer +This is a simple reducer that takes the movement input from the client and applies them to all circles that that player controls. Note that it is not possible for a player to move another player's circles using this reducer, because the `ctx.sender` value is not set by the client. Instead `ctx.sender` is set by SpacetimeDB after it has authenticated that sender. You can rest assured that the caller has been authenticated as that player by the time this reducer is called. -1. Add the following code to lib.rs. As we want to schedule `resource_spawn_agent` to run later, It will require to implement a scheduler table. +Finally, let's schedule a reducer to run every 50 milliseconds to move the player's circles around based on the most recently set player input. ```rust -#[spacetimedb(table, scheduled(resource_spawner_agent))] -struct ResouceSpawnAgentSchedueler { - _prev_time: Timestamp, +#[spacetimedb::table(name = move_all_players_timer, scheduled(move_all_players))] +pub struct MoveAllPlayersTimer { + #[primary_key] + #[auto_inc] + scheduled_id: u64, + scheduled_at: spacetimedb::ScheduleAt, } -#[spacetimedb(reducer) -pub fn resource_spawner_agent(_ctx: ReducerContext, _arg: ResourceSpawnAgentScheduler) -> Result<(), String> { - let config = Config::find_by_version(&0).unwrap(); +const START_PLAYER_SPEED: u32 = 10; - // Retrieve the maximum number of nodes we want to spawn from the Config table - let num_resource_nodes = config.num_resource_nodes as usize; +fn mass_to_max_move_speed(mass: u32) -> f32 { + 2.0 * START_PLAYER_SPEED as f32 / (1.0 + (mass as f32 / START_PLAYER_MASS as f32).sqrt()) +} - // Count the number of nodes currently spawned and exit if we have reached num_resource_nodes - let num_resource_nodes_spawned = ResourceNodeComponent::iter().count(); - if num_resource_nodes_spawned >= num_resource_nodes { - log::info!("All resource nodes spawned. Skipping."); - return Ok(()); +#[spacetimedb::reducer] +pub fn move_all_players(ctx: &ReducerContext, _timer: MoveAllPlayersTimer) -> Result<(), String> { + let world_size = ctx + .db + .config() + .id() + .find(0) + .ok_or("Config not found")? + .world_size; + + // Handle player input + for circle in ctx.db.circle().iter() { + let mut circle_entity = ctx.db.entity().entity_id().find(&circle.entity_id).unwrap(); + let circle_radius = mass_to_radius(circle_entity.mass); + let direction = circle.direction * circle.speed; + let new_pos = + circle_entity.position + direction * mass_to_max_move_speed(circle_entity.mass); + let min = circle_radius; + let max = world_size as f32 - circle_radius; + circle_entity.position.x = new_pos.x.clamp(min, max); + circle_entity.position.y = new_pos.y.clamp(min, max); + ctx.db.entity().entity_id().update(circle_entity); } - // Pick a random X and Z based off the map_extents - let mut rng = rand::thread_rng(); - let map_extents = config.map_extents as f32; - let location = StdbVector2 { - x: rng.gen_range(-map_extents..map_extents), - z: rng.gen_range(-map_extents..map_extents), - }; - // Pick a random Y rotation in degrees - let rotation = rng.gen_range(0.0..360.0); - - // Insert our SpawnableEntityComponent which assigns us our entity_id - let entity_id = SpawnableEntityComponent::insert(SpawnableEntityComponent { entity_id: 0 }) - .expect("Failed to create resource spawnable entity component.") - .entity_id; - - // Insert our static location with the random position and rotation we selected - StaticLocationComponent::insert(StaticLocationComponent { - entity_id, - location: location.clone(), - rotation, - }) - .expect("Failed to insert resource static location component."); - - // Insert our resource node component, so far we only have iron - ResourceNodeComponent::insert(ResourceNodeComponent { - entity_id, - resource_type: ResourceNodeType::Iron, - }) - .expect("Failed to insert resource node component."); - - // Log that we spawned a node with the entity_id and location - log::info!( - "Resource node spawned: {} at ({}, {})", - entity_id, - location.x, - location.z, - ); - Ok(()) } ``` -2. Since this reducer uses `rand::Rng` we need add include it. Add this `use` statement to the top of lib.rs. +This reducer is very similar to a standard game "tick" or "frame" that you might find in an ordinary game server or similar to something like the `Update` loop in a game engine like Unity. We've scheduled it every 50 milliseconds and we can use it to step forward our simulation by moving all the circles a little bit further in the direction they're moving. -```rust -use rand::Rng; -``` +In this reducer, we're just looping through all the circles in the game and updating their position based on their direction, speed, and mass. Just basic physics. -3. Add the following code to the end of the `init` reducer to set the reducer to repeat at every regular interval. +Add the following to your `init` reducer to schedule the `move_all_players` reducer to run every 50 milliseconds. ```rust - // Start our resource spawner repeating reducer - ResouceSpawnAgentSchedueler::insert(ResouceSpawnAgentSchedueler { - _prev_time: TimeStamp::now(), - scheduled_id: 1, - scheduled_at: duration!(1000ms).into() - }).expect(); + ctx.db + .move_all_players_timer() + .try_insert(MoveAllPlayersTimer { + scheduled_id: 0, + scheduled_at: ScheduleAt::Interval(Duration::from_millis(50).as_micros() as u64), + })?; ``` -struct ResouceSpawnAgentSchedueler { +Republish your module with: -4. Next we need to generate our client code and publish the module. Since we changed the schema we need to make sure we include the `--clear-database` flag. Run the following commands from your Server directory: - -```bash -spacetime generate --out-dir ../Assets/autogen --lang=csharp - -spacetime publish -c yourname/bitcraftmini +```sh +spacetime publish --server local blackholio --delete-data ``` -Your resource node spawner will start as soon as you publish since we scheduled it to run in our init reducer. You can watch the log output by using the `--follow` flag on the logs CLI command. +Regenerate your server bindings with: -```bash -spacetime logs -f yourname/bitcraftmini +```sh +spacetime generate --lang csharp --out-dir ../client/Assets/autogen ``` -### Step 3: Spawn the Resource Nodes on the Client - -1. First we need to update the `GameResource` component in Unity to work for multiplayer. Open GameResource.cs and add `using SpacetimeDB.Types;` to the top of the file. Then change the variable `Type` to be of type `ResourceNodeType` instead of `int`. Also add a new variable called `EntityId` of type `ulong`. +> **BUG WORKAROUND NOTE**: You may have to delete LoggedOutPlayer.cs again. -```csharp - public ulong EntityId; +### Moving on the Client - public ResourceNodeType Type = ResourceNodeType.Iron; -``` +All that's left is to modify our `PlayerController` on the client to call the `update_player_input` reducer. Open `PlayerController.cs` and add an `Update` function: -2. Now that we've changed the `Type` variable, we need to update the code in the `PlayerAnimator` component that references it. Open PlayerAnimator.cs and update the following section of code. We need to add `using SpacetimeDB.Types;` to this file as well. This fixes the compile errors that result from changing the type of the `Type` variable to our new server generated enum. +```cs + public void Update() + { + if (!IsLocalPlayer || NumberOfOwnedCircles == 0) + { + return; + } -```csharp - var resourceType = res?.Type ?? ResourceNodeType.Iron; - switch (resourceType) + if (Input.GetKeyDown(KeyCode.Q)) + { + if (LockInputPosition.HasValue) { - case ResourceNodeType.Iron: - _animator.SetTrigger("Mine"); - Interacting = true; - break; - default: - Interacting = false; - break; - } - for (int i = 0; i < _tools.Length; i++) + LockInputPosition = null; + } + else { - _tools[i].SetActive(((int)resourceType) == i); + LockInputPosition = (Vector2)Input.mousePosition; } - _target = res; -``` + } -3. Now that our `GameResource` is ready to be spawned, lets update the `BitcraftMiniGameManager` component to actually create them. First, we need to add the new tables to our SpacetimeDB subscription. Open BitcraftMiniGameManager.cs and update the following code: + // Throttled input requests + if (Time.time - LastMovementSendTimestamp >= SEND_UPDATES_FREQUENCY) + { + LastMovementSendTimestamp = Time.time; -```csharp - SpacetimeDBClient.instance.Subscribe(new List() + var mousePosition = LockInputPosition ?? (Vector2)Input.mousePosition; + var screenSize = new Vector2 { - "SELECT * FROM Config", - "SELECT * FROM SpawnableEntityComponent", - "SELECT * FROM PlayerComponent", - "SELECT * FROM MobileEntityComponent", - // Our new tables for part 2 of the tutorial - "SELECT * FROM ResourceNodeComponent", - "SELECT * FROM StaticLocationComponent" - }); + x = Screen.width, + y = Screen.height, + }; + var centerOfScreen = screenSize / 2; + + var direction = (mousePosition - centerOfScreen) / (screenSize.y / 3); + if (testInputEnabled) { direction = testInput; } + GameManager.Conn.Reducers.UpdatePlayerInput(direction); + } + } ``` -4. Next let's add an `OnInsert` handler for the `ResourceNodeComponent`. Add the following line to the `Start` function. +Let's try it out! Press play and roam freely around the arena! Now we're cooking with gas. -```csharp - ResourceNodeComponent.OnInsert += ResourceNodeComponent_OnInsert; -``` +### Collisions and Eating Food -5. Finally we add the new function to handle the insert event. This function will be called whenever a new `ResourceNodeComponent` is inserted into our local client cache. We can use this to spawn the resource node in the world. Add the following code to the `BitcraftMiniGameManager` class. +Well this is pretty fun, but wouldn't it be better if we could eat food and grow our circle? Surely, that's going to be a pain, right? -To get the position and the rotation of the node, we look up the `StaticLocationComponent` for this entity by using the EntityId. +Wrong. With SpacetimeDB it's extremely easy. All we have to do is add an `is_overlapping` helper function which does some basic math based on mass radii, and modify our `move_all_player` reducer to loop through every entity in the arena for every circle, checking each for overlaps. This may not be the most efficient way to do collision checking (building a quad tree or doing [spatial hashing](https://conkerjo.wordpress.com/2009/06/13/spatial-hashing-implementation-for-fast-2d-collisions/) might be better), but SpacetimeDB is very fast so for this number of entities it'll be a breeze for SpacetimeDB. -```csharp - private void ResourceNodeComponent_OnInsert(ResourceNodeComponent insertedValue, ReducerEvent callInfo) - { - switch(insertedValue.ResourceType) - { - case ResourceNodeType.Iron: - var iron = Instantiate(IronPrefab); - StaticLocationComponent loc = StaticLocationComponent.FindByEntityId(insertedValue.EntityId); - Vector3 nodePos = new Vector3(loc.Location.X, 0.0f, loc.Location.Z); - iron.transform.position = new Vector3(nodePos.x, MathUtil.GetTerrainHeight(nodePos), nodePos.z); - iron.transform.rotation = Quaternion.Euler(0.0f, loc.Rotation, 0.0f); - break; +Sometimes simple is best! + +```rust +const MINIMUM_SAFE_MASS_RATIO: f32 = 0.85; + +fn is_overlapping(a: &Entity, b: &Entity) -> bool { + let dx = a.position.x - b.position.x; + let dy = a.position.y - b.position.y; + let distance_sq = dx * dx + dy * dy; + + let radius_a = mass_to_radius(a.mass); + let radius_b = mass_to_radius(b.mass); + + // If the distance between the two circle centers is less than the + // maximum radius, then the center of the smaller circle is inside + // the larger circle. This gives some leeway for the circles to overlap + // before being eaten. + let max_radius = f32::max(radius_a, radius_b); + distance_sq <= max_radius * max_radius +} + +#[spacetimedb::reducer] +pub fn move_all_players(ctx: &ReducerContext, _timer: MoveAllPlayersTimer) -> Result<(), String> { + let world_size = ctx + .db + .config() + .id() + .find(0) + .ok_or("Config not found")? + .world_size; + + // Handle player input + for circle in ctx.db.circle().iter() { + let mut circle_entity = ctx.db.entity().entity_id().find(&circle.entity_id).unwrap(); + let circle_radius = mass_to_radius(circle_entity.mass); + let direction = circle.direction * circle.speed; + let new_pos = + circle_entity.position + direction * mass_to_max_move_speed(circle_entity.mass); + let min = circle_radius; + let max = world_size as f32 - circle_radius; + circle_entity.position.x = new_pos.x.clamp(min, max); + circle_entity.position.y = new_pos.y.clamp(min, max); + + // Check collisions + for entity in ctx.db.entity().iter() { + if entity.entity_id == circle_entity.entity_id { + continue; + } + if is_overlapping(&circle_entity, &entity) { + // Check to see if we're overlapping with food + if ctx.db.food().entity_id().find(&entity.entity_id).is_some() { + ctx.db.entity().entity_id().delete(&entity.entity_id); + ctx.db.food().entity_id().delete(&entity.entity_id); + circle_entity.mass += entity.mass; + } + + // Check to see if we're overlapping with another circle owned by another player + let other_circle = ctx.db.circle().entity_id().find(&entity.entity_id); + if let Some(other_circle) = other_circle { + if other_circle.player_id != circle.player_id { + let mass_ratio = entity.mass as f32 / circle_entity.mass as f32; + if mass_ratio < MINIMUM_SAFE_MASS_RATIO { + ctx.db.entity().entity_id().delete(&entity.entity_id); + ctx.db.circle().entity_id().delete(&entity.entity_id); + circle_entity.mass += entity.mass; + } + } + } + } } + ctx.db.entity().entity_id().update(circle_entity); } + + Ok(()) +} +``` + +For every circle, we look at all other entities. If they are overlapping then for food, we add the mass of the food to the circle and delete the food, otherwise if it's a circle we delete the smaller circle and add the mass to the bigger circle. + +That's it. We don't even have to do anything on the client. + +```sh +spacetime publish --server local blackholio ``` -### Step 4: Play the Game! +Just update your module by publishing and you're on your way eating food! Try to see how big you can get! + +We didn't even have to update the client, because our client's `OnDelete` callbacks already handled deleting entities from the scene when they're deleted on the server. SpacetimeDB just synchronizes the state with your client automatically. + +Notice that the food automatically respawns as you vaccuum them up. This is because our scheduled reducer is automatically replacing the food 2 times per second, to ensure that there is always 600 food on the map. + +# Conclusion + +So far you've learned how to configure a new Unity project to work with SpacetimeDB, how to develop, build, and publish a SpacetimeDB server module. Within the module, you've learned how to create tables, update tables, and write reducers. You've learned about special reducers like `client_connected` and `init` and how to created scheduled reducers. You learned how we can used scheduled reducers to implement a physics simulation right within your module. + +You've also learned how view module logs and connect your client to your server module, call reducers from the client and synchronize the data with client. Finally you learned how to use that synchronized data to draw game objects on the screen, so that we can interact with them and play a game! + +And all of that completely from scratch! + +Our game is still pretty limited in some important ways. The biggest limitation is that the client assumes your username is "3Blave" and doesn't give you a menu or a window to set your username before joining the game. Notably, we do not have a unique constraint on the `name` column, so that does not prevent us from connecting multiple clients to the same server. + +In fact, if you build what we have and run multiple clients you already have a (very simple) MMO! You can connect hundreds of players to this arena with SpacetimeDB. + +There's still plenty more we can do to build this into a proper game though. For example, you might want to also add + +- Username chooser +- Chat +- Leaderboards +- Nice animations +- Nice shaders +- Space theme! + +Fortunately, we've done that for you! If you'd like to check out the completed tutorial game you can download it on GitHub: + +https://github.com/ClockworkLabs/Blackholio -6. Hit Play in the Unity Editor and you should now see your resource nodes spawning in the world! +If you have any suggestions or comments on the tutorial, either open an issue in our [docs repo](https://github.com/ClockworkLabs/spacetime-docs), or join our Discord (https://discord.gg/SpacetimeDB) and chat with us! diff --git a/docs/unity/part-5.md b/docs/unity/part-5.md deleted file mode 100644 index 2c59c73b..00000000 --- a/docs/unity/part-5.md +++ /dev/null @@ -1,108 +0,0 @@ -# Unity Tutorial - Advanced - Part 5 - BitCraft Mini - -Need help with the tutorial? [Join our Discord server](https://discord.gg/spacetimedb)! - -This progressive tutorial is continued from the [Part 4](/docs/unity/part-3) Tutorial. - -**Oct 14th, 2023: This tutorial has not yet been updated for the recent 0.7.0 release, it will be updated asap!** - -BitCraft Mini is a game that we developed which extends the code you've already developed in this tutorial. It is inspired by our game [BitCraft](https://bitcraftonline.com) and illustrates how you could build a more complex game from just the components we've discussed. Right now you can walk around, mine ore, and manage your inventory. - -## 1. Download - -You can git-clone BitCraftMini from here: - -```plaintext -git clone ssh://git@github.com/clockworklabs/BitCraftMini -``` - -Once you have downloaded BitCraftMini, you will need to compile the spacetime module. - -## 2. Compile the Spacetime Module - -In order to compile the BitCraftMini module, you will need to install cargo. You can install cargo from here: - -> https://www.rust-lang.org/tools/install - -Once you have cargo installed, you can compile and publish the module with these commands: - -```bash -cd BitCraftMini/Server -spacetime publish -``` - -`spacetime publish` will output an address where your module has been deployed to. You will want to copy/save this address because you will need it in step 3. Here is an example of what it should look like: - -```plaintext -$ spacetime publish -info: component 'rust-std' for target 'wasm32-unknown-unknown' is up to date - Finished release [optimized] target(s) in 0.03s -Publish finished successfully. -Created new database with address: c91c17ecdcea8a05302be2bad9dd59b3 -``` - -Optionally, you can specify a name when you publish the module: - -```bash -spacetime publish "unique-module-name" -``` - -Currently, all the named modules exist in the same namespace so if you get a message saying that database is not owned by you, it means that someone else has already published a module with that name. You can either choose a different name or you can use the address instead. If you specify a name when you publish, you can use that name in place of the autogenerated address in both the CLI and in the Unity client. - -In the BitCraftMini module we have a function called `initialize()`. This function should be called immediately after publishing the module to spacetimedb. This function is in charge of generating some initial settings that are required for the server to operate. You can call this function like so: - -```bash -spacetime call "" "initialize" "[]" -``` - -Here we are telling spacetime to invoke the `initialize()` function on our module "bitcraftmini". If the function had some arguments, we would json encode them and put them into the "[]". Since `initialize()` requires no parameters, we just leave it empty. - -After you have called `initialize()` on the spacetime module you shouldgenerate the client files: - -```bash -spacetime generate --out-dir ../Client/Assets/_Project/autogen --lang=cs -``` - -Here is some sample output: - -```plaintext -$ spacetime generate --out-dir ../Client/Assets/_Project/autogen --lang cs -info: component 'rust-std' for target 'wasm32-unknown-unknown' is up to date - Finished release [optimized] target(s) in 0.03s -compilation took 234.613518ms -Generate finished successfully. -``` - -If you've gotten this message then everything should be working properly so far. - -## 3. Replace address in BitCraftMiniGameManager - -The following settings are exposed in the `BitCraftMiniGameManager` inspector: Module Address, Host Name, and SSL Enabled. - -Open the Main scene in Unity and click on the `GameManager` object in the heirarchy. The inspector window will look like this: - -![GameManager-Inspector](/images/unity-tutorial/GameManager-Inspector.JPG) - -Update the module address with the address you got from the `spacetime publish` command. If you are using SpacetimeDB Cloud `testnet`, the host name should be `testnet.spacetimedb.com` and SSL Enabled should be checked. If you are running SpacetimeDB Standalone locally, the host name should be `localhost:3000` and SSL Enabled should be unchecked. For instructions on how to deploy to these environments, see the [Deployment Section](/docs/deploying/testnet) - -## 4. Play Mode - -You should now be able to enter play mode and walk around! You can mine some rocks, cut down some trees and if you connect more clients you can trade with other players. - -## 5. Editing the Module - -If you want to make further updates to the module, make sure to use this publish command instead: - -```bash -spacetime publish -``` - -Where `` is your own address. If you do this instead then you won't have to change the address inside of `BitCraftMiniGameManager.cs` - -When you change the server module you should also regenerate the client files as well: - -```bash -spacetime generate --out-dir ../Client/Assets/_Project/autogen --lang=cs -``` - -You may want to consider putting these 2 commands into a simple shell script to make the process a bit cleaner. diff --git a/nav.ts b/nav.ts index 8ca41be7..467a86d7 100644 --- a/nav.ts +++ b/nav.ts @@ -42,17 +42,9 @@ const nav: Nav = { section('Unity Tutorial - Basic Multiplayer'), page('Overview', 'unity-tutorial', 'unity/index.md'), page('1 - Setup', 'unity/part-1', 'unity/part-1.md'), - page('2a - Server (Rust)', 'unity/part-2a-rust', 'unity/part-2a-rust.md'), - page( - '2b - Server (C#)', - 'unity/part-2b-c-sharp', - 'unity/part-2b-c-sharp.md' - ), - page('3 - Client', 'unity/part-3', 'unity/part-3.md'), - - section('Unity Tutorial - Advanced'), - page('4 - Resources And Scheduling', 'unity/part-4', 'unity/part-4.md'), - page('5 - BitCraft Mini', 'unity/part-5', 'unity/part-5.md'), + page('2 - Connecting to SpacetimeDB', 'unity/part-2', 'unity/part-2.md'), + page('3 - Gameplay', 'unity/part-3', 'unity/part-3.md'), + page('4 - Moving and Colliding', 'unity/part-4', 'unity/part-4.md'), section('Server Module Languages'), page('Overview', 'modules', 'modules/index.md'), diff --git a/package.json b/package.json index 26e48ffb..c96b785b 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "typescript": "^5.3.2" }, "scripts": { - "build": "tsc nav.ts --outDir docs", + "build": "tsc --project ./tsconfig.json", "check-links": "tsx scripts/checkLinks.ts" }, "author": "Clockwork Labs", diff --git a/scripts/checkLinks.ts b/scripts/checkLinks.ts index d67302f7..58c94f47 100644 --- a/scripts/checkLinks.ts +++ b/scripts/checkLinks.ts @@ -31,23 +31,36 @@ function validatePathsExist(slugToPath: Map): void { }); } -// Function to extract links from markdown files with line numbers -function extractLinksFromMarkdown(filePath: string): { link: string; line: number }[] { +// Function to extract links and images from markdown files with line numbers +function extractLinksAndImagesFromMarkdown(filePath: string): { link: string; type: 'image' | 'link'; line: number }[] { const fileContent = fs.readFileSync(filePath, 'utf-8'); const lines = fileContent.split('\n'); - const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g; + const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g; // Matches standard Markdown links + const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g; // Matches image links in Markdown + + const linksAndImages: { link: string; type: 'image' | 'link'; line: number }[] = []; + const imageSet = new Set(); // To store links that are classified as images - const links: { link: string; line: number }[] = []; lines.forEach((lineContent, index) => { let match: RegExpExecArray | null; + + // Extract image links and add them to the imageSet + while ((match = imageRegex.exec(lineContent)) !== null) { + const link = match[2]; + linksAndImages.push({ link, type: 'image', line: index + 1 }); + imageSet.add(link); + } + + // Extract standard links while ((match = linkRegex.exec(lineContent)) !== null) { - links.push({ link: match[2], line: index + 1 }); // Add 1 to make line numbers 1-based + const link = match[2]; + linksAndImages.push({ link, type: 'link', line: index + 1 }); } }); - return links; + // Filter out links that exist as images + return linksAndImages.filter(item => !(item.type === 'link' && imageSet.has(item.link))); } - // Function to resolve relative links using slugs function resolveLink(link: string, currentSlug: string): string { if (link.startsWith('#')) { @@ -66,30 +79,9 @@ function resolveLink(link: string, currentSlug: string): string { return resolvedSlug.startsWith('/docs') ? resolvedSlug : `/docs${resolvedSlug}`; } -// Function to extract headings from a markdown file -function extractHeadingsFromMarkdown(filePath: string): string[] { - if (!fs.existsSync(filePath) || !fs.lstatSync(filePath).isFile()) { - return []; // Return an empty list if the file does not exist or is not a file - } - - const fileContent = fs.readFileSync(filePath, 'utf-8'); - const headingRegex = /^(#{1,6})\s+(.*)$/gm; // Match markdown headings like # Heading - const headings: string[] = []; - let match: RegExpExecArray | null; - - const slugger = new GitHubSlugger(); - while ((match = headingRegex.exec(fileContent)) !== null) { - const heading = match[2].trim(); // Extract the heading text - const slug = slugger.slug(heading); // Slugify the heading text - headings.push(slug); - } - - return headings; -} - -// Function to check if the links in .md files match the slugs in nav.ts and validate fragments +// Function to check if the links in .md files match the slugs in nav.ts and validate fragments/images function checkLinks(): void { - const brokenLinks: { file: string; link: string; line: number }[] = []; + const brokenLinks: { file: string; link: string; type: 'image' | 'link'; line: number }[] = []; let totalFiles = 0; let totalLinks = 0; let validLinks = 0; @@ -97,7 +89,6 @@ function checkLinks(): void { let totalFragments = 0; let validFragments = 0; let invalidFragments = 0; - let currentFileFragments = 0; // Extract the slug-to-path mapping from nav.ts const slugToPath = extractSlugToPathMap(nav); @@ -122,12 +113,12 @@ function checkLinks(): void { totalFiles = mdFiles.length; mdFiles.forEach((file) => { - const links = extractLinksFromMarkdown(file); - totalLinks += links.length; + const linksAndImages = extractLinksAndImagesFromMarkdown(file); + totalLinks += linksAndImages.length; const currentSlug = pathToSlug.get(file) || ''; - links.forEach(({ link, line }) => { + linksAndImages.forEach(({ link, type, line }) => { // Exclude external links (starting with http://, https://, mailto:, etc.) if (/^([a-z][a-z0-9+.-]*):/.test(link)) { return; // Skip external links @@ -140,10 +131,23 @@ function checkLinks(): void { } } - // Resolve the link const resolvedLink = resolveLink(link, currentSlug); - + + if (type === 'image') { + // Validate image paths + const normalizedLink = resolvedLink.startsWith('/') ? resolvedLink.slice(1) : resolvedLink; + const imagePath = path.resolve(__dirname, '../', normalizedLink); + + if (!fs.existsSync(imagePath)) { + brokenLinks.push({ file, link: resolvedLink, type: 'image', line }); + invalidLinks += 1; + } else { + validLinks += 1; + } + return; + } + // Split the resolved link into base and fragment const [baseLink, fragmentRaw] = resolvedLink.split('#'); const fragment: string | null = fragmentRaw || null; @@ -154,7 +158,7 @@ function checkLinks(): void { // Check if the base link matches a valid slug if (!validSlugs.includes(baseLink)) { - brokenLinks.push({ file, link: resolvedLink, line }); + brokenLinks.push({ file, link: resolvedLink, type: 'link', line }); invalidLinks += 1; return; } else { @@ -168,14 +172,11 @@ function checkLinks(): void { const targetHeadings = extractHeadingsFromMarkdown(targetFile); if (!targetHeadings.includes(fragment)) { - brokenLinks.push({ file, link: resolvedLink, line }); + brokenLinks.push({ file, link: resolvedLink, type: 'link', line }); invalidFragments += 1; invalidLinks += 1; } else { validFragments += 1; - if (baseLink === currentSlug) { - currentFileFragments += 1; - } } } } @@ -183,31 +184,52 @@ function checkLinks(): void { }); if (brokenLinks.length > 0) { - console.error(`\nFound ${brokenLinks.length} broken links:`); - brokenLinks.forEach(({ file, link, line }) => { - console.error(`File: ${file}:${line}, Link: ${link}`); + console.error(`\nFound ${brokenLinks.length} broken links/images:`); + brokenLinks.forEach(({ file, link, type, line }) => { + const typeLabel = type === 'image' ? 'Image' : 'Link'; + console.error(`${typeLabel}: ${file}:${line}, Path: ${link}`); }); } else { - console.log('All links are valid!'); + console.log('All links and images are valid!'); } // Print statistics - console.log('\n=== Link Validation Statistics ==='); + console.log('\n=== Validation Statistics ==='); console.log(`Total markdown files processed: ${totalFiles}`); - console.log(`Total links processed: ${totalLinks}`); - console.log(` Valid links: ${validLinks}`); - console.log(` Invalid links: ${invalidLinks}`); + console.log(`Total links/images processed: ${totalLinks}`); + console.log(` Valid: ${validLinks}`); + console.log(` Invalid: ${invalidLinks}`); console.log(`Total links with fragments processed: ${totalFragments}`); console.log(` Valid links with fragments: ${validFragments}`); console.log(` Invalid links with fragments: ${invalidFragments}`); - console.log(`Fragments referring to the current file: ${currentFileFragments}`); - console.log('================================='); + console.log('==============================='); if (brokenLinks.length > 0) { process.exit(1); // Exit with an error code if there are broken links } } +// Function to extract headings from a markdown file +function extractHeadingsFromMarkdown(filePath: string): string[] { + if (!fs.existsSync(filePath) || !fs.lstatSync(filePath).isFile()) { + return []; // Return an empty list if the file does not exist or is not a file + } + + const fileContent = fs.readFileSync(filePath, 'utf-8'); + const headingRegex = /^(#{1,6})\s+(.*)$/gm; // Match markdown headings like # Heading + const headings: string[] = []; + let match: RegExpExecArray | null; + + const slugger = new GitHubSlugger(); + while ((match = headingRegex.exec(fileContent)) !== null) { + const heading = match[2].trim(); // Extract the heading text + const slug = slugger.slug(heading); // Slugify the heading text + headings.push(slug); + } + + return headings; +} + // Function to get all markdown files recursively function getMarkdownFiles(dir: string): string[] { let files: string[] = []; diff --git a/tsconfig.json b/tsconfig.json index efe136bd..d3f1db7d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,10 +1,11 @@ { "compilerOptions": { "target": "ESNext", - "module": "commonjs", + "module": "ESNext", "outDir": "./docs", - "esModuleInterop": true, + "esModuleInterop": false, "strict": true, "skipLibCheck": true - } + }, + "include": ["nav.ts"] } From fa1934322e59a00c8780c3413bece0ddc47cd66c Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Wed, 15 Jan 2025 22:59:34 -0500 Subject: [PATCH 084/164] Made fixes to the tutorial for changes that were introduced to the C# in RC3 (#135) --- docs/unity/part-2.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/unity/part-2.md b/docs/unity/part-2.md index ab82604e..b5aa6cb1 100644 --- a/docs/unity/part-2.md +++ b/docs/unity/part-2.md @@ -317,7 +317,7 @@ public class GameManager : MonoBehaviour // we can use it to authenticate the connection. if (PlayerPrefs.HasKey(AuthToken.GetTokenKey())) { - builder = builder.WithCredentials((default, AuthToken.Token)); + builder = builder.WithToken(AuthToken.Token); } // Building the connection will establish a connection to the SpacetimeDB @@ -337,7 +337,7 @@ public class GameManager : MonoBehaviour // Request all tables Conn.SubscriptionBuilder() .OnApplied(HandleSubscriptionApplied) - .Subscribe("SELECT * FROM *"); + .SubscribeToAllTables(); } void HandleConnectError(Exception ex) From ea5dc57ac6d893174f4b056d035fee02a765532c Mon Sep 17 00:00:00 2001 From: james gilles Date: Thu, 16 Jan 2025 10:48:41 -0500 Subject: [PATCH 085/164] Add remark to style guide inspired by SpacetimeDB#2050 (#133) --- STYLE.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/STYLE.md b/STYLE.md index a72b1dd2..96baef43 100644 --- a/STYLE.md +++ b/STYLE.md @@ -33,6 +33,9 @@ For meta-variables in code blocks, enclose the meta-variable name in `{}` curly Do not use single-backtick code highlighting for words which are not variable, function, method or type names. (Or other sorts of defined symbols that appear in actual code.) Similarly, do not use italics for words which are not meta-variables that the reader is expected to substitute. In particular, do not use code highlighting for emphasis or to introduce vocabulary. +Because this meta-syntax is not valid syntax, it should be followed by an example that shows what the result would look like in a +concrete situation. + For example: > To find rows in a table *table* with a given value in a `#[unique]` or `#[primary_key]` column, do: @@ -41,7 +44,14 @@ For example: > ctx.db.{table}().{column}().find({value}) > ``` > -> where *column* is the name of the unique column and *value* is the value you're looking for in that column. This is equivalent to: +> where *column* is the name of the unique column and *value* is the value you're looking for in that column. +> For example: +> +> ```rust +> ctx.db.people().name().find("Billy") +> ``` +> +> This is equivalent to: > > ```sql > SELECT * FROM {table} WHERE {column} = {value} From 6508df7b983a20a570e13d078acef42f45670a2a Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Fri, 17 Jan 2025 21:08:09 -0500 Subject: [PATCH 086/164] Addresses feedback that John provided (#137) * Addresses feedback that John provided * Fixed broken link --- docs/index.md | 2 +- docs/nav.js | 2 +- docs/sdks/c-sharp/index.md | 2 +- docs/unity/index.md | 7 ++++++- docs/unity/part-1.md | 2 ++ docs/unity/part-2.md | 18 ++++++++++++------ docs/unity/part-3.md | 22 ++++++++++++---------- docs/unity/part-4.md | 4 ++-- nav.ts | 2 +- 9 files changed, 38 insertions(+), 23 deletions(-) diff --git a/docs/index.md b/docs/index.md index bfa957ef..974b543f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -189,4 +189,4 @@ A user has a single [`Identity`](#identity), but may open multiple connections t Follow our [Quick Start](/docs/getting-started) guide! 5. How do I create a Unity game with SpacetimeDB? - Follow our [Unity Project](/docs/unity-tutorial) guide! + Follow our [Unity Tutorial](/docs/unity) guide! diff --git a/docs/nav.js b/docs/nav.js index fea9ed85..bdf49517 100644 --- a/docs/nav.js +++ b/docs/nav.js @@ -14,7 +14,7 @@ const nav = { section('Migration Guides'), page('v0.12', 'migration/v0.12', 'migration/v0.12.md'), section('Unity Tutorial - Basic Multiplayer'), - page('Overview', 'unity-tutorial', 'unity/index.md'), + page('Overview', 'unity', 'unity/index.md'), page('1 - Setup', 'unity/part-1', 'unity/part-1.md'), page('2 - Connecting to SpacetimeDB', 'unity/part-2', 'unity/part-2.md'), page('3 - Gameplay', 'unity/part-3', 'unity/part-3.md'), diff --git a/docs/sdks/c-sharp/index.md b/docs/sdks/c-sharp/index.md index 0315d36c..e9c5f23a 100644 --- a/docs/sdks/c-sharp/index.md +++ b/docs/sdks/c-sharp/index.md @@ -101,7 +101,7 @@ This is the global instance of a SpacetimeDB client in a particular .NET/Unity p The Unity SpacetimeDB SDK relies on there being a `NetworkManager` somewhere in the scene. Click on the GameManager object in the scene, and in the inspector, add the `NetworkManager` component. -This component will handle updating and closing the [`SpacetimeDBClient.instance`](#property-spacetimedbclientinstance) for you, but will not call [`SpacetimeDBClient.Connect`](#method-spacetimedbclientconnect), you still need to handle that yourself. See the [Unity Tutorial](/docs/unity-tutorial) for more information. +This component will handle updating and closing the [`SpacetimeDBClient.instance`](#property-spacetimedbclientinstance) for you, but will not call [`SpacetimeDBClient.Connect`](#method-spacetimedbclientconnect), you still need to handle that yourself. See the [Unity Tutorial](/docs/unity) for more information. ### Method `SpacetimeDBClient.Connect` diff --git a/docs/unity/index.md b/docs/unity/index.md index 2a2d78f4..99ed9420 100644 --- a/docs/unity/index.md +++ b/docs/unity/index.md @@ -12,7 +12,12 @@ Our game, called Blackhol.io, will be similar but with space themes in twists. I This tutorial assumes that you have a basic understanding of the Unity Editor, using a command line terminal and coding. We'll give you some CLI commands to execute. If you are using Windows, we recommend using Git Bash or PowerShell. For Mac, we recommend Terminal. -Tested with UnityEngine `2022.3.32f1 LTS` (and may also work on newer versions). +The SpacetimeDB Unity SDK minimum supported Unity version is `2021.2` as the SDK requires C# 9. This tutorial has been tested with the following Unity versions. + +- `2022.3.32f1 LTS` +- `6000.0.33f1` + +Please file an issue [here]() if you encounter an issue with a specific Unity version. ## Blackhol.io Tutorial - Basic Multiplayer diff --git a/docs/unity/part-1.md b/docs/unity/part-1.md index 6f4eafdf..7678d0f5 100644 --- a/docs/unity/part-1.md +++ b/docs/unity/part-1.md @@ -30,6 +30,8 @@ In this section, we will guide you through the process of setting up a Unity Pro ### Step 1: Create a Blank Unity Project +The SpacetimeDB Unity SDK minimum supported Unity version is `2021.2` as the SDK requires C# 9. See [the overview](.) for more information on specific supported versions. + Open Unity and create a new project by selecting "New" from the Unity Hub or going to **File -> New Project**. diff --git a/docs/unity/part-2.md b/docs/unity/part-2.md index b5aa6cb1..efa7bdba 100644 --- a/docs/unity/part-2.md +++ b/docs/unity/part-2.md @@ -6,19 +6,21 @@ This progressive tutorial is continued from [part 1](/docs/unity/part-1). ## Create a Server Module -Run the following command to initialize the SpacetimeDB server module project with Rust as the language: +If you have not already installed the `spacetime` CLI, check out our [Getting Started](/docs/getting-started) guide for instructions on how to install. + +In your `blackholio` directory, run the following command to initialize the SpacetimeDB server module project with Rust as the language: ```bash -spacetime init --lang=rust rust-server +spacetime init --lang=rust server-rust ``` -This command creates a new folder named "rust-server" within your Unity project directory and sets up the SpacetimeDB server project with Rust as the programming language. +This command creates a new folder named `server-rust` alongside your Unity project `client` directory and sets up the SpacetimeDB server project with Rust as the programming language. ### SpacetimeDB Tables -In this section we'll be making some edits to the file `server/src/lib.rs`. We recommend you open up this file in an IDE like VSCode or RustRover. +In this section we'll be making some edits to the file `server-rust/src/lib.rs`. We recommend you open up this file in an IDE like VSCode or RustRover. -**Important: Open the `server/src/lib.rs` file and delete its contents. We will be writing it from scratch here.** +**Important: Open the `server-rust/src/lib.rs` file and delete its contents. We will be writing it from scratch here.** First we need to add some imports at the top of the file. Some will remain unused for now. @@ -175,7 +177,9 @@ This following log output indicates that SpacetimeDB is successfully running on Starting SpacetimeDB listening on 127.0.0.1:3000 ``` -Now that SpacetimeDB is running we can publish our module to the SpacetimeDB host. In a separate terminal window, navigate to the `blackholio/server-rust` directory and run `spacetime publish --server local blackholio`. This will publish our Blackholio server logic to SpacetimeDB. +Now that SpacetimeDB is running we can publish our module to the SpacetimeDB host. In a separate terminal window, navigate to the `blackholio/server-rust` directory. + +If you are not already logged in to the `spacetime` CLI, run the `spacetime login` command log in to your SpacetimeDB website account. Once you are logged in, run `spacetime publish --server local blackholio`. This will publish our Blackholio server logic to SpacetimeDB. If the publish completed successfully, you will see something like the following in the logs: @@ -186,6 +190,8 @@ Publishing module... Created new database with name: blackholio, identity: c200d2c69b4524292b91822afac8ab016c15968ac993c28711f68c6bc40b89d5 ``` +> If you sign into `spacetime login` via GitHub, the token you get will be issued by `auth.spacetimedb.com`. This will also ensure that you can recover your identity in case you lose it. On the other hand, if you do `spacetime login --server-issued-login local`, you will get an identity which is issued directly by your local server. Do note, however, that `--server-issued-login` tokens are not recoverable if lost, and are only recognized by the server that issued them. + Next, use the `spacetime` command to call our newly defined `debug` reducer: ```sh diff --git a/docs/unity/part-3.md b/docs/unity/part-3.md index c8967976..22791698 100644 --- a/docs/unity/part-3.md +++ b/docs/unity/part-3.md @@ -82,7 +82,7 @@ Although, we've written the reducer to spawn food, no food will actually be spaw We would like for this function to be called periodically to "top up" the amount of food on the map so that it never falls very far below our target amount of food. SpacetimeDB has built in functionality for exactly this. With SpacetimeDB you can schedule your module to call itself in the future or repeatedly with reducers. -In order to schedule a reducer to be called we have to create a new table which specifies when an how a reducer should be called. Add this new table to the top of the file. +In order to schedule a reducer to be called we have to create a new table which specifies when an how a reducer should be called. Add this new table to the top of the file, below your imports. ```rust #[spacetimedb::table(name = spawn_food_timer, scheduled(spawn_food))] @@ -466,7 +466,7 @@ The `EntityController` script just provides some helper functions and basic func > > If you're interested in learning more checkout [this demo](https://gabrielgambetta.com/client-side-prediction-live-demo.html) from Gabriel Gambetta. -Let's also create a new `Extensions.cs` script and replace the contents with: +At this point you'll have a compilation error because we can't yet convert from `SpacetimeDB.Types.DbVector2` to `UnityEngine.Vector2`. To fix this, let's also create a new `Extensions.cs` script and replace the contents with: ```cs using SpacetimeDB.Types; @@ -910,15 +910,15 @@ Lastly modify the `GameManager.SetupArea` method to set the `WorldSize` on the ` ### Entering the Game -The last step is to call the `enter_game` reducer on the server, passing in a username for our player, which will spawn a circle for our player. For the sake of simplicity, let's call the `enter_game` reducer from the `HandleSubscriptionApplied` callback with the name "3Blave". +At this point, you may need to regenerate your bindings the following command from the `server-rust` directory. -> You may need to regenerate your bindings the following command from the `server-rust` directory. -> -> ```sh -> spacetime generate --lang csharp --out-dir ../client/Assets/autogen -> ``` -> -> **BUG WORKAROUND NOTE**: There is currently a bug in the C# code generation that requires you to delete `autogen/LoggedOutPlayer.cs` after running this command. +```sh +spacetime generate --lang csharp --out-dir ../client/Assets/autogen +``` + +> **BUG WORKAROUND NOTE**: As of `1.0.0-rc3` you will now have a compilation error in Unity. There is currently a bug in the C# code generation that requires you to delete `autogen/LoggedOutPlayer.cs` after running this command. + +The last step is to call the `enter_game` reducer on the server, passing in a username for our player, which will spawn a circle for our player. For the sake of simplicity, let's call the `enter_game` reducer from the `HandleSubscriptionApplied` callback with the name "3Blave". ```cs private void HandleSubscriptionApplied(EventContext ctx) @@ -942,6 +942,8 @@ At this point, after publishing our module we can press the play button to see t +> The label won't be centered at this point. Feel free to adjust it if you like. We just didn't want to complicate the tutorial. + ### Troubleshooting - If you get an error when running the generate command, make sure you have an empty subfolder in your Unity project Assets folder called `autogen` diff --git a/docs/unity/part-4.md b/docs/unity/part-4.md index 8d89e7be..e179cd4a 100644 --- a/docs/unity/part-4.md +++ b/docs/unity/part-4.md @@ -142,7 +142,7 @@ use math::DbVector2; // ... ``` -Next, add the following table to your `lib.rs` file. +Next, add the following reducer to your `lib.rs` file. ```rust #[spacetimedb::reducer] @@ -290,7 +290,7 @@ Well this is pretty fun, but wouldn't it be better if we could eat food and grow Wrong. With SpacetimeDB it's extremely easy. All we have to do is add an `is_overlapping` helper function which does some basic math based on mass radii, and modify our `move_all_player` reducer to loop through every entity in the arena for every circle, checking each for overlaps. This may not be the most efficient way to do collision checking (building a quad tree or doing [spatial hashing](https://conkerjo.wordpress.com/2009/06/13/spatial-hashing-implementation-for-fast-2d-collisions/) might be better), but SpacetimeDB is very fast so for this number of entities it'll be a breeze for SpacetimeDB. -Sometimes simple is best! +Sometimes simple is best! Add the following code to your `lib.rs` file. ```rust const MINIMUM_SAFE_MASS_RATIO: f32 = 0.85; diff --git a/nav.ts b/nav.ts index 467a86d7..609a7f01 100644 --- a/nav.ts +++ b/nav.ts @@ -40,7 +40,7 @@ const nav: Nav = { page('v0.12', 'migration/v0.12', 'migration/v0.12.md'), section('Unity Tutorial - Basic Multiplayer'), - page('Overview', 'unity-tutorial', 'unity/index.md'), + page('Overview', 'unity', 'unity/index.md'), page('1 - Setup', 'unity/part-1', 'unity/part-1.md'), page('2 - Connecting to SpacetimeDB', 'unity/part-2', 'unity/part-2.md'), page('3 - Gameplay', 'unity/part-3', 'unity/part-3.md'), From cced5a3621c2b6a747bdf62f4ee29383b99045c8 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Sat, 18 Jan 2025 01:15:13 -0500 Subject: [PATCH 087/164] Update quickstart.md --- docs/sdks/typescript/quickstart.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/sdks/typescript/quickstart.md b/docs/sdks/typescript/quickstart.md index 96725cbd..660b0bc2 100644 --- a/docs/sdks/typescript/quickstart.md +++ b/docs/sdks/typescript/quickstart.md @@ -1,6 +1,6 @@ -# Typescript Client SDK Quick Start +# Typescript Client SDK Quickstart -In this guide we'll show you how to get up and running with a simple SpacetimDB app with a client written in Typescript. +In this guide we'll show you how to get up and running with a simple SpacetimeDB app with a client written in Typescript. We'll implement a basic single page web app for the module created in our Rust or C# Module Quickstart guides. **Make sure you follow one of these guides before you start on this one.** From 4a9761165f486fc93093e06350105cb98503031a Mon Sep 17 00:00:00 2001 From: rekhoff Date: Fri, 24 Jan 2025 14:59:10 -0800 Subject: [PATCH 088/164] Rekhoff/unity blackhol.io tutorial switcher (#140) * Unified Rust and C# documentation for Blackhol.io Creates a single markdown file for each page, containing both Rust and C# server implementations, using the following format: :::rust # A rust section ::: :::csharp # A csharp section ::: The visibility of each section should then be controlled by a dropdown on the website itself, leveraging tooling not contained in this branch. * Updated language code blocks to clarify the block refers to the server's language Prepped the combined documents to specifically tag for the server side code such that: :::server-rust A rust server implementation section. ::: :::server-csharp A csharp server implementation section. ::: And in a future additionally update the documentation to differentiate the client portion too, like: :::client-unity A Unity client implementation section. ::: * Update part-4.md Added additional clarification that the linked repo contained content beyond the scope of the tutorial. * Updated subscriptions to match new API format * Small fixes to unity tutorial index page * Added a note about column names to the C# tutorial * Merged a section which was now abutting another section * Clarified where you are supposed to put C# reducers * Small, mostly whitespace fixes --------- Co-authored-by: Tyler Cloutier --- docs/unity/index.md | 12 +- docs/unity/part-1.md | 2 +- docs/unity/part-2.md | 209 +++++++++++++++++++++++++++++++-- docs/unity/part-3.md | 273 ++++++++++++++++++++++++++++++++++++++++++- docs/unity/part-4.md | 201 +++++++++++++++++++++++++++++-- 5 files changed, 667 insertions(+), 30 deletions(-) diff --git a/docs/unity/index.md b/docs/unity/index.md index 99ed9420..e477c3c3 100644 --- a/docs/unity/index.md +++ b/docs/unity/index.md @@ -2,26 +2,26 @@ Need help with the tutorial or CLI commands? [Join our Discord server](https://discord.gg/spacetimedb)! -In this tutorial you'll learn how to build a small-scoped MMORPG in Unity, from scratch, using SpacetimeDB. Although, the game we're going to build is small in scope, it'll scale to hundreds of players and will help you get acquanted with all the features and best practices of SpacetimeDB, while building a fun little game. +In this tutorial you'll learn how to build a small-scoped MMORPG in Unity, from scratch, using SpacetimeDB. Although, the game we're going to build is small in scope, it'll scale to hundreds of players and will help you get acquanted with all the features and best practices of SpacetimeDB, while building [a fun little game](https://github.com/ClockworkLabs/Blackholio). By the end, you should have a basic understanding of what SpacetimeDB offers for developers making multiplayer games. The game is inspired by [agar.io](https://agar.io), but SpacetimeDB themed with some fun twists. If you're not familiar [agar.io](https://agar.io), it's a web game in which you and hundreds of other players compete to cultivate mass to become the largest cell in the Petri dish. -Our game, called Blackhol.io, will be similar but with space themes in twists. It should give you a great idea of the types of games you can develop with SpacetimeDB. +Our game, called [Blackhol.io](https://github.com/ClockworkLabs/Blackholio), will be similar but space themed. It should give you a great idea of the types of games you can develop easily with SpacetimeDB. -This tutorial assumes that you have a basic understanding of the Unity Editor, using a command line terminal and coding. We'll give you some CLI commands to execute. If you are using Windows, we recommend using Git Bash or PowerShell. For Mac, we recommend Terminal. +This tutorial assumes that you have a basic understanding of the Unity Editor, using a command line terminal and programming. We'll give you some CLI commands to execute. If you are using Windows, we recommend using Git Bash or PowerShell. For Mac, we recommend Terminal. -The SpacetimeDB Unity SDK minimum supported Unity version is `2021.2` as the SDK requires C# 9. This tutorial has been tested with the following Unity versions. +We recommend using Unity `2022.3.32f1` or later, but the SDK's minimum supported Unity version is `2021.2` as the SDK requires C# 9. This tutorial has been tested with the following Unity versions. - `2022.3.32f1 LTS` - `6000.0.33f1` -Please file an issue [here]() if you encounter an issue with a specific Unity version. +Please file an issue [here](https://github.com/clockworklabs/spacetime-docs/issues) if you encounter an issue with a specific Unity version. ## Blackhol.io Tutorial - Basic Multiplayer -Get started with the core client-server setup. For part 2, you may choose your server module preference of [Rust](/docs/modules/rust) or [C#](/docs/modules/c-sharp): +First you'll get started with the core client/server setup. For part 2, you'll be able to choose between [Rust](/docs/modules/rust) or [C#](/docs/modules/c-sharp) for your server module language: - [Part 1 - Setup](/docs/unity/part-1) - [Part 2 - Connecting to SpacetimeDB](/docs/unity/part-2) diff --git a/docs/unity/part-1.md b/docs/unity/part-1.md index 7678d0f5..f19a28bf 100644 --- a/docs/unity/part-1.md +++ b/docs/unity/part-1.md @@ -38,7 +38,7 @@ Open Unity and create a new project by selecting "New" from the Unity Hub or goi **⚠️ Important: Choose the `Universal 2D`** template to select a template which uses the Unity Universal Render Pipeline. -For `Project Name` use `client`. For Project Location make sure that you use your `blackholio` directory. This is the directory that we created in a previous step. +For `Project Name` use `client-unity`. For Project Location make sure that you use your `blackholio` directory. This is the directory that we created in a previous step. diff --git a/docs/unity/part-2.md b/docs/unity/part-2.md index efa7bdba..9e9936c9 100644 --- a/docs/unity/part-2.md +++ b/docs/unity/part-2.md @@ -10,29 +10,64 @@ If you have not already installed the `spacetime` CLI, check out our [Getting St In your `blackholio` directory, run the following command to initialize the SpacetimeDB server module project with Rust as the language: +:::server-rust +Run the following command to initialize the SpacetimeDB server module project with Rust as the language: + ```bash spacetime init --lang=rust server-rust ``` -This command creates a new folder named `server-rust` alongside your Unity project `client` directory and sets up the SpacetimeDB server project with Rust as the programming language. +This command creates a new folder named `server-rust` alongside your Unity project `client-unity` directory and sets up the SpacetimeDB server project with Rust as the programming language. +::: +:::server-csharp +Run the following command to initialize the SpacetimeDB server module project with C# as the language: + +```bash +spacetime init --lang=csharp server-csharp +``` + +This command creates a new folder named `server-csharp` alongside your Unity project `client-unity` directory and sets up the SpacetimeDB server project with C# as the programming language. +::: ### SpacetimeDB Tables +:::server-rust In this section we'll be making some edits to the file `server-rust/src/lib.rs`. We recommend you open up this file in an IDE like VSCode or RustRover. **Important: Open the `server-rust/src/lib.rs` file and delete its contents. We will be writing it from scratch here.** +::: +:::server-csharp +In this section we'll be making some edits to the file `server-csharp/Lib.cs`. We recommend you open up this file in an IDE like VSCode or Rider. + +**Important: Open the `server-csharp/Lib.cs` file and delete its contents. We will be writing it from scratch here.** +::: First we need to add some imports at the top of the file. Some will remain unused for now. +:::server-rust **Copy and paste into lib.rs:** ```rust use std::time::Duration; use spacetimedb::{rand::Rng, Identity, SpacetimeType, ReducerContext, ScheduleAt, Table, Timestamp}; ``` +::: +:::server-csharp +**Copy and paste into Lib.cs:** + +```csharp +using SpacetimeDB; + +public static partial class Module +{ + +} +``` +::: We are going to start by defining a SpacetimeDB *table*. A *table* in SpacetimeDB is a relational database table which stores rows, similar to something you might find in SQL. SpacetimeDB tables differ from normal relational database tables in that they are stored fully in memory, are blazing fast to access, and are defined in your module code, rather than in SQL. +:::server-rust Each row in a SpacetimeDB table is associated with a `struct` type in Rust. Let's start by defining the `Config` table. This is a simple table which will store some metadata about our game's state. Add the following code to `lib.rs`. @@ -50,18 +85,48 @@ pub struct Config { Let's break down this code. This defines a normal Rust `struct` with two fields: `id` and `world_size`. We have decorated the struct with the `spacetimedb::table` macro. This procedural Rust macro signals to SpacetimeDB that it should create a new SpacetimeDB table with the row type defined by the `Config` type's fields. -> NOTE: It is possible to have two different tables with different table names share the same type. - The `spacetimedb::table` macro takes two parameters, a `name` which is the name of the table and what you will use to query the table in SQL, and a `public` visibility modifier which ensures that the rows of this table are visible to everyone. The `#[primary_key]` attribute, specifies that the `id` field should be used as the primary key of the table. +::: +:::server-csharp +Each row in a SpacetimeDB table is associated with a `struct` type in C#. + +Let's start by defining the `Config` table. This is a simple table which will store some metadata about our game's state. Add the following code inside the `Module` class in `Lib.cs`. + +```csharp +// We're using this table as a singleton, so in this table +// there only be one element where the `id` is 0. +[Table(Name = "config", Public = true)] +public partial struct Config +{ + [PrimaryKey] + public uint id; + public ulong world_size; +} +``` + +Let's break down this code. This defines a normal C# `struct` with two fields: `id` and `world_size`. We have added the `[Table(Name = "config", Public = true)]` attribute the struct. This attribute signals to SpacetimeDB that it should create a new SpacetimeDB table with the row type defined by the `Config` type's fields. + +> Although we're using `lower_snake_case` for our column names to have consistent column names across languages in this tutorial, you can also use `camelCase` or `PascalCase` if you prefer. See [#2168](https://github.com/clockworklabs/SpacetimeDB/issues/2168) for more information. + +The `Table` attribute with takes two parameters, a `Name` which is the name of the table and what you will use to query the table in SQL, and a `Public` visibility modifier which ensures that the rows of this table are visible to everyone. + +The `[PrimaryKey]` attribute, specifies that the `id` field should be used as the primary key of the table. +::: > NOTE: The primary key of a row defines the "identity" of the row. A change to a row which doesn't modify the primary key is considered an update, but if you change the primary key, then you have deleted the old row and inserted a new one. +:::server-rust You can learn more the `table` macro in our [Rust module reference](/docs/modules/rust). +::: +:::server-csharp +You can learn more the `Table` attribute in our [C# module reference](/docs/modules/c-sharp). +::: ### Creating Entities +:::server-rust Next, we're going to define a new `SpacetimeType` called `DbVector2` which we're going to use to store positions. The difference between a `#[derive(SpacetimeType)]` and a `#[spacetimedb(table)]` is that tables actually store data, whereas the deriving `SpacetimeType` just allows you to create a new column of that type in a SpacetimeDB table. Therefore, `DbVector2` is only a type, and does not define a table. **Append to the bottom of lib.rs:** @@ -107,6 +172,60 @@ pub struct Food { pub entity_id: u32, } ``` +::: +:::server-csharp +Next, we're going to define a new `SpacetimeType` called `DbVector2` which we're going to use to store positions. The difference between a `[SpacetimeDB.Type]` and a `[SpacetimeDB.Table]` is that tables actually store data, whereas the deriving `SpacetimeType` just allows you to create a new column of that type in a SpacetimeDB table. Therefore, `DbVector2` is only a type, and does not define a table. + +**Append to the bottom of Lib.cs:** + +```csharp +// This allows us to store 2D points in tables. +[SpacetimeDB.Type] +public partial struct DbVector2 +{ + public float x; + public float y; + + public DbVector2(float x, float y) + { + this.x = x; + this.y = y; + } +} +``` + +Let's create a few tables to represent entities in our game by adding the following to the end of the `Module` class. + +```csharp +[Table(Name = "entity", Public = true)] +public partial struct Entity +{ + [PrimaryKey, AutoInc] + public uint entity_id; + public DbVector2 position; + public uint mass; +} + +[Table(Name = "circle", Public = true)] +public partial struct Circle +{ + [PrimaryKey] + public uint entity_id; + [SpacetimeDB.Index.BTree] + public uint player_id; + public DbVector2 direction; + public float speed; + public ulong last_split_time; +} + +[Table(Name = "food", Public = true)] +public partial struct Food +{ + [PrimaryKey] + public uint entity_id; +} +``` +::: The first table we defined is the `entity` table. An entity represents an object in our game world. We have decided, for convenience, that all entities in our game should share some common fields, namely `position` and `mass`. @@ -120,6 +239,7 @@ The `Circle` table, however, represents an entity that is controlled by a player Next, let's create a table to store our player data. +:::server-rust ```rust #[spacetimedb::table(name = player, public)] #[derive(Debug, Clone)] @@ -134,6 +254,22 @@ pub struct Player { ``` There's a few new concepts we should touch on. First of all, we are using the `#[unique]` attribute on the `player_id` field. This attribute adds a constraint to the table that ensures that only one row in the player table has a particular `player_id`. +::: +:::server-csharp +```csharp +[Table(Name = "player", Public = true)] +public partial struct Player +{ + [PrimaryKey] + public Identity identity; + [Unique, AutoInc] + public uint player_id; + public string name; +} +``` + +There's a few new concepts we should touch on. First of all, we are using the `[Unique]` attribute on the `player_id` field. This attribute adds a constraint to the table that ensures that only one row in the player table has a particular `player_id`. We are also using the `[AutoInc]` attribute on the `player_id` field, which indicates "this field should get automatically assigned an auto-incremented value". +::: We also have an `identity` field which uses the `Identity` type. The `Identity` type is a identifier that SpacetimeDB uses to uniquely assign and authenticate SpacetimeDB users. @@ -141,6 +277,7 @@ We also have an `identity` field which uses the `Identity` type. The `Identity` Next, we write our very first reducer. A reducer is a module function which can be called by clients. Let's write a simple debug reducer to see how they work. +:::server-rust ```rust #[spacetimedb::reducer] pub fn debug(ctx: &ReducerContext) -> Result<(), String> { @@ -148,6 +285,19 @@ pub fn debug(ctx: &ReducerContext) -> Result<(), String> { Ok(()) } ``` +::: +:::server-csharp + +Add this function to the `Module` class in `Lib.cs`: + +```csharp +[Reducer] +public static void Debug(ReducerContext ctx) +{ + Log.Info($"This reducer was called by {ctx.CallerIdentity}"); +} +``` +::: This reducer doesn't update any tables, it just prints out the `Identity` of the client that called it. @@ -177,7 +327,12 @@ This following log output indicates that SpacetimeDB is successfully running on Starting SpacetimeDB listening on 127.0.0.1:3000 ``` +:::server-rust Now that SpacetimeDB is running we can publish our module to the SpacetimeDB host. In a separate terminal window, navigate to the `blackholio/server-rust` directory. +::: +:::server-csharp +Now that SpacetimeDB is running we can publish our module to the SpacetimeDB host. In a separate terminal window, navigate to the `blackholio/server-csharp` directory. +::: If you are not already logged in to the `spacetime` CLI, run the `spacetime login` command log in to your SpacetimeDB website account. Once you are logged in, run `spacetime publish --server local blackholio`. This will publish our Blackholio server logic to SpacetimeDB. @@ -192,11 +347,19 @@ Created new database with name: blackholio, identity: c200d2c69b4524292b91822afa > If you sign into `spacetime login` via GitHub, the token you get will be issued by `auth.spacetimedb.com`. This will also ensure that you can recover your identity in case you lose it. On the other hand, if you do `spacetime login --server-issued-login local`, you will get an identity which is issued directly by your local server. Do note, however, that `--server-issued-login` tokens are not recoverable if lost, and are only recognized by the server that issued them. -Next, use the `spacetime` command to call our newly defined `debug` reducer: +:::server-rust ```sh spacetime call blackholio debug ``` +::: +:::server-csharp +Next, use the `spacetime` command to call our newly defined `Debug` reducer: + +```sh +spacetime call blackholio Debug +``` +::: If the call completed successfully, that command will have no output, but we can see the debug logs by running: @@ -218,6 +381,7 @@ You should see something like the following output: ### Connecting our Client +:::server-rust Next let's connect our client to our module. Let's start by modifying our `debug` reducer. Rename the reducer to be called `connect` and add `client_connected` in parentheses after `spacetimedb::reducer`. The end result should look like this: ```rust @@ -235,7 +399,26 @@ The `client_connected` argument to the `spacetimedb::reducer` macro indicates to > - `init` - Called the first time you publish your module and anytime you clear the database with `spacetime publish --delete-data`. > - `client_connected` - Called when a user connects to the SpacetimeDB module. Their identity can be found in the `sender` value of the `ReducerContext`. > - `client_disconnected` - Called when a user disconnects from the SpacetimeDB module. +::: +:::server-csharp +Next let's connect our client to our module. Let's start by modifying our `Debug` reducer. Rename the reducer to be called `Connect` and add `ReducerKind.ClientConnected` in parentheses after `SpacetimeDB.Reducer`. The end result should look like this: +```csharp +[Reducer(ReducerKind.ClientConnected)] +public static void Connect(ReducerContext ctx) +{ + Log.Info($"{ctx.CallerIdentity} just connected."); +} +``` + +The `ReducerKind.ClientConnected` argument to the `SpacetimeDB.Reducer` attribute indicates to SpacetimeDB that this is a special reducer. This reducer is only every called by SpacetimeDB itself when a client connects to your module. + +> SpacetimeDB gives you the ability to define custom reducers that automatically trigger when certain events occur. +> +> - `ReducerKind.Init` - Called the first time you publish your module and anytime you clear the database with `spacetime publish --delete-data`. +> - `ReducerKind.ClientConnected` - Called when a user connects to the SpacetimeDB module. Their identity can be found in the `CallerIdentity` value of the `ReducerContext`. +> - `ReducerKind.ClientDisconnected` - Called when a user disconnects from the SpacetimeDB module. +::: Publish your module again by running: @@ -247,21 +430,26 @@ spacetime publish --server local blackholio The `spacetime` CLI has built in functionality to let us generate C# types that correspond to our tables, types, and reducers that we can use from our Unity client. +:::server-rust Let's generate our types for our module. In the `blackholio/server-rust` directory run the following command: +::: +:::server-csharp +Let's generate our types for our module. In the `blackholio/server-csharp` directory run the following command: +::: ```sh -spacetime generate --lang csharp --out-dir ../client/Assets/autogen # you can call this anything, I have chosen `autogen` +spacetime generate --lang csharp --out-dir ../client-unity/Assets/autogen # you can call this anything, I have chosen `autogen` ``` -This will generate a set of files in the `client/Assets/autogen` directory which contain the code generated types and reducer functions that are defined in your module, but usable on the client. +This will generate a set of files in the `client-unity/Assets/autogen` directory which contain the code generated types and reducer functions that are defined in your module, but usable on the client. ```sh -ls ../client/Assets/autogen/*.cs -../client/Assets/autogen/Circle.cs ../client/Assets/autogen/DbVector2.cs ../client/Assets/autogen/Food.cs -../client/Assets/autogen/Config.cs ../client/Assets/autogen/Entity.cs ../client/Assets/autogen/Player.cs +ls ../client-unity/Assets/autogen/*.cs +../client-unity/Assets/autogen/Circle.cs ../client-unity/Assets/autogen/DbVector2.cs ../client-unity/Assets/autogen/Food.cs +../client-unity/Assets/autogen/Config.cs ../client-unity/Assets/autogen/Entity.cs ../client-unity/Assets/autogen/Player.cs ``` -This will also generate a file in the `client/Assets/autogen/_Globals` directory with a type aware `DbConnection` class. We will use this class to connect to your module from Unity. +This will also generate a file in the `client-unity/Assets/autogen/_Globals` directory with a type aware `DbConnection` class. We will use this class to connect to your module from Unity. > IMPORTANT! At this point there will be an error in your Unity project. Due to a [known issue](https://docs.unity3d.com/6000.0/Documentation/Manual/csharp-compiler.html) with Unity and C# 9 you need to insert the following code into your Unity project. > @@ -366,7 +554,6 @@ public class GameManager : MonoBehaviour OnSubscriptionApplied?.Invoke(); } - public static bool IsConnected() { return Conn != null && Conn.IsActive; diff --git a/docs/unity/part-3.md b/docs/unity/part-3.md index 22791698..1bfbc51e 100644 --- a/docs/unity/part-3.md +++ b/docs/unity/part-3.md @@ -6,6 +6,7 @@ This progressive tutorial is continued from [part 2](/docs/unity/part-2). ### Spawning Food +:::server-rust Let's start by spawning food into the map. The first thing we need to do is create a new, special reducer called the `init` reducer. SpacetimeDB calls the `init` reducer automatically when first publish your module, and also after any time you run with `publish --delete-data`. It gives you an opportunity to initialize the state of your module before any clients connect. Add this new reducer above our `connect` reducer. @@ -75,13 +76,83 @@ pub fn spawn_food(ctx: &ReducerContext) -> Result<(), String> { Ok(()) } ``` +::: +:::server-csharp +Let's start by spawning food into the map. The first thing we need to do is create a new, special reducer called the `Init` reducer. SpacetimeDB calls the `Init` reducer automatically when you first publish your module, and also after any time you run with `publish --delete-data`. It gives you an opportunity to initialize the state of your module before any clients connect. + +Add this new reducer above our `Connect` reducer. + +```csharp +// Note the `init` parameter passed to the reducer macro. +// That indicates to SpacetimeDB that it should be called +// once upon database creation. +[Reducer(ReducerKind.Init)] +public static void Init(ReducerContext ctx) +{ + Log.Info($"Initializing..."); + ctx.Db.config.Insert(new Config { world_size = 1000 }); +} +``` + +This reducer also demonstrates how to insert new rows into a table. Here we are adding a single `Config` row to the `config` table with the `Insert` function. + +Now that we've ensured that our database always has a valid `world_size` let's spawn some food into the map. Add the following code to the end of the `Module` class. + +```csharp +const uint FOOD_MASS_MIN = 2; +const uint FOOD_MASS_MAX = 4; +const uint TARGET_FOOD_COUNT = 600; + +public static float MassToRadius(uint mass) => MathF.Sqrt(mass); + +[Reducer] +public static void SpawnFood(ReducerContext ctx) +{ + if (ctx.Db.player.Count == 0) //Are there no players yet? + { + return; + } + + var world_size = (ctx.Db.config.id.Find(0) ?? throw new Exception("Config not found")).world_size; + var rng = ctx.Rng; + var food_count = ctx.Db.food.Count; + while (food_count < TARGET_FOOD_COUNT) + { + var food_mass = rng.Range(FOOD_MASS_MIN, FOOD_MASS_MAX); + var food_radius = MassToRadius(food_mass); + var x = rng.Range(food_radius, world_size - food_radius); + var y = rng.Range(food_radius, world_size - food_radius); + var entity = ctx.Db.entity.Insert(new Entity() + { + position = new DbVector2(x, y), + mass = food_mass, + }); + ctx.Db.food.Insert(new Food + { + entity_id = entity.entity_id, + }); + food_count++; + Log.Info($"Spawned food! {entity.entity_id}"); + } +} + +public static float Range(this Random rng, float min, float max) => rng.NextSingle() * (max - min) + min; + +public static uint Range(this Random rng, uint min, uint max) => (uint)rng.NextInt64(min, max); +``` +::: In this reducer, we are using the `world_size` we configured along with the `ReducerContext`'s random number generator `.rng()` function to place 600 food uniformly randomly throughout the map. We've also chosen the `mass` of the food to be a random number between 2 and 4 inclusive. +:::server-csharp +We also added two helper functions so we can get a random range as either a `uint` or a `float`. + +::: Although, we've written the reducer to spawn food, no food will actually be spawned until we call the function while players are logged in. This raises the question, who should call this function and when? We would like for this function to be called periodically to "top up" the amount of food on the map so that it never falls very far below our target amount of food. SpacetimeDB has built in functionality for exactly this. With SpacetimeDB you can schedule your module to call itself in the future or repeatedly with reducers. +:::server-rust In order to schedule a reducer to be called we have to create a new table which specifies when an how a reducer should be called. Add this new table to the top of the file, below your imports. ```rust @@ -95,20 +166,48 @@ pub struct SpawnFoodTimer { ``` Note the `scheduled(spawn_food)` parameter in the table macro. This tells SpacetimeDB that the rows in this table specify a schedule for when the `spawn_food` reducer should be called. Each scheduled table requires a `scheduled_id` and a `scheduled_at` field so that SpacetimeDB can call your reducer, however you can also add your own fields to these rows as well. +::: +:::server-csharp +In order to schedule a reducer to be called we have to create a new table which specifies when an how a reducer should be called. Add this new table to the top of the `Module` class. + +```csharp +[Table(Name = "spawn_food_timer", Scheduled = nameof(SpawnFood), ScheduledAt = nameof(scheduled_at))] +public partial struct SpawnFoodTimer +{ + [PrimaryKey, AutoInc] + public ulong scheduled_id; + public ScheduleAt scheduled_at; +} +``` + +Note the `Scheduled = nameof(SpawnFood)` parameter in the table macro. This tells SpacetimeDB that the rows in this table specify a schedule for when the `SpawnFood` reducer should be called. Each scheduled table requires a `scheduled_id` and a `scheduled_at` field so that SpacetimeDB can call your reducer, however you can also add your own fields to these rows as well. +::: You can create, delete, or change a schedule by inserting, deleting, or updating rows in this table. You will see an error telling you that the `spawn_food` reducer needs to take two arguments, but currently only takes one. This is because the schedule row must be passed in to all scheduled reducers. Modify your `spawn_food` reducer to take the scheduled row as an argument. +:::server-rust ```rust #[spacetimedb::reducer] pub fn spawn_food(ctx: &ReducerContext, _timer: SpawnFoodTimer) -> Result<(), String> { // ... } ``` +::: +:::server-csharp +```csharp +[Reducer] +public static void SpawnFood(ReducerContext ctx, SpawnFoodTimer _timer) +{ + // ... +} +``` +::: In our case we aren't interested in the data on the row, so we name the argument `_timer`. +:::server-rust Let's modify our `init` reducer to schedule our `spawn_food` reducer to be called every 500 milliseconds. ```rust @@ -128,6 +227,25 @@ pub fn init(ctx: &ReducerContext) -> Result<(), String> { ``` > You can use `ScheduleAt::Interval` to schedule a reducer call at an interval like we're doing here. SpacetimeDB will continue to call the reducer at this interval until you remove the row. You can also use `ScheduleAt::Time()` to specify a specific at which to call a reducer once. SpacetimeDB will remove that row automatically after the reducer has been called. +::: +:::server-csharp +Let's modify our `Init` reducer to schedule our `SpawnFood` reducer to be called every 500 milliseconds. + +```csharp +[Reducer(ReducerKind.Init)] +public static void Init(ReducerContext ctx) +{ + Log.Info($"Initializing..."); + ctx.Db.config.Insert(new Config { world_size = 1000 }); + ctx.Db.spawn_food_timer.Insert(new SpawnFoodTimer + { + scheduled_at = new ScheduleAt.Interval(TimeSpan.FromMilliseconds(500)) + }); +} +``` + +> You can use `ScheduleAt.Interval` to schedule a reducer call at an interval like we're doing here. SpacetimeDB will continue to call the reducer at this interval until you remove the row. You can also use `ScheduleAt.Time()` to specify a specific at which to call a reducer once. SpacetimeDB will remove that row automatically after the reducer has been called. +::: ### Logging Players In @@ -135,12 +253,20 @@ Let's continue building out our server module by modifying it to log in a player Let's add a second table to our `Player` struct. Modify the `Player` struct by adding this above the struct: +:::server-rust ```rust #[spacetimedb::table(name = logged_out_player)] ``` +::: +:::server-csharp +```csharp +[Table(Name = "logged_out_player")] +``` +::: Your struct should now look like this: +:::server-rust ```rust #[spacetimedb::table(name = player, public)] #[spacetimedb::table(name = logged_out_player)] @@ -154,6 +280,21 @@ pub struct Player { name: String, } ``` +::: +:::server-csharp +```csharp +[Table(Name = "player", Public = true)] +[Table(Name = "logged_out_player")] +public partial struct Player +{ + [PrimaryKey] + public Identity identity; + [Unique, AutoInc] + public uint player_id; + public string name; +} +``` +::: This line creates an additional tabled called `logged_out_player` whose rows share the same `Player` type as in the `player` table. @@ -161,6 +302,7 @@ This line creates an additional tabled called `logged_out_player` whose rows sha > > If your client isn't syncing rows from the server, check that your table is not accidentally marked private. +:::server-rust Next, modify your `connect` reducer and add a new `disconnect` reducer below it: ```rust @@ -168,7 +310,10 @@ Next, modify your `connect` reducer and add a new `disconnect` reducer below it: pub fn connect(ctx: &ReducerContext) -> Result<(), String> { if let Some(player) = ctx.db.logged_out_player().identity().find(&ctx.sender) { ctx.db.player().insert(player.clone()); - ctx.db.logged_out_player().delete(player); + ctx.db + .logged_out_player() + .identity() + .delete(&player.identity); } else { ctx.db.player().try_insert(Player { identity: ctx.sender, @@ -187,11 +332,52 @@ pub fn disconnect(ctx: &ReducerContext) -> Result<(), String> { .identity() .find(&ctx.sender) .ok_or("Player not found")?; + let player_id = player.player_id; ctx.db.logged_out_player().insert(player); ctx.db.player().identity().delete(&ctx.sender); + + // Remove any circles from the arena + for circle in ctx.db.circle().player_id().filter(&player_id) { + ctx.db.entity().entity_id().delete(&circle.entity_id); + ctx.db.circle().entity_id().delete(&circle.entity_id); + } + Ok(()) } ``` +::: +:::server-csharp +Next, modify your `Connect` reducer and add a new `Disconnect` reducer below it: + +```csharp +[Reducer(ReducerKind.ClientConnected)] +public static void Connect(ReducerContext ctx) +{ + var player = ctx.Db.logged_out_player.identity.Find(ctx.CallerIdentity); + if (player != null) + { + ctx.Db.player.Insert(player.Value); + ctx.Db.logged_out_player.identity.Delete(player.Value.identity); + } + else + { + ctx.Db.player.Insert(new Player + { + identity = ctx.CallerIdentity, + name = "", + }); + } +} + +[Reducer(ReducerKind.ClientDisconnected)] +public static void Disconnect(ReducerContext ctx) +{ + var player = ctx.Db.player.identity.Find(ctx.CallerIdentity) ?? throw new Exception("Player not found"); + ctx.Db.logged_out_player.Insert(player); + ctx.Db.player.identity.Delete(player.identity); +} +``` +::: Now when a client connects, if the player corresponding to the client is in the `logged_out_player` table, we will move them into the `player` table, thus indicating that they are logged in and connected. For any new unrecognized client connects we will create a `Player` and insert it into the `player` table. @@ -208,6 +394,7 @@ When a player disconnects, we will transfer their player row from the `player` t Now that we've got our food spawning and our players set up, let's create a match and spawn player circle entities into it. The first thing we should do before spawning a player into a match is give them a name. +:::server-rust Add the following to the bottom of your file. ```rust @@ -271,9 +458,65 @@ fn spawn_circle_at( ``` The `enter_game` reducer takes one argument, the player's `name`. We can use this name to display as a label for the player in the match, by storing the name on the player's row. We are also spawning some circles for the player to control now that they are entering the game. To do this, we choose a random position within the bounds of the arena and create a new entity and corresponding circle row. +::: +:::server-csharp +Add the following to the end of the `Module` class. + +```csharp +const uint START_PLAYER_MASS = 15; + +[Reducer] +public static void EnterGame(ReducerContext ctx, string name) +{ + Log.Info($"Creating player with name {name}"); + var player = ctx.Db.player.identity.Find(ctx.CallerIdentity) ?? throw new Exception("Player not found"); + player.name = name; + ctx.Db.player.identity.Update(player); + SpawnPlayerInitialCircle(ctx, player.player_id); +} + +public static Entity SpawnPlayerInitialCircle(ReducerContext ctx, uint player_id) +{ + var rng = ctx.Rng; + var world_size = (ctx.Db.config.id.Find(0) ?? throw new Exception("Config not found")).world_size; + var player_start_radius = MassToRadius(START_PLAYER_MASS); + var x = rng.Range(player_start_radius, world_size - player_start_radius); + var y = rng.Range(player_start_radius, world_size - player_start_radius); + return SpawnCircleAt( + ctx, + player_id, + START_PLAYER_MASS, + new DbVector2(x, y), + ctx.Timestamp + ); +} + +public static Entity SpawnCircleAt(ReducerContext ctx, uint player_id, uint mass, DbVector2 position, DateTimeOffset timestamp) +{ + var entity = ctx.Db.entity.Insert(new Entity + { + position = position, + mass = mass, + }); + + ctx.Db.circle.Insert(new Circle + { + entity_id = entity.entity_id, + player_id = player_id, + direction = new DbVector2(0, 1), + speed = 0f, + last_split_time = (ulong)timestamp.ToUnixTimeMilliseconds(), + }); + return entity; +} +``` + +The `EnterGame` reducer takes one argument, the player's `name`. We can use this name to display as a label for the player in the match, by storing the name on the player's row. We are also spawning some circles for the player to control now that they are entering the game. To do this, we choose a random position within the bounds of the arena and create a new entity and corresponding circle row. +::: Let's also modify our `disconnect` reducer to remove the circles from the arena when the player disconnects from the server. +:::server-rust ```rust #[spacetimedb::reducer(client_disconnected)] pub fn disconnect(ctx: &ReducerContext) -> Result<(), String> { @@ -296,6 +539,26 @@ pub fn disconnect(ctx: &ReducerContext) -> Result<(), String> { Ok(()) } ``` +::: +:::server-csharp +```csharp +[Reducer(ReducerKind.ClientDisconnected)] +public static void Disconnect(ReducerContext ctx) +{ + var player = ctx.Db.player.identity.Find(ctx.CallerIdentity) ?? throw new Exception("Player not found"); + // Remove any circles from the arena + foreach (var circle in ctx.Db.circle.player_id.Filter(player.player_id)) + { + var entity = ctx.Db.entity.entity_id.Find(circle.entity_id) ?? throw new Exception("Could not find circle"); + ctx.Db.entity.entity_id.Delete(entity.entity_id); + ctx.Db.circle.entity_id.Delete(entity.entity_id); + } + ctx.Db.logged_out_player.Insert(player); + ctx.Db.player.identity.Delete(player.identity); +} +``` +::: + Finally publish the new module to SpacetimeDB with this command: @@ -910,14 +1173,18 @@ Lastly modify the `GameManager.SetupArea` method to set the `WorldSize` on the ` ### Entering the Game +:::server-rust At this point, you may need to regenerate your bindings the following command from the `server-rust` directory. +::: +:::server-csharp +At this point, you may need to regenerate your bindings the following command from the `server-csharp` directory. +::: ```sh -spacetime generate --lang csharp --out-dir ../client/Assets/autogen +spacetime generate --lang csharp --out-dir ../client-unity/Assets/autogen ``` > **BUG WORKAROUND NOTE**: As of `1.0.0-rc3` you will now have a compilation error in Unity. There is currently a bug in the C# code generation that requires you to delete `autogen/LoggedOutPlayer.cs` after running this command. - The last step is to call the `enter_game` reducer on the server, passing in a username for our player, which will spawn a circle for our player. For the sake of simplicity, let's call the `enter_game` reducer from the `HandleSubscriptionApplied` callback with the name "3Blave". ```cs diff --git a/docs/unity/part-4.md b/docs/unity/part-4.md index e179cd4a..78c9a3cd 100644 --- a/docs/unity/part-4.md +++ b/docs/unity/part-4.md @@ -8,6 +8,7 @@ This progressive tutorial is continued from [part 3](/docs/unity/part-3). At this point, we're very close to having a working game. All we have to do is modify our server to allow the player to move around, and to simulate the physics and collisions of the game. +:::server-rust Let's start by building out a simple math library to help us do collision calculations. Create a new `math.rs` file in the `server-rust/src` directory and add the following contents. Let's also move the `DbVector2` type from `lib.rs` into this file. ```rust @@ -163,9 +164,58 @@ pub fn update_player_input(ctx: &ReducerContext, direction: DbVector2) -> Result ``` This is a simple reducer that takes the movement input from the client and applies them to all circles that that player controls. Note that it is not possible for a player to move another player's circles using this reducer, because the `ctx.sender` value is not set by the client. Instead `ctx.sender` is set by SpacetimeDB after it has authenticated that sender. You can rest assured that the caller has been authenticated as that player by the time this reducer is called. +::: +:::server-csharp +Let's start by building out a simple math library to help us do collision calculations. Create a new `Math.cs` file in the `csharp-server` directory and add the following contents. Let's also remove the `DbVector2` type from `Lib.cs`. + +```csharp +[SpacetimeDB.Type] +public partial struct DbVector2 +{ + public float x; + public float y; + + public DbVector2(float x, float y) + { + this.x = x; + this.y = y; + } + + public float SqrMagnitude => x * x + y * y; + public float Magnitude => MathF.Sqrt(SqrMagnitude); + public DbVector2 Normalized => this / Magnitude; + + public static DbVector2 operator +(DbVector2 a, DbVector2 b) => new DbVector2(a.x + b.x, a.y + b.y); + public static DbVector2 operator -(DbVector2 a, DbVector2 b) => new DbVector2(a.x - b.x, a.y - b.y); + public static DbVector2 operator *(DbVector2 a, float b) => new DbVector2(a.x * b, a.y * b); + public static DbVector2 operator /(DbVector2 a, float b) => new DbVector2(a.x / b, a.y / b); +} +``` + +Next, add the following reducer to the `Module` class of your `Lib.cs` file. + +```csharp +[Reducer] +public static void UpdatePlayerInput(ReducerContext ctx, DbVector2 direction) +{ + var player = ctx.Db.player.identity.Find(ctx.CallerIdentity) ?? throw new Exception("Player not found"); + foreach (var c in ctx.Db.circle.player_id.Filter(player.player_id)) + { + var circle = c; + circle.direction = direction.Normalized; + circle.speed = Math.Clamp(direction.Magnitude, 0f, 1f); + ctx.Db.circle.entity_id.Update(circle); + } + +} +``` + +This is a simple reducer that takes the movement input from the client and applies them to all circles that that player controls. Note that it is not possible for a player to move another player's circles using this reducer, because the `ctx.CallerIdentity` value is not set by the client. Instead `ctx.CallerIdentity` is set by SpacetimeDB after it has authenticated that sender. You can rest assured that the caller has been authenticated as that player by the time this reducer is called. +::: Finally, let's schedule a reducer to run every 50 milliseconds to move the player's circles around based on the most recently set player input. +:::server-rust ```rust #[spacetimedb::table(name = move_all_players_timer, scheduled(move_all_players))] pub struct MoveAllPlayersTimer { @@ -208,21 +258,70 @@ pub fn move_all_players(ctx: &ReducerContext, _timer: MoveAllPlayersTimer) -> Re Ok(()) } ``` +::: +:::server-csharp +```csharp +[Table(Name = "move_all_players_timer", Scheduled = nameof(MoveAllPlayers), ScheduledAt = nameof(scheduled_at))] +public partial struct MoveAllPlayersTimer +{ + [PrimaryKey, AutoInc] + public ulong scheduled_id; + public ScheduleAt scheduled_at; +} + +const uint START_PLAYER_SPEED = 10; + +public static float MassToMaxMoveSpeed(uint mass) => 2f * START_PLAYER_SPEED / (1f + MathF.Sqrt((float)mass / START_PLAYER_MASS)); + +[Reducer] +public static void MoveAllPlayers(ReducerContext ctx, MoveAllPlayersTimer timer) +{ + var world_size = (ctx.Db.config.id.Find(0) ?? throw new Exception("Config not found")).world_size; + + var circle_directions = ctx.Db.circle.Iter().Select(c => (c.entity_id, c.direction * c.speed)).ToDictionary(); + + // Handle player input + foreach (var circle in ctx.Db.circle.Iter()) + { + var circle_entity = ctx.Db.entity.entity_id.Find(circle.entity_id) ?? throw new Exception("Circle has no entity"); + var circle_radius = MassToRadius(circle_entity.mass); + var direction = circle_directions[circle.entity_id]; + var new_pos = circle_entity.position + direction * MassToMaxMoveSpeed(circle_entity.mass); + circle_entity.position.x = Math.Clamp(new_pos.x, circle_radius, world_size - circle_radius); + circle_entity.position.y = Math.Clamp(new_pos.y, circle_radius, world_size - circle_radius); + ctx.Db.entity.entity_id.Update(circle_entity); + } +} +``` +::: This reducer is very similar to a standard game "tick" or "frame" that you might find in an ordinary game server or similar to something like the `Update` loop in a game engine like Unity. We've scheduled it every 50 milliseconds and we can use it to step forward our simulation by moving all the circles a little bit further in the direction they're moving. In this reducer, we're just looping through all the circles in the game and updating their position based on their direction, speed, and mass. Just basic physics. +:::server-rust Add the following to your `init` reducer to schedule the `move_all_players` reducer to run every 50 milliseconds. ```rust - ctx.db - .move_all_players_timer() - .try_insert(MoveAllPlayersTimer { - scheduled_id: 0, - scheduled_at: ScheduleAt::Interval(Duration::from_millis(50).as_micros() as u64), - })?; +ctx.db + .move_all_players_timer() + .try_insert(MoveAllPlayersTimer { + scheduled_id: 0, + scheduled_at: ScheduleAt::Interval(Duration::from_millis(50).as_micros() as u64), + })?; ``` +::: +:::server-csharp +Add the following to your `Init` reducer to schedule the `MoveAllPlayers` reducer to run every 50 milliseconds. + +```csharp +ctx.Db.move_all_players_timer.Insert(new MoveAllPlayersTimer +{ + scheduled_at = new ScheduleAt.Interval(TimeSpan.FromMilliseconds(50)) +}); +``` +::: + Republish your module with: @@ -233,7 +332,7 @@ spacetime publish --server local blackholio --delete-data Regenerate your server bindings with: ```sh -spacetime generate --lang csharp --out-dir ../client/Assets/autogen +spacetime generate --lang csharp --out-dir ../client-unity/Assets/autogen ``` > **BUG WORKAROUND NOTE**: You may have to delete LoggedOutPlayer.cs again. @@ -288,9 +387,10 @@ Let's try it out! Press play and roam freely around the arena! Now we're cooking Well this is pretty fun, but wouldn't it be better if we could eat food and grow our circle? Surely, that's going to be a pain, right? +:::server-rust Wrong. With SpacetimeDB it's extremely easy. All we have to do is add an `is_overlapping` helper function which does some basic math based on mass radii, and modify our `move_all_player` reducer to loop through every entity in the arena for every circle, checking each for overlaps. This may not be the most efficient way to do collision checking (building a quad tree or doing [spatial hashing](https://conkerjo.wordpress.com/2009/06/13/spatial-hashing-implementation-for-fast-2d-collisions/) might be better), but SpacetimeDB is very fast so for this number of entities it'll be a breeze for SpacetimeDB. -Sometimes simple is best! Add the following code to your `lib.rs` file. +Sometimes simple is best! Add the following code to your `lib.rs` file and make sure to replace the existing `move_all_players` reducer. ```rust const MINIMUM_SAFE_MASS_RATIO: f32 = 0.85; @@ -366,6 +466,84 @@ pub fn move_all_players(ctx: &ReducerContext, _timer: MoveAllPlayersTimer) -> Re Ok(()) } ``` +::: +:::server-csharp +Wrong. With SpacetimeDB it's extremely easy. All we have to do is add an `IsOverlapping` helper function which does some basic math based on mass radii, and modify our `MoveAllPlayers` reducer to loop through every entity in the arena for every circle, checking each for overlaps. This may not be the most efficient way to do collision checking (building a quad tree or doing [spatial hashing](https://conkerjo.wordpress.com/2009/06/13/spatial-hashing-implementation-for-fast-2d-collisions/) might be better), but SpacetimeDB is very fast so for this number of entities it'll be a breeze for SpacetimeDB. + +Sometimes simple is best! Add the following code to the `Module` class of your `Lib.cs` file and make sure to replace the existing `MoveAllPlayers` reducer. + +```csharp +const float MINIMUM_SAFE_MASS_RATIO = 0.85f; + +public static bool IsOverlapping(Entity a, Entity b) +{ + var dx = a.position.x - b.position.x; + var dy = a.position.y - b.position.y; + var distance_sq = dx * dx + dy * dy; + + var radius_a = MassToRadius(a.mass); + var radius_b = MassToRadius(b.mass); + + // If the distance between the two circle centers is less than the + // maximum radius, then the center of the smaller circle is inside + // the larger circle. This gives some leeway for the circles to overlap + // before being eaten. + var max_radius = radius_a > radius_b ? radius_a: radius_b; + return distance_sq <= max_radius * max_radius; +} + +[Reducer] +public static void MoveAllPlayers(ReducerContext ctx, MoveAllPlayersTimer timer) +{ + var world_size = (ctx.Db.config.id.Find(0) ?? throw new Exception("Config not found")).world_size; + + // Handle player input + foreach (var circle in ctx.Db.circle.Iter()) + { + var circle_entity = ctx.Db.entity.entity_id.Find(circle.entity_id) ?? throw new Exception("Circle has no entity"); + var circle_radius = MassToRadius(circle_entity.mass); + var direction = circle.direction * circle.speed; + var new_pos = circle_entity.position + direction * MassToMaxMoveSpeed(circle_entity.mass); + circle_entity.position.x = Math.Clamp(new_pos.x, circle_radius, world_size - circle_radius); + circle_entity.position.y = Math.Clamp(new_pos.y, circle_radius, world_size - circle_radius); + + // Check collisions + foreach (var entity in ctx.Db.entity.Iter()) + { + if (entity.entity_id == circle_entity.entity_id) + { + continue; + } + if (IsOverlapping(circle_entity, entity)) + { + // Check to see if we're overlapping with food + if (ctx.Db.food.entity_id.Find(entity.entity_id).HasValue) { + ctx.Db.entity.entity_id.Delete(entity.entity_id); + ctx.Db.food.entity_id.Delete(entity.entity_id); + circle_entity.mass += entity.mass; + } + + // Check to see if we're overlapping with another circle owned by another player + var other_circle = ctx.Db.circle.entity_id.Find(entity.entity_id); + if (other_circle.HasValue && + other_circle.Value.player_id != circle.player_id) + { + var mass_ratio = (float)entity.mass / circle_entity.mass; + if (mass_ratio < MINIMUM_SAFE_MASS_RATIO) + { + ctx.Db.entity.entity_id.Delete(entity.entity_id); + ctx.Db.circle.entity_id.Delete(entity.entity_id); + circle_entity.mass += entity.mass; + } + } + } + } + ctx.Db.entity.entity_id.Update(circle_entity); + } +} +``` +::: + For every circle, we look at all other entities. If they are overlapping then for food, we add the mass of the food to the circle and delete the food, otherwise if it's a circle we delete the smaller circle and add the mass to the bigger circle. @@ -383,7 +561,12 @@ Notice that the food automatically respawns as you vaccuum them up. This is beca # Conclusion +:::server-rust So far you've learned how to configure a new Unity project to work with SpacetimeDB, how to develop, build, and publish a SpacetimeDB server module. Within the module, you've learned how to create tables, update tables, and write reducers. You've learned about special reducers like `client_connected` and `init` and how to created scheduled reducers. You learned how we can used scheduled reducers to implement a physics simulation right within your module. +::: +:::server-csharp +So far you've learned how to configure a new Unity project to work with SpacetimeDB, how to develop, build, and publish a SpacetimeDB server module. Within the module, you've learned how to create tables, update tables, and write reducers. You've learned about special reducers like `ClientConnected` and `Init` and how to created scheduled reducers. You learned how we can used scheduled reducers to implement a physics simulation right within your module. +::: You've also learned how view module logs and connect your client to your server module, call reducers from the client and synchronize the data with client. Finally you learned how to use that synchronized data to draw game objects on the screen, so that we can interact with them and play a game! @@ -402,7 +585,7 @@ There's still plenty more we can do to build this into a proper game though. For - Nice shaders - Space theme! -Fortunately, we've done that for you! If you'd like to check out the completed tutorial game you can download it on GitHub: +Fortunately, we've done that for you! If you'd like to check out the completed tutorial game, with these additional features, you can download it on GitHub: https://github.com/ClockworkLabs/Blackholio From 4e82fa020f9f3a8839d9c62729e8eb44b8123c32 Mon Sep 17 00:00:00 2001 From: rekhoff Date: Mon, 27 Jan 2025 15:45:45 -0800 Subject: [PATCH 089/164] Changed subscript to SubscribeToAllTables (#155) Missed updating the HandleConnect from `Subscribe("SELECT * FROM *"` to `SubscribeToAllTables()` in the updated code block on page 3. --- docs/unity/part-3.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/unity/part-3.md b/docs/unity/part-3.md index 1bfbc51e..ecd1990a 100644 --- a/docs/unity/part-3.md +++ b/docs/unity/part-3.md @@ -1035,7 +1035,7 @@ Next lets add some callbacks when rows change in the database. Modify the `Handl // Request all tables Conn.SubscriptionBuilder() .OnApplied(HandleSubscriptionApplied) - .Subscribe("SELECT * FROM *"); + .SubscribeToAllTables(); } ``` From e93c330d3fba072727c8b63b7008f3d7b9969731 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Mon, 27 Jan 2025 19:41:07 -0500 Subject: [PATCH 090/164] Updated the TypeScript quickstart guide to use the new 1.0 API (#141) * Updated the quickstart guide to use the new 1.0 API * Completed quickstart rewrite * Update docs/sdks/typescript/quickstart.md Co-authored-by: Phoebe Goldman * Update docs/sdks/typescript/quickstart.md Co-authored-by: Phoebe Goldman * Update docs/sdks/typescript/quickstart.md Co-authored-by: Phoebe Goldman * Update docs/sdks/typescript/quickstart.md Co-authored-by: Phoebe Goldman * Update docs/sdks/typescript/quickstart.md Co-authored-by: Phoebe Goldman * Clarification * Update docs/sdks/typescript/quickstart.md Co-authored-by: Phoebe Goldman * Update docs/sdks/typescript/quickstart.md Co-authored-by: Phoebe Goldman * Wrong type of quotes * Update docs/sdks/typescript/quickstart.md Co-authored-by: Phoebe Goldman * Update docs/sdks/typescript/quickstart.md Co-authored-by: Phoebe Goldman * Apply suggestions from code review Co-authored-by: Phoebe Goldman Co-authored-by: rekhoff * Address review comments --------- Co-authored-by: Phoebe Goldman Co-authored-by: rekhoff --- docs/sdks/typescript/quickstart.md | 757 +++++++++++++++++------------ 1 file changed, 456 insertions(+), 301 deletions(-) diff --git a/docs/sdks/typescript/quickstart.md b/docs/sdks/typescript/quickstart.md index 660b0bc2..13f04e21 100644 --- a/docs/sdks/typescript/quickstart.md +++ b/docs/sdks/typescript/quickstart.md @@ -1,8 +1,13 @@ -# Typescript Client SDK Quickstart +# TypeScript Client SDK Quickstart -In this guide we'll show you how to get up and running with a simple SpacetimeDB app with a client written in Typescript. +In this guide, you'll learn how to use TypeScript to create a SpacetimeDB client application. -We'll implement a basic single page web app for the module created in our Rust or C# Module Quickstart guides. **Make sure you follow one of these guides before you start on this one.** +Please note that TypeScript is supported as a client language only. **Before you get started on this guide**, you should complete one of the quickstart guides for creating a SpacetimeDB server module listed below. + +- [Rust](/docs/modules/rust/quickstart) +- [C#](/docs/modules/c-sharp/quickstart) + +By the end of this introduciton, you will have created a basic single page web app which connects to the `quickstart-chat` module created in the above module quickstart guides. ## Project structure @@ -12,61 +17,64 @@ Enter the directory `quickstart-chat` you created in the [Rust Module Quickstart cd quickstart-chat ``` -Within it, create a `client` react app: +Within it, create a `client` React app: ```bash -npx create-react-app client --template typescript +pnpm create vite@latest client -- --template react-ts +cd client +pnpm install ``` We also need to install the `spacetime-client-sdk` package: ```bash -cd client -npm install @clockworklabs/spacetimedb-sdk +pnpm install @clockworklabs/spacetimedb-sdk@1.0.0-rc1.0 ``` +> If you are using another package manager like `yarn` or `npm`, the same steps should work with the appropriate commands for those tools. + +You can now `pnpm run dev` to see the Vite template app running at `http://localhost:5173`. + ## Basic layout -We are going to start by creating a basic layout for our app. The page contains four sections: +The app we're going to create is a basic chat application. We are going to start by creating a layout for our app. The webpage page will contain four sections: 1. A profile section, where we can set our name. 2. A message section, where we can see all the messages. 3. A system section, where we can see system messages. 4. A new message section, where we can send a new message. -The `onSubmitNewName` and `onMessageSubmit` callbacks will be called when the user clicks the submit button in the profile and new message sections, respectively. We'll hook these up later. - Replace the entire contents of `client/src/App.tsx` with the following: -```typescript -import React, { useEffect, useState } from "react"; -import logo from "./logo.svg"; -import "./App.css"; +```tsx +import React, { useEffect, useState } from 'react'; +import './App.css'; -export type MessageType = { - name: string; - message: string; +export type PrettyMessage = { + senderName: string; + text: string; }; function App() { - const [newName, setNewName] = useState(""); + const [newName, setNewName] = useState(''); const [settingName, setSettingName] = useState(false); - const [name, setName] = useState(""); - const [systemMessage, setSystemMessage] = useState(""); - const [messages, setMessages] = useState([]); + const [systemMessage, setSystemMessage] = useState(''); + const [newMessage, setNewMessage] = useState(''); - const [newMessage, setNewMessage] = useState(""); + const prettyMessages: PrettyMessage[] = []; + + const name = ''; const onSubmitNewName = (e: React.FormEvent) => { e.preventDefault(); setSettingName(false); - // Fill in app logic here + // TODO: Call `setName` reducer }; const onMessageSubmit = (e: React.FormEvent) => { e.preventDefault(); - // Fill in app logic here - setNewMessage(""); + setNewMessage(''); + // TODO: Call `sendMessage` reducer }; return ( @@ -89,9 +97,8 @@ function App() {
setNewName(e.target.value)} + onChange={e => setNewName(e.target.value)} /> @@ -99,19 +106,19 @@ function App() {

Messages

- {messages.length < 1 &&

No messages

} + {prettyMessages.length < 1 &&

No messages

}
- {messages.map((message, key) => ( + {prettyMessages.map((message, key) => (

- {message.name} + {message.senderName}

-

{message.message}

+

{message.text}

))}
-
+

System

{systemMessage}

@@ -121,16 +128,16 @@ function App() {

New Message

@@ -142,365 +149,513 @@ function App() { export default App; ``` -Now when you run `npm start`, you should see a basic chat app that does not yet send or receive messages. - -## Generate your module types - -The `spacetime` CLI's `generate` command will generate client-side interfaces for the tables, reducers and types defined in your server module. - -In your `quickstart-chat` directory, run: - -```bash -mkdir -p client/src/module_bindings -spacetime generate --lang typescript --out-dir client/src/module_bindings --project-path server -``` +We have configured the `onSubmitNewName` and `onSubmitMessage` callbacks to be called when the user clicks the submit button in the profile and new message sections, respectively. For now, they do nothing when called, but later we'll add some logic to call SpacetimeDB reducers when these callbacks are called. + +Let's also make it pretty. Replace the contents of `client/src/App.css` with the following: + +```css +.App { + display: grid; + /* + 3 rows: + 1) Profile + 2) Main content (left = message, right = system) + 3) New message + */ + grid-template-rows: auto 1fr auto; + /* 2 columns: left for chat, right for system */ + grid-template-columns: 2fr 1fr; + + height: 100vh; /* fill viewport height */ + width: clamp(300px, 100%, 1200px); + margin: 0 auto; +} -Take a look inside `client/src/module_bindings`. The CLI should have generated four files: +/* ----- Profile (Row 1, spans both columns) ----- */ +.profile { + grid-column: 1 / 3; + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem; + border-bottom: 1px solid var(--theme-color); +} -``` -module_bindings -├── message.ts -├── send_message_reducer.ts -├── set_name_reducer.ts -└── user.ts -``` +.profile h1 { + margin-right: auto; /* pushes name/edit form to the right */ +} -We need to import these types into our `client/src/App.tsx`. While we are at it, we will also import the SpacetimeDBClient class from our SDK. In order to let the SDK know what tables and reducers we will be using we need to also register them. +.profile form { + display: flex; + flex-grow: 1; + align-items: center; + gap: 0.5rem; + max-width: 300px; +} -```typescript -import { - SpacetimeDBClient, - Identity, - Address, -} from '@clockworklabs/spacetimedb-sdk'; +.profile form input { + background-color: var(--textbox-color); +} -import Message from './module_bindings/message'; -import User from './module_bindings/user'; -import SendMessageReducer from './module_bindings/send_message_reducer'; -import SetNameReducer from './module_bindings/set_name_reducer'; +/* ----- Chat Messages (Row 2, Col 1) ----- */ +.message { + grid-row: 2 / 3; + grid-column: 1 / 2; + + /* Ensure this section scrolls if content is long */ + overflow-y: auto; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 1rem; +} -SpacetimeDBClient.registerReducers(SendMessageReducer, SetNameReducer); -SpacetimeDBClient.registerTables(Message, User); -``` +.message h1 { + margin-right: 0.5rem; +} -## Create your SpacetimeDB client +/* ----- System Panel (Row 2, Col 2) ----- */ +.system { + grid-row: 2 / 3; + grid-column: 2 / 3; + + /* Also scroll independently if needed */ + overflow-y: auto; + padding: 1rem; + border-left: 1px solid var(--theme-color); + white-space: pre-wrap; + font-family: monospace; +} -First, we need to create a SpacetimeDB client and connect to the module. Create your client at the top of the `App` function. +/* ----- New Message (Row 3, spans columns 1-2) ----- */ +.new-message { + grid-column: 1 / 3; + display: flex; + justify-content: center; + align-items: center; + padding: 1rem; + border-top: 1px solid var(--theme-color); +} -We are going to create a stateful variable to store our client's SpacetimeDB identity when we receive it. Also, we are using `localStorage` to retrieve your auth token if this client has connected before. We will explain these later. +.new-message form { + display: flex; + flex-direction: column; + gap: 0.75rem; + width: 100%; + max-width: 600px; +} -Replace `` with the name you chose when publishing your module during the module quickstart. If you are using SpacetimeDB Cloud, the host will be `wss://spacetimedb.com/spacetimedb`. +.new-message form h3 { + margin-bottom: 0.25rem; +} -Add this before the `App` function declaration: +/* Distinct background for the textarea */ +.new-message form textarea { + font-family: monospace; + font-weight: 400; + font-size: 1rem; + resize: vertical; + min-height: 80px; + background-color: var(--textbox-color); + color: inherit; + + /* Subtle shadow for visibility */ + box-shadow: + 0 1px 3px rgba(0, 0, 0, 0.12), + 0 1px 2px rgba(0, 0, 0, 0.24); +} -```typescript -let token = localStorage.getItem('auth_token') || undefined; -var spacetimeDBClient = new SpacetimeDBClient( - 'ws://localhost:3000', - 'chat', - token -); +@media (prefers-color-scheme: dark) { + .new-message form textarea { + box-shadow: 0 0 0 1px #17492b; + } +} ``` -Inside the `App` function, add a few refs: - -```typescript -let local_identity = useRef(undefined); -let initialized = useRef(false); -const client = useRef(spacetimeDBClient); -``` +Next we need to replace the global styles in `client/src/index.css` as well: -## Register callbacks and connect +```css +/* ----- CSS Reset & Global Settings ----- */ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} -We need to handle several sorts of events: +/* ----- Color Variables ----- */ +:root { + --theme-color: #3dc373; + --theme-color-contrast: #08180e; + --textbox-color: #edfef4; + color-scheme: light dark; +} -1. `onConnect`: When we connect and receive our credentials, we'll save them to browser local storage, so that the next time we connect, we can re-authenticate as the same user. -2. `initialStateSync`: When we're informed of the backlog of past messages, we'll sort them and update the `message` section of the page. -3. `Message.onInsert`: When we receive a new message, we'll update the `message` section of the page. -4. `User.onInsert`: When a new user joins, we'll update the `system` section of the page with an appropiate message. -5. `User.onUpdate`: When a user is updated, we'll add a message with their new name, or declare their new online status to the `system` section of the page. -6. `SetNameReducer.on`: If the server rejects our attempt to set our name, we'll update the `system` section of the page with an appropriate error message. -7. `SendMessageReducer.on`: If the server rejects a message we send, we'll update the `system` section of the page with an appropriate error message. +@media (prefers-color-scheme: dark) { + :root { + --theme-color: #4cf490; + --theme-color-contrast: #132219; + --textbox-color: #0f311d; + } +} -We will add callbacks for each of these items in the following sections. All of these callbacks will be registered inside the `App` function after the `useRef` declarations. +/* ----- Page Setup ----- */ +html, +body, +#root { + height: 100%; + margin: 0; +} -### onConnect Callback +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} -On connect SpacetimeDB will provide us with our client credentials. +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', + monospace; +} -Each user has a set of credentials, which consists of two parts: +/* ----- Buttons ----- */ +button { + padding: 0.5rem 0.75rem; + border: none; + border-radius: 0.375rem; + background-color: var(--theme-color); + color: var(--theme-color-contrast); + cursor: pointer; + font-weight: 600; + letter-spacing: 0.1px; + font-family: monospace; +} -- An `Identity`, a unique public identifier. We're using these to identify `User` rows. -- A `Token`, a private key which SpacetimeDB uses to authenticate the client. +/* ----- Inputs & Textareas ----- */ +input, +textarea { + border: none; + border-radius: 0.375rem; + caret-color: var(--theme-color); + font-family: monospace; + font-weight: 600; + letter-spacing: 0.1px; + padding: 0.5rem 0.75rem; +} -These credentials are generated by SpacetimeDB each time a new client connects, and sent to the client so they can be saved, in order to re-connect with the same identity. +input:focus, +textarea:focus { + outline: none; + box-shadow: 0 0 0 2px var(--theme-color); +} +``` -We want to store our local client identity in a stateful variable and also save our `token` to local storage for future connections. +Now when you run `pnpm run dev` and open `http://localhost:5173`, you should see a basic chat app that does not yet send or receive messages. -Each client also has an `Address`, which modules can use to distinguish multiple concurrent connections by the same `Identity`. We don't need to know our `Address`, so we'll ignore that argument. +## Generate your module types -Once we are connected, we can send our subscription to the SpacetimeDB module. SpacetimeDB is set up so that each client subscribes via SQL queries to some subset of the database, and is notified about changes only to that subset. For complex apps with large databases, judicious subscriptions can save each client significant network bandwidth, memory and computation compared. For example, in [BitCraft](https://bitcraftonline.com), each player's client subscribes only to the entities in the "chunk" of the world where that player currently resides, rather than the entire game world. Our app is much simpler than BitCraft, so we'll just subscribe to the whole database. +The `spacetime` CLI's `generate` command will generate client-side interfaces for the tables, reducers and types defined in your server module. -To the body of `App`, add: +In your `quickstart-chat` directory, run: -```typescript -client.current.onConnect((token, identity, address) => { - console.log('Connected to SpacetimeDB'); +```bash +mkdir -p client/src/module_bindings +spacetime generate --lang typescript --out-dir client/src/module_bindings --project-path server +``` - local_identity.current = identity; +> This command assumes you've already created a server module in `quickstart-chat/server`. If you haven't completed one of the server module quickstart guides, you can follow either the [Rust](/docs/modules/rust/quickstart) or [C#](/docs/modules/c-sharp/quickstart) module quickstart to create one and then return here. - localStorage.setItem('auth_token', token); +Take a look inside `client/src/module_bindings`. The CLI should have generated several files: - client.current.subscribe(['SELECT * FROM User', 'SELECT * FROM Message']); -}); +``` +module_bindings +├── identity_connected_reducer.ts +├── identity_disconnected_reducer.ts +├── index.ts +├── init_reducer.ts +├── message_table.ts +├── message_type.ts +├── send_message_reducer.ts +├── set_name_reducer.ts +├── user_table.ts +└── user_type.ts ``` -### initialStateSync callback - -This callback fires when our local client cache of the database is populated. This is a good time to set the initial messages list. - -We'll define a helper function, `setAllMessagesInOrder`, to supply the `MessageType` class for our React application. It will call the autogenerated `Message.all` function to get an array of `Message` rows, then sort them and convert them to `MessageType`. +With `spacetime generate` we have generated TypeScript types derived from the types you specified in your module, which we can conveniently use in our client. We've placed these in the `module_bindings` folder. The main entry to the SpacetimeDB API is the `DBConnection`, a type which manages a connection to a remote database. Let's import it and a few other types into our `client/src/App.tsx`. -To find the `User` based on the message's `sender` identity, we'll use `User::findByIdentity`, which behaves like the same function on the server. +```tsx +import { DBConnection, EventContext, Message, User } from './module_bindings'; +import { Identity } from '@clockworklabs/spacetimedb-sdk'; +``` -Whenever we want to display a user name, if they have set a name, we'll use that. If they haven't set a name, we'll instead use the first 8 bytes of their identity, encoded as hexadecimal. We'll define the function `userNameOrIdentity` to handle this. +## Create your SpacetimeDB client -We also have to handle the case where we don't find a matching `User` row. This can happen when the module owner sends a message using the CLI's `spacetime call`. In this case, we'll display `unknown`. +Now that we've imported the `DBConnection` type, we can use it to connect our app to our module. + +Add the following to your `App` function, just below `const [newMessage, setNewMessage] = useState('');`: + +```tsx + const [connected, setConnected] = useState(false); + const [identity, setIdentity] = useState(null); + const [conn, setConn] = useState(null); + + useEffect(() => { + const onConnect = ( + conn: DBConnection, + identity: Identity, + token: string + ) => { + setIdentity(identity); + setConnected(true); + localStorage.setItem('auth_token', token); + console.log( + 'Connected to SpacetimeDB with identity:', + identity.toHexString() + ); + conn + .subscriptionBuilder() + .onApplied(() => { + console.log('SDK client cache initialized.'); + }) + .subscribe(['SELECT * FROM message', 'SELECT * FROM user']); + }; -To the body of `App`, add: + const onDisconnect = () => { + console.log('Disconnected from SpacetimeDB'); + setConnected(false); + }; -```typescript -function userNameOrIdentity(user: User): string { - console.log(`Name: ${user.name} `); - if (user.name !== null) { - return user.name || ''; - } else { - var identityStr = new Identity(user.identity).toHexString(); - console.log(`Name: ${identityStr} `); - return new Identity(user.identity).toHexString().substring(0, 8); - } -} + const onConnectError = (_conn: DBConnection, err: Error) => { + console.log('Error connecting to SpacetimeDB:', err); + }; -function setAllMessagesInOrder() { - let messages = Array.from(Message.all()); - messages.sort((a, b) => (a.sent > b.sent ? 1 : a.sent < b.sent ? -1 : 0)); + setConn( + DBConnection.builder() + .withUri('ws://localhost:3000') + .withModuleName('quickstart-chat') + .withToken(localStorage.getItem('auth_token') || '') + .onConnect(onConnect) + .onDisconnect(onDisconnect) + .onConnectError(onConnectError) + .build() + ); + }, []); +``` - let messagesType: MessageType[] = messages.map(message => { - let sender_identity = User.findByIdentity(message.sender); - let display_name = sender_identity - ? userNameOrIdentity(sender_identity) - : 'unknown'; +Here we are configuring our SpacetimeDB connection by specifying the server URI, module name, and a few callbacks including the `onConnect` callback. When `onConnect` is called after connecting, we store the connection state, our `Identity`, and our SpacetimeDB credentials in our React state. If there is an error connecting, we print that error to the console as well. - return { - name: display_name, - message: message.text, - }; - }); +We are also using `localStorage` to store our SpacetimeDB credentials. This way, we can reconnect to SpacetimeDB with the same `Identity` and token if we refresh the page. The first time we connect, we won't have any credentials stored, so we pass `undefined` to the `withToken` method. This will cause SpacetimeDB to generate new credentials for us. - setMessages(messagesType); -} +If you chose a different name for your module, replace `quickstart-chat` with that name, or republish your module as `quickstart-chat`. -client.current.on('initialStateSync', () => { - setAllMessagesInOrder(); - var user = User.findByIdentity(local_identity?.current?.toUint8Array()!); - setName(userNameOrIdentity(user!)); -}); -``` +In the `onConnect` function we are also subscribing to the `message` and `user` tables. When we subscribe, SpacetimeDB will run our subscription queries and store the result in a local "client cache". This cache will be updated in real-time as the data in the table changes on the server. The `onApplied` callback is called after SpacetimeDB has synchronized our subscribed data with the client cache. -### Message.onInsert callback - Update messages +### Accessing the Data -When we receive a new message, we'll update the messages section of the page. Keep in mind that we only want to do this for new messages, i.e. those inserted by a `send_message` reducer invocation. When the server is initializing our cache, we'll get a callback for each existing message, but we don't want to update the page for those. To that effect, our `onInsert` callback will check if its `ReducerEvent` argument is not `undefined`, and only update the `message` section in that case. +Once SpacetimeDB is connected, we can easily access the data in the client cache using our `DBConnection`. The `conn.db` field allows you to access all of the tables of your database. Those tables will contain all data requested by your subscription configuration. -To the body of `App`, add: +Let's create custom React hooks for the `message` and `user` tables. Add the following code above your `App` component: -```typescript -Message.onInsert((message, reducerEvent) => { - if (reducerEvent !== undefined) { - setAllMessagesInOrder(); - } -}); -``` +```tsx +function useMessages(conn: DBConnection | null): Message[] { + const [messages, setMessages] = useState([]); -### User.onInsert callback - Notify about new users + useEffect(() => { + if (!conn) return; + const onInsert = (_ctx: EventContext, message: Message) => { + setMessages(prev => [...prev, message]); + }; + conn.db.message.onInsert(onInsert); + + const onDelete = (_ctx: EventContext, message: Message) => { + setMessages(prev => + prev.filter( + m => + m.text !== message.text && + m.sent !== message.sent && + m.sender !== message.sender + ) + ); + }; + conn.db.message.onDelete(onDelete); -For each table, we can register on-insert and on-delete callbacks to be run whenever a subscribed row is inserted or deleted. We register these callbacks using the `onInsert` and `onDelete` methods of the trait `TableType`, which is automatically implemented for each table by `spacetime generate`. + return () => { + conn.db.message.removeOnInsert(onInsert); + conn.db.message.removeOnDelete(onDelete); + }; + }, [conn]); -These callbacks can fire in two contexts: + return messages; +} -- After a reducer runs, when the client's cache is updated about changes to subscribed rows. -- After calling `subscribe`, when the client's cache is initialized with all existing matching rows. +function useUsers(conn: DBConnection | null): Map { + const [users, setUsers] = useState>(new Map()); -This second case means that, even though the module only ever inserts online users, the client's `User.onInsert` callbacks may be invoked with users who are offline. We'll only notify about online users. + useEffect(() => { + if (!conn) return; + const onInsert = (_ctx: EventContext, user: User) => { + setUsers(prev => new Map(prev.set(user.identity.toHexString(), user))); + }; + conn.db.user.onInsert(onInsert); -`onInsert` and `onDelete` callbacks take two arguments: the altered row, and a `ReducerEvent | undefined`. This will be `undefined` for rows inserted when initializing the cache for a subscription. `ReducerEvent` is a class containing information about the reducer that triggered this event. For now, we can ignore this argument. + const onUpdate = (_ctx: EventContext, oldUser: User, newUser: User) => { + setUsers(prev => { + prev.delete(oldUser.identity.toHexString()); + return new Map(prev.set(newUser.identity.toHexString(), newUser)); + }); + }; + conn.db.user.onUpdate(onUpdate); -We are going to add a helper function called `appendToSystemMessage` that will append a line to the `systemMessage` state. We will use this to update the `system` message when a new user joins. + const onDelete = (_ctx: EventContext, user: User) => { + setUsers(prev => { + prev.delete(user.identity.toHexString()); + return new Map(prev); + }); + }; + conn.db.user.onDelete(onDelete); -To the body of `App`, add: + return () => { + conn.db.user.removeOnInsert(onInsert); + conn.db.user.removeOnUpdate(onUpdate); + conn.db.user.removeOnDelete(onDelete); + }; + }, [conn]); -```typescript -// Helper function to append a line to the systemMessage state -function appendToSystemMessage(line: String) { - setSystemMessage(prevMessage => prevMessage + '\n' + line); + return users; } - -User.onInsert((user, reducerEvent) => { - if (user.online) { - appendToSystemMessage(`${userNameOrIdentity(user)} has connected.`); - } -}); ``` -### User.onUpdate callback - Notify about updated users - -Because we declared a `#[primarykey]` column in our `User` table, we can also register on-update callbacks. These run whenever a row is replaced by a row with the same primary key, like our module's `User::update_by_identity` calls. We register these callbacks using the `onUpdate` method which is automatically implemented by `spacetime generate` for any table with a `#[primarykey]` column. +These custom React hooks update the React state anytime a row in our tables change, causing React to rerender. -`onUpdate` callbacks take three arguments: the old row, the new row, and a `ReducerEvent`. +> In principle, it should be possible to automatically generate these hooks based on your module's schema, or use [`useSyncExternalStore`](https://react.dev/reference/react/useSyncExternalStore). For simplicity, rather than creating them mechanically, we're just going to do it manually. -In our module, users can be updated for three reasons: +Next add let's add these hooks to our `App` component just below our connection setup: -1. They've set their name using the `set_name` reducer. -2. They're an existing user re-connecting, so their `online` has been set to `true`. -3. They've disconnected, so their `online` has been set to `false`. +```tsx + const messages = useMessages(conn); + const users = useUsers(conn); +``` -We'll update the `system` message in each of these cases. +Let's now prettify our messages in our render function by sorting them by their `sent` timestamp, and joining the username of the sender to the message by looking up the user by their `Identity` in the `user` table. Replace `const prettyMessages: PrettyMessage[] = [];` with the following: + +```tsx + const prettyMessages: PrettyMessage[] = messages + .sort((a, b) => (a.sent > b.sent ? 1 : -1)) + .map(message => ({ + senderName: + users.get(message.sender.toHexString())?.name || + message.sender.toHexString().substring(0, 8), + text: message.text, + })); +``` -To the body of `App`, add: +That's all we have to do to hook up our SpacetimeDB state to our React state. SpacetimeDB will make sure that any change on the server gets pushed down to our application and rerendered on screen in real-time. -```typescript -User.onUpdate((oldUser, user, reducerEvent) => { - if (oldUser.online === false && user.online === true) { - appendToSystemMessage(`${userNameOrIdentity(user)} has connected.`); - } else if (oldUser.online === true && user.online === false) { - appendToSystemMessage(`${userNameOrIdentity(user)} has disconnected.`); - } +Let's also update our render function to show a loading message while we're connecting to SpacetimeDB. Add this just below our `prettyMessages` declaration: - if (user.name !== oldUser.name) { - appendToSystemMessage( - `User ${userNameOrIdentity(oldUser)} renamed to ${userNameOrIdentity( - user - )}.` +```tsx + if (!conn || !connected || !identity) { + return ( +
+

Connecting...

+
); } -}); ``` -### SetNameReducer.on callback - Handle errors and update profile name - -We can also register callbacks to run each time a reducer is invoked. We register these callbacks using the `OnReducer` method which is automatically implemented for each reducer by `spacetime generate`. - -Each reducer callback takes a number of parameters: - -1. `ReducerEvent` that contains information about the reducer that triggered this event. It contains several fields. The ones we care about are: - - - `callerIdentity`: The `Identity` of the client that called the reducer. - - `status`: The `Status` of the reducer run, one of `"Committed"`, `"Failed"` or `"OutOfEnergy"`. - - `message`: The error message, if any, that the reducer returned. +Finally, let's also compute the name of the user from the `Identity` in our `name` variable. Replace `const name = '';` with the following: -2. The rest of the parameters are arguments passed to the reducer. - -These callbacks will be invoked in one of two cases: - -1. If the reducer was successful and altered any of our subscribed rows. -2. If we requested an invocation which failed. +```tsx + const name = + users.get(identity?.toHexString())?.name || + identity?.toHexString().substring(0, 8) || + 'unknown'; +``` -Note that a status of `Failed` or `OutOfEnergy` implies that the caller identity is our own identity. +### Calling Reducers -We already handle other users' `set_name` calls using our `User.onUpdate` callback, but we need some additional behavior for setting our own name. If our name was rejected, we'll update the `system` message. If our name was accepted, we'll update our name in the app. +Let's hook up our callbacks so we can send some messages and see them displayed in the app after being synchronized by SpacetimeDB. We need to update the `onSubmitNewName` and `onSubmitMessage` callbacks to send the appropriate reducer to the module. -We'll test both that our identity matches the sender and that the status is `Failed`, even though the latter implies the former, for demonstration purposes. +Modify the `onSubmitNewName` callback by adding a call to the `setName` reducer: -If the reducer status comes back as `committed`, we'll update the name in our app. +```tsx + const onSubmitNewName = (e: React.FormEvent) => { + e.preventDefault(); + setSettingName(false); + conn.reducers.setName(newName); + }; +``` -To the body of `App`, add: +Next modify the `onSubmitMessage` callback by adding a call to the `sendMessage` reducer: -```typescript -SetNameReducer.on((reducerEvent, newName) => { - if ( - local_identity.current && - reducerEvent.callerIdentity.isEqual(local_identity.current) - ) { - if (reducerEvent.status === 'failed') { - appendToSystemMessage(`Error setting name: ${reducerEvent.message} `); - } else if (reducerEvent.status === 'committed') { - setName(newName); - } - } -}); +```tsx + const onMessageSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setNewMessage(""); + conn.reducers.sendMessage(newMessage); + }; ``` -### SendMessageReducer.on callback - Handle errors +SpacetimeDB generated these functions for us based on the type information provided by our module. Calling these functions will invoke our reducers in our module. -We handle warnings on rejected messages the same way as rejected names, though the types and the error message are different. We don't need to do anything for successful SendMessage reducer runs; our Message.onInsert callback already displays them. +Let's try out our app to see the result of these changes. -To the body of `App`, add: - -```typescript -SendMessageReducer.on((reducerEvent, newMessage) => { - if ( - local_identity.current && - reducerEvent.callerIdentity.isEqual(local_identity.current) - ) { - if (reducerEvent.status === 'failed') { - appendToSystemMessage(`Error sending message: ${reducerEvent.message} `); - } - } -}); +```sh +cd client +pnpm run dev ``` -## Update the UI button callbacks - -We need to update the `onSubmitNewName` and `onMessageSubmit` callbacks to send the appropriate reducer to the module. +> Don't forget! You may need to publish your server module if you haven't yet. -`spacetime generate` defined two functions for us, `SetNameReducer.call` and `SendMessageReducer.call`, which send a message to the database to invoke the corresponding reducer. The first argument, the `ReducerContext`, is supplied by the server, but we pass all other arguments ourselves. In our case, that means that both `SetNameReducer.call` and `SendMessageReducer.call` take one argument, a `String`. +Send some messages and update your username and watch it change in real-time. Note that when you update your username it also updates immediately for all prior messages. This is because the messages store the user's `Identity` directly, instead of their username, so we can retroactively apply their username to all prior messages. -Add the following to the `onSubmitNewName` callback: +Try opening a few incognito windows to see what it's like with multiple users! -```typescript -SetNameReducer.call(newName); -``` - -Add the following to the `onMessageSubmit` callback: +### Notify about new users -```typescript -SendMessageReducer.call(newMessage); -``` +We can also register `onInsert` and `onDelete` callbacks for the purpose of handling events, not just state. For example, we might want to show a notification any time a new user connects to the module. -## Connecting to the module +Note that these callbacks can fire in two contexts: -We need to connect to the module when the app loads. We'll do this by adding a `useEffect` hook to the `App` function. This hook should only run once, when the component is mounted, but we are going to use an `initialized` boolean to ensure that it only runs once. +- After a reducer runs, when the client's cache is updated about changes to subscribed rows. +- After calling `subscribe`, when the client's cache is initialized with all existing matching rows. -```typescript -useEffect(() => { - if (!initialized.current) { - client.current.connect(); - initialized.current = true; - } -}, []); +Our `user` table includes all users not just online users, so we want to take care to only show a notification when new users join. Let's add a `useEffect` which subscribes a callback when a `user` is inserted into the table and a callback when a `user` is updated. Add the following to your `App` component just below the other `useEffect`. + +```tsx + useEffect(() => { + if (!conn) return; + conn.db.user.onInsert((_ctx, user) => { + if (user.online) { + const name = user.name || user.identity.toHexString().substring(0, 8); + setSystemMessage(prev => prev + `\n${name} has connected.`); + } + }); + conn.db.user.onUpdate((_ctx, oldUser, newUser) => { + const name = + newUser.name || newUser.identity.toHexString().substring(0, 8); + if (oldUser.online === false && newUser.online === true) { + setSystemMessage(prev => prev + `\n${name} has connected.`); + } else if (oldUser.online === true && newUser.online === false) { + setSystemMessage(prev => prev + `\n${name} has disconnected.`); + } + }); + }, [conn]); ``` -## What's next? - -When you run `npm start` you should see a chat app that can send and receive messages. If you open it in multiple private browser windows, you should see that messages are synchronized between them. +Here we post a message saying a new user has connected if the user is being added to the `user` table and they're online, or if an existing user's online status is being set to "online". -Congratulations! You've built a simple chat app with SpacetimeDB. You can find the full source code for this app [here](https://github.com/clockworklabs/spacetimedb-typescript-sdk/tree/main/examples/quickstart) +Note that `onInsert` and `onDelete` callbacks takes two arguments: an `EventContext` and the row. The `EventContext` can be used just like the `DBConnection` and has all the same access functions, in addition to containing information about the event that triggered this callback. For now, we can ignore this argument though, since we have all the info we need in the user rows. -For a more advanced example of the SpacetimeDB TypeScript SDK, take a look at the [Spacetime MUD (multi-user dungeon)](https://github.com/clockworklabs/spacetime-mud/tree/main/react-client). +## Conclusion -## Troubleshooting - -If you encounter the following error: +Congratulations! You've built a simple chat app with SpacetimeDB. You can find the full source code for this app [here](https://github.com/clockworklabs/spacetimedb-typescript-sdk/tree/main/examples/quickstart) -``` -TS2802: Type 'IterableIterator' can only be iterated through when using the '--downlevelIteration' flag or with a '--target' of 'es2015' or higher. -``` +At this point you've learned how to create a basic TypeScript client for your SpacetimeDB `quickstart-chat` module. You've learned how to connect to SpacetimeDB and call reducers to update data. You've learned how to subscribe to table data, and hook it up so that it updates reactively in a React application. -You can fix it by changing your compiler target. Add the following to your `tsconfig.json` file: +## What's next? -```json -{ - "compilerOptions": { - "target": "es2015" - } -} -``` +We covered a lot here, but we haven't covered everything. Take a look at our [reference documentation](/docs/sdks/typescript) to find out how you can use SpacetimeDB in more advanced ways, including managing reducer errors and subscribing to reducer events. \ No newline at end of file From 4811e384344784b26b02b12105b3532860a25e3e Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Tue, 28 Jan 2025 13:18:13 -0500 Subject: [PATCH 091/164] Update Rust client SDK docs for SpacetimeDB#2118 (#130) --- docs/sdks/rust/index.md | 8 ++++---- docs/sdks/rust/quickstart.md | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/sdks/rust/index.md b/docs/sdks/rust/index.md index d8befe53..71d40b5a 100644 --- a/docs/sdks/rust/index.md +++ b/docs/sdks/rust/index.md @@ -75,7 +75,7 @@ impl DbConnectionBuilder { } ``` -Chain a call to `.on_connect(callback)` to your builder to register a callback to run when your new `DbConnection` successfully initiates its connection to the remote module. The callback accepts three arguments: a reference to the `DbConnection`, the `Identity` by which SpacetimeDB identifies this connection, and a private access token which can be saved and later passed to [`with_credentials`](#method-with_credentials) to authenticate the same user in future connections. +Chain a call to `.on_connect(callback)` to your builder to register a callback to run when your new `DbConnection` successfully initiates its connection to the remote module. The callback accepts three arguments: a reference to the `DbConnection`, the `Identity` by which SpacetimeDB identifies this connection, and a private access token which can be saved and later passed to [`with_token`](#method-with_token) to authenticate the same user in future connections. This interface may change in an upcoming release as we rework SpacetimeDB's authentication model. @@ -93,15 +93,15 @@ impl DbConnectionBuilder { Chain a call to `.on_connect(callback)` to your builder to register a callback to run when your `DbConnection` disconnects from the remote module, either as a result of a call to [`disconnect`](#method-disconnect) or due to an error. -#### Method `with_credentials` +#### Method `with_token` ```rust impl DbConnectionBuilder { - fn with_credentials(self, credentials: Option<(Identity, String)>) -> Self; + fn with_token(self, token: Option>) -> Self; } ``` -Chain a call to `.with_credentials(credentials)` to your builder to provide an `Identity` and private access token to authenticate with, or to explicitly select an anonymous connection. If this method is not called or `None` is passed, SpacetimeDB will generate a new `Identity` and sign a new private access token for the connection. +Chain a call to `.with_token(token)` to your builder to provide an OpenID Connect compliant JSON Web Token to authenticate with, or to explicitly select an anonymous connection. If this method is not called or `None` is passed, SpacetimeDB will generate a new `Identity` and sign a new private access token for the connection. This interface may change in an upcoming release as we rework SpacetimeDB's authentication model. diff --git a/docs/sdks/rust/quickstart.md b/docs/sdks/rust/quickstart.md index e7e3fd3e..ced12969 100644 --- a/docs/sdks/rust/quickstart.md +++ b/docs/sdks/rust/quickstart.md @@ -376,7 +376,7 @@ fn connect_to_db() -> DbConnection { .on_disconnect(on_disconnected) .with_uri(SPACETIMEDB_URI) .with_module_name(DB_NAME) - .with_credentials(credentials.load().unwrap()) + .with_token(credentials.load().unwrap()) .build().expect("Failed to connect"); conn.run_threaded(); conn From 1d64af2e61bc5ad89b7d60ba9556bcec47dafdba Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Tue, 16 Jul 2024 12:39:04 -0700 Subject: [PATCH 092/164] docs(70): The 1.0 SQL spec Closes #70. --- docs/sql/index.md | 591 ++++++++++++++++++++++++++-------------------- 1 file changed, 333 insertions(+), 258 deletions(-) diff --git a/docs/sql/index.md b/docs/sql/index.md index 66097209..d2808d38 100644 --- a/docs/sql/index.md +++ b/docs/sql/index.md @@ -1,407 +1,482 @@ # SQL Support -SpacetimeDB supports a subset of SQL as a query language. Developers can evaluate SQL queries against a Spacetime database via the `spacetime sql` command-line tool and the [`/database/sql/:name_or_address POST` HTTP endpoint](/docs/http/database#databasesqlname_or_address-post). Client developers also write SQL queries when subscribing to events in the [WebSocket API](/docs/ws#subscribe) or via an SDK `subscribe` function. +SpacetimeDB supports two subsets of SQL: +One for queries issued through the cli or [http] api. +Another for subscriptions issued via the [sdk] or [websocket] api. -SpacetimeDB aims to support much of the [SQL 2016 standard](https://www.iso.org/standard/63555.html), and in particular aims to be compatible with [PostgreSQL](https://www.postgresql.org/). +## Subscriptions -SpacetimeDB 0.6 implements a relatively small subset of SQL. Future SpacetimeDB versions will implement additional SQL features. +```ebnf +SELECT projection FROM relation [ WHERE predicate ] +``` -## Types +The subscription language is strictly a query language. +Its sole purpose is to replicate a subset of the rows in the database, +and to **automatically** update them in realtime as the database changes. -| Type | Description | -| --------------------------------------------- | -------------------------------------- | -| [Nullable types](#nullable-types) | Types which may not hold a value. | -| [Logic types](#logic-types) | Booleans, i.e. `true` and `false`. | -| [Integer types](#integer-types) | Numbers without fractional components. | -| [Floating-point types](#floating-point-types) | Numbers with fractional components. | -| [Text types](#text-types) | UTF-8 encoded text. | +There is no context for manually updating this view. +Hence data manipulation commands like `INSERT` and `DELETE` are not supported. -### Definition statements +> NOTE: Because subscriptions are evaluated in realtime, +> performance is critical, and as a result, +> additional restrictions are applied over ad hoc queries. +> These restrictions are highlighted below. -| Statement | Description | -| ----------------------------- | ------------------------------------ | -| [CREATE TABLE](#create-table) | Create a new table. | -| [DROP TABLE](#drop-table) | Remove a table, discarding all rows. | +### SELECT -### Query statements +```ebnf +SELECT ( '*' | table '.' '*' ) +``` -| Statement | Description | -| ----------------- | -------------------------------------------------------------------------------------------- | -| [FROM](#from) | A source of data, like a table or a value. | -| [JOIN](#join) | Combine several data sources. | -| [SELECT](#select) | Select specific rows and columns from a data source, and optionally compute a derived value. | -| [DELETE](#delete) | Delete specific rows from a table. | -| [INSERT](#insert) | Insert rows into a table. | -| [UPDATE](#update) | Update specific rows in a table. | +The `SELECT` clause determines the table that is being subscribed to. +Since the subscription api is purely a replication api, +a query may only return rows from a single table, +and it must return the entire row. +Individual column projections are not allowed. -## Data types +A `*` projection is allowed when the table is unambiguous, +otherwise it must be qualified with the appropriate table name. -SpacetimeDB is built on the Spacetime Algebraic Type System, or SATS. SATS is a richer, more expressive type system than the one included in the SQL language. +#### Examples -Because SATS is a richer type system than SQL, some SATS types cannot cleanly correspond to SQL types. In particular, the SpacetimeDB SQL interface is unable to construct or compare instances of product and sum types. As such, SpacetimeDB SQL must largely restrict themselves to interacting with columns of builtin types. +```sql +-- Subscribe to all rows of a table +SELECT * FROM Inventory -Most SATS builtin types map cleanly to SQL types. +-- Qualify the `*` projection with the table +SELECT item.* from Inventory item -### Nullable types +-- Subscribe to all customers who have orders totaling more than $1000 +SELECT customer.* +FROM Customers customer JOIN Orders o ON customer.id = o.customer_id +WHERE o.amount > 1000 -SpacetimeDB types, by default, do not permit `NULL` as a value. Nullable types are encoded in SATS using a sum type which corresponds to [Rust's `Option`](https://doc.rust-lang.org/stable/std/option/enum.Option.html). In SQL, such types can be written by adding the constraint `NULL`, like `INT NULL`. +-- INVALID: Must return `Customers` or `Orders`, but not both +SELECT * +FROM Customers customer JOIN Orders o ON customer.id = o.customer_id +WHERE o.amount > 1000 +``` -### Logic types +### FROM -| SQL | SATS | Example | -| --------- | ------ | --------------- | -| `BOOLEAN` | `Bool` | `true`, `false` | +```ebnf +FROM table [ [AS] alias ] [ [INNER] JOIN table [ [AS] alias ] ON column '=' column ] +``` -### Numeric types +While you can only subscribe to rows from a single table, +you may reference two tables in the `FROM` clause using a `JOIN`. +A `JOIN` selects all combinations of rows from its input tables, +and `ON` determines which combinations are considered. -#### Integer types +Subscriptions do not support joins of more than two tables. -An integer is a number without a fractional component. +For any column referenced in `ON` clause of a `JOIN`, +it must be qualified with the appropriate table name or alias. -Adding the `UNSIGNED` constraint to an integer type allows only positive values. This allows representing a larger positive range without increasing the width of the integer. +In order for a `JOIN` to be evaluated efficiently, +subscriptions require an index to be defined on both join columns. -| SQL | SATS | Example | Min | Max | -| ------------------- | ----- | ------- | ------ | ----- | -| `TINYINT` | `I8` | 1 | -(2⁷) | 2⁷-1 | -| `TINYINT UNSIGNED` | `U8` | 1 | 0 | 2⁸-1 | -| `SMALLINT` | `I16` | 1 | -(2¹⁵) | 2¹⁵-1 | -| `SMALLINT UNSIGNED` | `U16` | 1 | 0 | 2¹⁶-1 | -| `INT`, `INTEGER` | `I32` | 1 | -(2³¹) | 2³¹-1 | -| `INT UNSIGNED` | `U32` | 1 | 0 | 2³²-1 | -| `BIGINT` | `I64` | 1 | -(2⁶³) | 2⁶³-1 | -| `BIGINT UNSIGNED` | `U64` | 1 | 0 | 2⁶⁴-1 | +#### Example -#### Floating-point types +```sql +-- Subscribe to all orders of products with less than 10 items in stock. +-- Must have an index on the `product_id` column of the `Orders` table, +-- as well as the `id` column of the `Product` table. +SELECT o.* +FROM Orders o JOIN Inventory product ON o.product_id = product.id +WHERE product.quantity < 10 + +-- Subscribe to all products that have at least one purchase +SELECT product.* +FROM Orders o JOIN Inventory product ON o.product_id = product.id + +-- INVALID: Must qualify the column names referenced in `ON` +SELECT product.* FROM Orders JOIN Inventory product ON product_id = id +``` -SpacetimeDB supports single- and double-precision [binary IEEE-754 floats](https://en.wikipedia.org/wiki/IEEE_754). +### WHERE + +```ebnf +predicate + = expr + | predicate AND predicate + | predicate OR predicate + ; + +expr + = literal + | column + | expr op expr + ; + +op + = '=' + | '<' + | '>' + | '<' '=' + | '>' '=' + | '!' '=' + | '<' '>' + ; + +literal + = INTEGER + | STRING + | HEX + | TRUE + | FALSE + ; +``` -| SQL | SATS | Example | Min | Max | -| ----------------- | ----- | ------- | ------------------------ | ----------------------- | -| `REAL` | `F32` | 1.0 | -3.40282347E+38 | 3.40282347E+38 | -| `DOUBLE`, `FLOAT` | `F64` | 1.0 | -1.7976931348623157E+308 | 1.7976931348623157E+308 | +While the `SELECT` clause determines the table, +the `WHERE` clause determines the rows in the subscription. -### Text types +Arithmetic expressions are not supported. -SpacetimeDB supports a single string type, `String`. SpacetimeDB strings are UTF-8 encoded. +#### Examples -| SQL | SATS | Example | Notes | -| ----------------------------------------------- | -------- | ------- | -------------------- | -| `CHAR`, `VARCHAR`, `NVARCHAR`, `TEXT`, `STRING` | `String` | 'hello' | Always UTF-8 encoded | +```sql +-- Find products that sell for more than $X +SELECT * FROM Inventory WHERE price > {X} -> SpacetimeDB SQL currently does not support length contraints like `CHAR(10)`. +-- Find products that sell for more than $X and have fewer than Y items in stock +SELECT * FROM Inventory WHERE price > {X} AND amount < {Y} +``` -## Syntax +## Query and DML (Data Manipulation Language) -### Comments +### Statements -SQL line comments begin with `--`. +- [SELECT](#select-1) +- [INSERT](#insert) +- [DELETE](#delete) +- [UPDATE](#update) +- [SET](#set) +- [SHOW](#show) -```sql --- This is a comment -``` +### SELECT -### Expressions +```ebnf +SELECT projection FROM relation [ WHERE predicate ] +``` -We can express different, composable, values that are universally called `expressions`. +The query languge is a strict superset of the subscription language. +The main differences are seen in column projections and [joins](#from-clause). -An expression is one of the following: +The subscription api only supports `*` projections, +but the query api supports individual column projections. -#### Literals +The subscription api limits the number of tables you can join, +and enforces index constraints on the join columns, +but the query language has no such constraints or limitations. -| Example | Description | -| --------- | ----------- | -| `1` | An integer. | -| `1.0` | A float. | -| `'hello'` | A string. | -| `true` | A boolean. | +#### SELECT Clause -#### Binary operators +```ebnf +projection + = '*' + | table '.' '*' + | projExpr { ',' projExpr } + ; -| Example | Description | -| ------- | ------------------- | -| `1 > 2` | Integer comparison. | -| `1 + 2` | Integer addition. | +projExpr + = column [ [ AS ] alias ] + ; +``` -#### Logical expressions +The `SELECT` clause determines the columns that are returned. -Any expression which returns a boolean, i.e. `true` or `false`, is a logical expression. +##### Examples -| Example | Description | -| ---------------- | ------------------------------------------------------------ | -| `1 > 2` | Integer comparison. | -| `1 + 2 == 3` | Equality comparison between a constant and a computed value. | -| `true AND false` | Boolean and. | -| `true OR false` | Boolean or. | -| `NOT true` | Boolean inverse. | +```sql +-- Select the items in my inventory +SELECT * FROM Inventory; -#### Function calls +-- Select the names and prices of the items in my inventory +SELECT item_name, price FROM Inventory +``` -| Example | Description | -| --------------- | -------------------------------------------------- | -| `lower('JOHN')` | Apply the function `lower` to the string `'JOHN'`. | +#### FROM Clause -#### Table identifiers +```ebnf +FROM table [ [AS] alias ] { [INNER] JOIN table [ [AS] alias ] ON predicate } +``` -| Example | Description | -| ------------- | ------------------------- | -| `inventory` | Refers to a table. | -| `"inventory"` | Refers to the same table. | +Unlike [subscriptions](#from), the query api supports joining more than two tables. -#### Column references +##### Examples -| Example | Description | -| -------------------------- | ------------------------------------------------------- | -| `inventory_id` | Refers to a column. | -| `"inventory_id"` | Refers to the same column. | -| `"inventory.inventory_id"` | Refers to the same column, explicitly naming its table. | +```sql +-- Find all customers who ordered a particular product and when they ordered it +SELECT customer.first_name, customer.last_name, o.date +FROM Customers customer +JOIN Orders o ON customer.id = o.customer_id +JOIN Inventory product ON o.product_id = product.id +WHERE product.name = {product_name} +``` -#### Wildcards +#### WHERE Clause -Special "star" expressions which select all the columns of a table. +See [Subscriptions](#where). -| Example | Description | -| ------------- | ------------------------------------------------------- | -| `*` | Refers to all columns of a table identified by context. | -| `inventory.*` | Refers to all columns of the `inventory` table. | +### INSERT -#### Parenthesized expressions +```ebnf +INSERT INTO table [ '(' column { ',' column } ')' ] VALUES '(' literal { ',' literal } ')' +``` -Sub-expressions can be enclosed in parentheses for grouping and to override operator precedence. +#### Examples -| Example | Description | -| ------------- | ----------------------- | -| `1 + (2 / 3)` | One plus a fraction. | -| `(1 + 2) / 3` | A sum divided by three. | +```sql +-- Inserting one row +INSERT INTO Inventory (item_id, item_name) VALUES (1, 'health1'); -### `CREATE TABLE` +-- Inserting two rows +INSERT INTO Inventory (item_id, item_name) VALUES (1, 'health1'), (2, 'health2'); +``` -A `CREATE TABLE` statement creates a new, initially empty table in the database. +### DELETE -The syntax of the `CREATE TABLE` statement is: +```ebnf +DELETE FROM table [ WHERE predicate ] +``` -> **CREATE TABLE** _table_name_ (_column_name_ _data_type_, ...); +Deletes all rows from a table. +If `WHERE` is specified, only the matching rows are deleted. -![create-table](/images/syntax/create_table.svg) +`DELETE` does not support joins. #### Examples -Create a table `inventory` with two columns, an integer `inventory_id` and a string `name`: - ```sql -CREATE TABLE inventory (inventory_id INTEGER, name TEXT); -``` +-- Delete all rows +DELETE FROM Inventory; -Create a table `player` with two integer columns, an `entity_id` and an `inventory_id`: - -```sql -CREATE TABLE player (entity_id INTEGER, inventory_id INTEGER); +-- Delete all rows with a specific item_id +DELETE FROM Inventory WHERE item_id = 1; ``` -Create a table `location` with three columns, an integer `entity_id` and floats `x` and `z`: +### UPDATE -```sql -CREATE TABLE location (entity_id INTEGER, x REAL, z REAL); +```ebnf +UPDATE table SET [ '(' assignment { ',' assignment } ')' ] [ WHERE predicate ] ``` -### `DROP TABLE` - -A `DROP TABLE` statement removes a table from the database, deleting all its associated rows, indexes, constraints and sequences. - -To empty a table of rows without destroying the table, use [`DELETE`](#delete). - -The syntax of the `DROP TABLE` statement is: +Updates column values of existing rows in a table. +The columns are identified by the `assignment` defined as `column '=' literal`. +The column values are updated for all rows that match the `WHERE` condition. +The rows are updated after the `WHERE` condition is evaluated for all rows. -> **DROP TABLE** _table_name_; +`UPDATE` does not support joins. -![drop-table](/images/syntax/drop_table.svg) - -Examples: +#### Examples ```sql -DROP TABLE inventory; +-- Update the item_name for all rows with a specific item_id +UPDATE Inventory SET item_name = 'new name' WHERE item_id = 1; ``` -## Queries - -### `FROM` +### SET -A `FROM` clause derives a data source from a table name. +> WARNING: The `SET` statement is experimental. +> Compatibility with future versions of SpacetimeDB is not guaranteed. -The syntax of the `FROM` clause is: - -> **FROM** _table_name_ _join_clause_?; +```ebnf +SET var ( TO | '=' ) literal +``` -![from](/images/syntax/from.svg) +Updates the value of a system variable. -#### Examples +### SHOW -Select all rows from the `inventory` table: +> WARNING: The `SHOW` statement is experimental. +> Compatibility with future versions of SpacetimeDB is not guaranteed. -```sql -SELECT * FROM inventory; +```ebnf +SHOW var ``` -### `JOIN` +Returns the value of a system variable. + +## System Variables -A `JOIN` clause combines two data sources into a new data source. +> WARNING: System variables are experimental. +> Compatibility with future versions of SpacetimeDB is not guaranteed. -Currently, SpacetimeDB SQL supports only inner joins, which return rows from two data sources where the values of two columns match. +- `row_limit` -The syntax of the `JOIN` clause is: + ```sql + -- Reject queries that scan more than 10K rows + SET row_limit = 10000 + ``` -> **JOIN** _table_name_ **ON** _expr_ = _expr_; +## Data types -![join](/images/syntax/join.svg) +The set of data types that SpacetimeDB supports is defined by SATS, +the Spacetime Algebraic Type System. -### Examples +Spacetime SQL however does not support all of SATS, +specifically in the way of product and sum types. +The language itself does not provide a way to construct them, +nore does it provide any scalar operators for them. +Nevertheless rows containing them can be returned to clients. -Select all players rows who have a corresponding location: +## Literals -```sql -SELECT player.* FROM player - JOIN location - ON location.entity_id = player.entity_id; +```ebnf +literal = INTEGER | FLOAT | STRING | HEX | TRUE | FALSE ; ``` -Select all inventories which have a corresponding player, and where that player has a corresponding location: +The following describes how to construct literal values for SATS data types in Spacetime SQL. -```sql -SELECT inventory.* FROM inventory - JOIN player - ON inventory.inventory_id = player.inventory_id - JOIN location - ON player.entity_id = location.entity_id; -``` +### Booleans -### `SELECT` +Booleans are represented using the canonical atoms `true` or `false`. -A `SELECT` statement returns values of particular columns from a data source, optionally filtering the data source to include only rows which satisfy a `WHERE` predicate. +### Integers -The syntax of the `SELECT` command is: +```ebnf +INTEGER + = [ '+' | '-' ] NUM + | [ '+' | '-' ] NUM 'E' [ '+' ] NUM + ; -> **SELECT** _column_expr_ > **FROM** _from_expr_ -> {**WHERE** _expr_}? +NUM + = DIGIT { DIGIT } + ; -![sql-select](/images/syntax/select.svg) +DIGIT + = 0..9 + ; +``` -#### Examples +SATS supports multple fixed width integer types. +The concrete type of a literal is inferred from the context. -Select all columns of all rows from the `inventory` table: +#### Examples ```sql -SELECT * FROM inventory; -SELECT inventory.* FROM inventory; +-- All products that sell for more than $1000 +SELECT * FROM Inventory WHERE price > 1000 +SELECT * FROM Inventory WHERE price > 1e3 +SELECT * FROM Inventory WHERE price > 1E3 ``` -Select only the `inventory_id` column of all rows from the `inventory` table: +### Floats -```sql -SELECT inventory_id FROM inventory; -SELECT inventory.inventory_id FROM inventory; +```ebnf +FLOAT + = [ '+' | '-' ] [ NUM ] '.' NUM + | [ '+' | '-' ] [ NUM ] '.' NUM 'E' [ '+' | '-' ] NUM + ; ``` -An optional `WHERE` clause can be added to filter the data source using a [logical expression](#logical-expressions). The `SELECT` will return only the rows from the data source for which the expression returns `true`. +SATS supports both 32 and 64 bit floating point types. +The concrete type of a literal is inferred from the context. #### Examples -Select all columns of all rows from the `inventory` table, with a filter that is always true: - ```sql -SELECT * FROM inventory WHERE 1 = 1; +-- All measurements where the temperature is greater than 105.3 +SELECT * FROM Measurements WHERE temperature > 105.3 +SELECT * FROM Measurements WHERE temperature > 1053e-1 +SELECT * FROM Measurements WHERE temperature > 1053E-1 ``` -Select all columns of all rows from the `inventory` table with the `inventory_id` 1: +### Strings -```sql -SELECT * FROM inventory WHERE inventory_id = 1; +```ebnf +STRING + = "'" { "''" | CHAR } "'" + ; ``` -Select only the `name` column of all rows from the `inventory` table with the `inventory_id` 1: - -```sql -SELECT name FROM inventory WHERE inventory_id = 1; -``` +`CHAR` is defined as a `utf-8` encoded unicode character. -Select all columns of all rows from the `inventory` table where the `inventory_id` is 2 or greater: +#### Examples ```sql -SELECT * FROM inventory WHERE inventory_id > 1; +SELECT * FROM Customers WHERE first_name = 'John' ``` -### `INSERT` - -An `INSERT INTO` statement inserts new rows into a table. - -One can insert one or more rows specified by value expressions. +### Hex -The syntax of the `INSERT INTO` statement is: +```ebnf +HEX + = 'X' "'" { HEXIT } "'" + | '0' 'x' { HEXIT } + ; -> **INSERT INTO** _table_name_ (_column_name_, ...) **VALUES** (_expr_, ...), ...; +HEXIT + = DIGIT | a..f | A..F + ; +``` -![sql-insert](/images/syntax/insert.svg) +Hex literals can represent [Identity], [Address], or binary types. +The type is ultimately inferred from the context. #### Examples -Insert a single row: - ```sql -INSERT INTO inventory (inventory_id, name) VALUES (1, 'health1'); +SELECT * FROM Program WHERE hash_value = 0xABCD1234 ``` -Insert two rows: +## Identifiers -```sql -INSERT INTO inventory (inventory_id, name) VALUES (1, 'health1'), (2, 'health2'); -``` - -### UPDATE - -An `UPDATE` statement changes the values of a set of specified columns in all rows of a table, optionally filtering the table to update only rows which satisfy a `WHERE` predicate. - -Columns not explicitly modified with the `SET` clause retain their previous values. +```ebnf +identifier + = LATIN { LATIN | DIGIT | '_' } + | '"' { '""' | CHAR } '"' + ; -If the `WHERE` clause is absent, the effect is to update all rows in the table. - -The syntax of the `UPDATE` statement is - -> **UPDATE** _table_name_ **SET** > _column_name_ = _expr_, ... -> {_WHERE expr_}?; - -![sql-update](/images/syntax/update.svg) +LATIN + = a..z | A..Z + ; +``` -#### Examples +Identifiers are tokens that identify database objects like tables or columns. +Spacetime SQL supports both quoted and unquoted identifiers. +Both types of identifiers are case sensitive. +Use quoted identifiers to avoid conflict with reserved SQL keywords, +or if your table or column contains non-alphanumeric characters. -Set the `name` column of all rows from the `inventory` table with the `inventory_id` 1 to `'new name'`: +### Example ```sql -UPDATE inventory - SET name = 'new name' - WHERE inventory_id = 1; -``` - -### DELETE +-- `ORDER` is a sql keyword and therefore needs to be quoted +SELECT * FROM "Order" -A `DELETE` statement deletes rows that satisfy the `WHERE` clause from the specified table. - -If the `WHERE` clause is absent, the effect is to delete all rows in the table. In that case, the result is a valid empty table. +-- A table containing `$` needs to be quoted as well +SELECT * FROM "Balance$" +``` -The syntax of the `DELETE` statement is +## Appendix -> **DELETE** _table_name_ -> {**WHERE** _expr_}?; +Common production rules that have been used throughout this document. -![sql-delete](/images/syntax/delete.svg) +```ebnf +table + = identifier + ; -#### Examples +alias + = identifier + ; -Delete all the rows from the `inventory` table with the `inventory_id` 1: +var + = identifier + ; -```sql -DELETE FROM inventory WHERE inventory_id = 1; +column + = identifier + | identifier '.' identifier + ; ``` -Delete all rows from the `inventory` table, leaving it empty: -```sql -DELETE FROM inventory; -``` +[sdk]: /docs/sdks/rust/index.md#subscribe-to-queries +[http]: /docs/http/database#databasesqlname_or_address-post +[websocket]: /docs/ws/index.md#subscribe + +[Identity]: /docs/index.md#identity +[Address]: /docs/index.md#address From f171fe237f5cd3894c7b31306ffa6abcfa1d3329 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Sat, 8 Feb 2025 00:38:59 -0500 Subject: [PATCH 093/164] Small TS SDK Quickstart Fixes (#157) Updated quickstart url --- docs/sdks/typescript/quickstart.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sdks/typescript/quickstart.md b/docs/sdks/typescript/quickstart.md index 13f04e21..6d905317 100644 --- a/docs/sdks/typescript/quickstart.md +++ b/docs/sdks/typescript/quickstart.md @@ -652,7 +652,7 @@ Note that `onInsert` and `onDelete` callbacks takes two arguments: an `EventCont ## Conclusion -Congratulations! You've built a simple chat app with SpacetimeDB. You can find the full source code for this app [here](https://github.com/clockworklabs/spacetimedb-typescript-sdk/tree/main/examples/quickstart) +Congratulations! You've built a simple chat app with SpacetimeDB. You can find the full source code for the client we've created in this quickstart tutorial [here](https://github.com/clockworklabs/spacetimedb-typescript-sdk/tree/main/examples/quickstart-chat). At this point you've learned how to create a basic TypeScript client for your SpacetimeDB `quickstart-chat` module. You've learned how to connect to SpacetimeDB and call reducers to update data. You've learned how to subscribe to table data, and hook it up so that it updates reactively in a React application. From 72f425104ce6d47f937f09ffacf35d1725c9cced Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Sat, 8 Feb 2025 00:39:35 -0500 Subject: [PATCH 094/164] Style guide: add formatting advice for GUI elements and menu paths (#129) * Style guide: add formatting advice for menu items * Generalize guidance to all GUI elements, not just menu paths --- STYLE.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/STYLE.md b/STYLE.md index 96baef43..1e958e29 100644 --- a/STYLE.md +++ b/STYLE.md @@ -73,6 +73,14 @@ Don't make promises, even weak ones, about what we plan to do in the future, wit If your document needs to describe a feature that isn't implemented yet, either rewrite to not depend on that feature, or just say that it's a "current limitation" without elaborating further. Include a workaround if there is one. +### Menu items and paths + +When describing GUI elements and menu items, like the **Unity Registry** tab, use bolded text to draw attention to any phrases that will appear in the actual UI. Readers will see this bolded text in the documentation and look for it on their screen. Where applicable, include a short description of the type or category of element, like "tab" above, or the **File** menu. This category should not be bolded, since it is not a word the reader can expect to find on their screen. + +When describing a chain of accesses through menus and submenus, use the **->** thin arrow (that's `->`, a hyphen followed by a greater-than sign) as a separator, like **File -> Quit** or **Window -> Package Manager**. List the top-level menu first, and proceed left-to-right until you reach the option you want the user to interact with. Include all nested submenus, like **Foo -> Bar -> Baz -> Quux**. Bold the whole sequence, including the arrows. + +It's generally not necessary or desirable to tell users where to look for the top-level menu. You may be tempted to write something like, "Open the **File** menu in the upper left, and navigate **File -> Export as -> Export as PDF**." Do not include "in the upper left" unless you are absolutely confident that the menu will be located there on any combination of OS, version, desktop environment, window manager, theming configuration &c. Even within a single system, UI designers are known to move graphical elements around during updates, making statements like "upper left" obsolete and stale. We can generally trust our readers to be familiar with their own systems and the software they use, and none of our documents involve introducing readers to new GUI software. (E.g. the Unity tutorial is targeted at introducing SpacetimeDB to people who already know Unity.) "Open the **File** menu and navigate **File -> Export as -> Export as PDF**" is sufficient. + ## Key vocabulary There are a small number of key terms that we need to use consistently throughout the documentation. From 36cb01e498b00131d3dbd62978db406b92a82726 Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Wed, 12 Feb 2025 08:28:10 -0800 Subject: [PATCH 095/164] Remove WebSocket api docs and all references to them (#165) Closes #164. --------- Co-authored-by: Phoebe Goldman --- docs/http/database.md | 11 +- docs/http/index.md | 2 +- docs/nav.js | 2 - docs/satn.md | 2 +- docs/sql/index.md | 3 +- docs/ws/index.md | 318 ------------------------------------------ nav.ts | 3 - 7 files changed, 11 insertions(+), 330 deletions(-) delete mode 100644 docs/ws/index.md diff --git a/docs/http/database.md b/docs/http/database.md index b23701e8..956a0cec 100644 --- a/docs/http/database.md +++ b/docs/http/database.md @@ -13,7 +13,7 @@ The HTTP endpoints in `/database` allow clients to interact with Spacetime datab | [`/database/register_tld GET`](#databaseregister_tld-get) | Register a top-level domain. | | [`/database/publish POST`](#databasepublish-post) | Publish a database given its module code. | | [`/database/delete/:address POST`](#databasedeleteaddress-post) | Delete a database. | -| [`/database/subscribe/:name_or_address GET`](#databasesubscribename_or_address-get) | Begin a [WebSocket connection](/docs/ws). | +| [`/database/subscribe/:name_or_address GET`](#databasesubscribename_or_address-get) | Begin a WebSocket connection. | | [`/database/call/:name_or_address/:reducer POST`](#databasecallname_or_addressreducer-post) | Invoke a reducer in a database. | | [`/database/schema/:name_or_address GET`](#databaseschemaname_or_address-get) | Get the schema for a database. | | [`/database/schema/:name_or_address/:entity_type/:entity GET`](#databaseschemaname_or_addressentity_typeentity-get) | Get a schema for a particular table or reducer. | @@ -248,7 +248,7 @@ Accessible through the CLI as `spacetime delete
`. ## `/database/subscribe/:name_or_address GET` -Begin a [WebSocket connection](/docs/ws) with a database. +Begin a WebSocket connection with a database. #### Parameters @@ -262,12 +262,17 @@ For more information about WebSocket headers, see [RFC 6455](https://datatracker | Name | Value | | ------------------------ | ---------------------------------------------------------------------------------------------------- | -| `Sec-WebSocket-Protocol` | [`v1.bin.spacetimedb`](/docs/ws#binary-protocol) or [`v1.text.spacetimedb`](/docs/ws#text-protocol). | +| `Sec-WebSocket-Protocol` | `v1.bin.spacetimedb` or `v1.text.spacetimedb` | | `Connection` | `Updgrade` | | `Upgrade` | `websocket` | | `Sec-WebSocket-Version` | `13` | | `Sec-WebSocket-Key` | A 16-byte value, generated randomly by the client, encoded as Base64. | +The SpacetimeDB binary WebSocket protocol, `v1.bin.spacetimedb`, encodes messages as well as reducer and row data using [BSATN](/docs/bsatn). +Its messages are defined [here](https://github.com/clockworklabs/SpacetimeDB/blob/master/crates/client-api-messages/src/websocket.rs). + +The SpacetimeDB text WebSocket protocol, `v1.text.spacetimedb`, encodes messages according to the [SATN JSON format](/docs/satn). + #### Optional Headers | Name | Value | diff --git a/docs/http/index.md b/docs/http/index.md index a59408e4..3f790b10 100644 --- a/docs/http/index.md +++ b/docs/http/index.md @@ -8,7 +8,7 @@ Rather than a password, each Spacetime identity is associated with a private tok Clients can request a new identity and token via [the `/identity POST` HTTP endpoint](/docs/http/identity#identity-post). -Alternately, a new identity and token will be generated during an anonymous connection via the [WebSocket API](/docs/ws), and passed to the client as [an `IdentityToken` message](/docs/ws#identitytoken). +Alternately, a new identity and token will be generated during an anonymous connection via the WebSocket API, and passed to the client as an `IdentityToken` message. ### Encoding `Authorization` headers diff --git a/docs/nav.js b/docs/nav.js index bdf49517..244f92b8 100644 --- a/docs/nav.js +++ b/docs/nav.js @@ -40,8 +40,6 @@ const nav = { page('`/identity`', 'http/identity', 'http/identity.md'), page('`/database`', 'http/database', 'http/database.md'), page('`/energy`', 'http/energy', 'http/energy.md'), - section('WebSocket API Reference'), - page('WebSocket', 'ws', 'ws/index.md'), section('Data Format'), page('SATN', 'satn', 'satn.md'), page('BSATN', 'bsatn', 'bsatn.md'), diff --git a/docs/satn.md b/docs/satn.md index 6fb0ee9f..3deb4851 100644 --- a/docs/satn.md +++ b/docs/satn.md @@ -1,6 +1,6 @@ # SATN JSON Format -The Spacetime Algebraic Type Notation JSON format defines how Spacetime `AlgebraicType`s and `AlgebraicValue`s are encoded as JSON. Algebraic types and values are JSON-encoded for transport via the [HTTP Databases API](/docs/http/database) and the [WebSocket text protocol](/docs/ws#text-protocol). +The Spacetime Algebraic Type Notation JSON format defines how Spacetime `AlgebraicType`s and `AlgebraicValue`s are encoded as JSON. Algebraic types and values are JSON-encoded for transport via the [HTTP Databases API](/docs/http/database) and the WebSocket text protocol. ## Values diff --git a/docs/sql/index.md b/docs/sql/index.md index d2808d38..09a250f1 100644 --- a/docs/sql/index.md +++ b/docs/sql/index.md @@ -2,7 +2,7 @@ SpacetimeDB supports two subsets of SQL: One for queries issued through the cli or [http] api. -Another for subscriptions issued via the [sdk] or [websocket] api. +Another for subscriptions issued via the [sdk] or WebSocket api. ## Subscriptions @@ -476,7 +476,6 @@ column [sdk]: /docs/sdks/rust/index.md#subscribe-to-queries [http]: /docs/http/database#databasesqlname_or_address-post -[websocket]: /docs/ws/index.md#subscribe [Identity]: /docs/index.md#identity [Address]: /docs/index.md#address diff --git a/docs/ws/index.md b/docs/ws/index.md deleted file mode 100644 index 1a3780cc..00000000 --- a/docs/ws/index.md +++ /dev/null @@ -1,318 +0,0 @@ -# The SpacetimeDB WebSocket API - -As an extension of the [HTTP API](/docs/http), SpacetimeDB offers a WebSocket API. Clients can subscribe to a database via a WebSocket connection to receive streaming updates as the database changes, and send requests to invoke reducers. Messages received from the server over a WebSocket will follow the same total ordering of transactions as are committed to the database. - -The SpacetimeDB SDKs comminicate with their corresponding database using the WebSocket API. - -## Connecting - -To initiate a WebSocket connection, send a `GET` request to the [`/database/subscribe/:name_or_address` endpoint](/docs/http/database#databasesubscribename_or_address-get) with headers appropriate to upgrade to a WebSocket connection as per [RFC 6455](https://datatracker.ietf.org/doc/html/rfc6455). - -To re-connect with an existing identity, include its token in a [SpacetimeDB Authorization header](/docs/http). Otherwise, a new identity and token will be generated for the client. - -## Protocols - -Clients connecting via WebSocket can choose between two protocols, [`v1.bin.spacetimedb`](#binary-protocol) and [`v1.text.spacetimedb`](#text-protocol). Clients should include one of these protocols in the `Sec-WebSocket-Protocol` header of their request. - -| `Sec-WebSocket-Protocol` header value | Selected protocol | -| ------------------------------------- | -------------------------- | -| `v1.bin.spacetimedb` | [Binary](#binary-protocol) | -| `v1.text.spacetimedb` | [Text](#text-protocol) | - -### Binary Protocol - -The SpacetimeDB binary WebSocket protocol, `v1.bin.spacetimedb`, encodes messages using [ProtoBuf 3](https://protobuf.dev), and reducer and row data using [BSATN](/docs/bsatn). - -The binary protocol's messages are defined in [`client_api.proto`](https://github.com/clockworklabs/SpacetimeDB/blob/master/crates/client-api-messages/protobuf/client_api.proto). - -### Text Protocol - -The SpacetimeDB text WebSocket protocol, `v1.text.spacetimedb`, encodes messages, reducer and row data as JSON. Reducer arguments and table rows are JSON-encoded according to the [SATN JSON format](/docs/satn). - -## Messages - -### Client to server - -| Message | Description | -| ------------------------------- | --------------------------------------------------------------------------- | -| [`FunctionCall`](#functioncall) | Invoke a reducer. | -| [`Subscribe`](#subscribe) | Register queries to receive streaming updates for a subset of the database. | - -#### `FunctionCall` - -Clients send a `FunctionCall` message to request that the database run a reducer. The message includes the reducer's name and a SATS `ProductValue` of arguments. - -##### Binary: ProtoBuf definition - -```protobuf -message FunctionCall { - string reducer = 1; - bytes argBytes = 2; -} -``` - -| Field | Value | -| ---------- | -------------------------------------------------------- | -| `reducer` | The name of the reducer to invoke. | -| `argBytes` | The reducer arguments encoded as a BSATN `ProductValue`. | - -##### Text: JSON encoding - -```typescript -{ - "call": { - "fn": string, - "args": array, - } -} -``` - -| Field | Value | -| ------ | ---------------------------------------------- | -| `fn` | The name of the reducer to invoke. | -| `args` | The reducer arguments encoded as a JSON array. | - -#### `Subscribe` - -Clients send a `Subscribe` message to register SQL queries in order to receive streaming updates. - -The client will only receive [`TransactionUpdate`s](#transactionupdate) for rows to which it is subscribed, and for reducer runs which alter at least one subscribed row. As a special exception, the client is always notified when a reducer run it requests via a [`FunctionCall` message](#functioncall) fails. - -SpacetimeDB responds to each `Subscribe` message with a [`SubscriptionUpdate` message](#subscriptionupdate) containing all matching rows at the time the subscription is applied. - -Each `Subscribe` message establishes a new set of subscriptions, replacing all previous subscriptions. Clients which want to add a query to an existing subscription must send a `Subscribe` message containing all the previous queries in addition to the new query. In this case, the returned [`SubscriptionUpdate`](#subscriptionupdate) will contain all previously-subscribed rows in addition to the newly-subscribed rows. - -Each query must be a SQL `SELECT * FROM` statement on a single table with an optional `WHERE` clause. See the [SQL Reference](/docs/sql) for the subset of SQL supported by SpacetimeDB. - -##### Binary: ProtoBuf definition - -```protobuf -message Subscribe { - repeated string query_strings = 1; -} -``` - -| Field | Value | -| --------------- | ----------------------------------------------------------------- | -| `query_strings` | A sequence of strings, each of which contains a single SQL query. | - -##### Text: JSON encoding - -```typescript -{ - "subscribe": { - "query_strings": array - } -} -``` - -| Field | Value | -| --------------- | --------------------------------------------------------------- | -| `query_strings` | An array of strings, each of which contains a single SQL query. | - -### Server to client - -| Message | Description | -| ------------------------------------------- | -------------------------------------------------------------------------- | -| [`IdentityToken`](#identitytoken) | Sent once upon successful connection with the client's identity and token. | -| [`SubscriptionUpdate`](#subscriptionupdate) | Initial message in response to a [`Subscribe` message](#subscribe). | -| [`TransactionUpdate`](#transactionupdate) | Streaming update after a reducer runs containing altered rows. | - -#### `IdentityToken` - -Upon establishing a WebSocket connection, the server will send an `IdentityToken` message containing the client's identity and token. If the client included a [SpacetimeDB Authorization header](/docs/http) in their connection request, the `IdentityToken` message will contain the same token used to connect, and its corresponding identity. If the client connected anonymously, SpacetimeDB will generate a new identity and token for the client. - -##### Binary: ProtoBuf definition - -```protobuf -message IdentityToken { - bytes identity = 1; - string token = 2; -} -``` - -| Field | Value | -| ---------- | --------------------------------------- | -| `identity` | The client's public Spacetime identity. | -| `token` | The client's private access token. | - -##### Text: JSON encoding - -```typescript -{ - "IdentityToken": { - "identity": array, - "token": string - } -} -``` - -| Field | Value | -| ---------- | --------------------------------------- | -| `identity` | The client's public Spacetime identity. | -| `token` | The client's private access token. | - -#### `SubscriptionUpdate` - -In response to a [`Subscribe` message](#subscribe), the database sends a `SubscriptionUpdate` containing all of the matching rows which are resident in the database at the time the `Subscribe` was received. - -##### Binary: ProtoBuf definition - -```protobuf -message SubscriptionUpdate { - repeated TableUpdate tableUpdates = 1; -} - -message TableUpdate { - uint32 tableId = 1; - string tableName = 2; - repeated TableRowOperation tableRowOperations = 3; -} - -message TableRowOperation { - enum OperationType { - DELETE = 0; - INSERT = 1; - } - OperationType op = 1; - bytes row = 3; -} -``` - -Each `SubscriptionUpdate` contains a `TableUpdate` for each table with subscribed rows. Each `TableUpdate` contains a `TableRowOperation` for each subscribed row. `SubscriptionUpdate`, `TableUpdate` and `TableRowOperation` are also used by the [`TransactionUpdate` message](#transactionupdate) to encode rows altered by a reducer, so `TableRowOperation` includes an `OperationType` which identifies the row alteration as either an insert or a delete. When a client receives a `SubscriptionUpdate` message in response to a [`Subscribe` message](#subscribe), all of the `TableRowOperation`s will have `op` of `INSERT`. - -| `TableUpdate` field | Value | -| -------------------- | ------------------------------------------------------------------------------------------------------------- | -| `tableId` | An integer identifier for the table. A table's `tableId` is not stable, so clients should not depend on it. | -| `tableName` | The string name of the table. Clients should use this field to identify the table, rather than the `tableId`. | -| `tableRowOperations` | A `TableRowOperation` for each inserted or deleted row. | - -| `TableRowOperation` field | Value | -| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `op` | `INSERT` for inserted rows during a [`TransactionUpdate`](#transactionupdate) or rows resident upon applying a subscription; `DELETE` for deleted rows during a [`TransactionUpdate`](#transactionupdate). | -| `row` | The altered row, encoded as a BSATN `ProductValue`. | - -##### Text: JSON encoding - -```typescript -// SubscriptionUpdate: -{ - "SubscriptionUpdate": { - "table_updates": array - } -} - -// TableUpdate: -{ - "table_id": number, - "table_name": string, - "table_row_operations": array -} - -// TableRowOperation: -{ - "op": "insert" | "delete", - "row": array -} -``` - -Each `SubscriptionUpdate` contains a `TableUpdate` for each table with subscribed rows. Each `TableUpdate` contains a `TableRowOperation` for each subscribed row. `SubscriptionUpdate`, `TableUpdate` and `TableRowOperation` are also used by the [`TransactionUpdate` message](#transactionupdate) to encode rows altered by a reducer, so `TableRowOperation` includes an `"op"` field which identifies the row alteration as either an insert or a delete. When a client receives a `SubscriptionUpdate` message in response to a [`Subscribe` message](#subscribe), all of the `TableRowOperation`s will have `"op"` of `"insert"`. - -| `TableUpdate` field | Value | -| ---------------------- | -------------------------------------------------------------------------------------------------------------- | -| `table_id` | An integer identifier for the table. A table's `table_id` is not stable, so clients should not depend on it. | -| `table_name` | The string name of the table. Clients should use this field to identify the table, rather than the `table_id`. | -| `table_row_operations` | A `TableRowOperation` for each inserted or deleted row. | - -| `TableRowOperation` field | Value | -| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `op` | `"insert"` for inserted rows during a [`TransactionUpdate`](#transactionupdate) or rows resident upon applying a subscription; `"delete"` for deleted rows during a [`TransactionUpdate`](#transactionupdate). | -| `row` | The altered row, encoded as a JSON array. | - -#### `TransactionUpdate` - -Upon a reducer run, a client will receive a `TransactionUpdate` containing information about the reducer which ran and the subscribed rows which it altered. Clients will only receive a `TransactionUpdate` for a reducer invocation if either of two criteria is met: - -1. The reducer ran successfully and altered at least one row to which the client subscribes. -2. The reducer was invoked by the client, and either failed or was terminated due to insufficient energy. - -Each `TransactionUpdate` contains a [`SubscriptionUpdate`](#subscriptionupdate) with all rows altered by the reducer, including inserts and deletes; and an `Event` with information about the reducer itself, including a [`FunctionCall`](#functioncall) containing the reducer's name and arguments. - -##### Binary: ProtoBuf definition - -```protobuf -message TransactionUpdate { - Event event = 1; - SubscriptionUpdate subscriptionUpdate = 2; -} - -message Event { - enum Status { - committed = 0; - failed = 1; - out_of_energy = 2; - } - uint64 timestamp = 1; - bytes callerIdentity = 2; - FunctionCall functionCall = 3; - Status status = 4; - string message = 5; - int64 energy_quanta_used = 6; - uint64 host_execution_duration_micros = 7; -} -``` - -| Field | Value | -| -------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `event` | An `Event` containing information about the reducer run. | -| `subscriptionUpdate` | A [`SubscriptionUpdate`](#subscriptionupdate) containing all the row insertions and deletions committed by the transaction. | - -| `Event` field | Value | -| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `timestamp` | The time when the reducer started, as microseconds since the Unix epoch. | -| `callerIdentity` | The identity of the client which requested the reducer invocation. For event-driven and scheduled reducers, this is the identity of the database owner. | -| `functionCall` | A [`FunctionCall`](#functioncall) containing the name of the reducer and the arguments passed to it. | -| `status` | `committed` if the reducer ran successfully and its changes were committed to the database; `failed` if the reducer signaled an error; `out_of_energy` if the reducer was canceled due to insufficient energy. | -| `message` | The error message with which the reducer failed if `status` is `failed`, or the empty string otherwise. | -| `energy_quanta_used` | The amount of energy consumed by running the reducer. | -| `host_execution_duration_micros` | The duration of the reducer's execution, in microseconds. | - -##### Text: JSON encoding - -```typescript -// TransactionUpdate: -{ - "TransactionUpdate": { - "event": Event, - "subscription_update": SubscriptionUpdate - } -} - -// Event: -{ - "timestamp": number, - "status": "committed" | "failed" | "out_of_energy", - "caller_identity": string, - "function_call": { - "reducer": string, - "args": array, - }, - "energy_quanta_used": number, - "message": string -} -``` - -| Field | Value | -| --------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `event` | An `Event` containing information about the reducer run. | -| `subscription_update` | A [`SubscriptionUpdate`](#subscriptionupdate) containing all the row insertions and deletions committed by the transaction. | - -| `Event` field | Value | -| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `timestamp` | The time when the reducer started, as microseconds since the Unix epoch. | -| `status` | `committed` if the reducer ran successfully and its changes were committed to the database; `failed` if the reducer signaled an error; `out_of_energy` if the reducer was canceled due to insufficient energy. | -| `caller_identity` | The identity of the client which requested the reducer invocation. For event-driven and scheduled reducers, this is the identity of the database owner. | -| `function_call.reducer` | The name of the reducer. | -| `function_call.args` | The reducer arguments encoded as a JSON array. | -| `energy_quanta_used` | The amount of energy consumed by running the reducer. | -| `message` | The error message with which the reducer failed if `status` is `failed`, or the empty string otherwise. | diff --git a/nav.ts b/nav.ts index 609a7f01..364b8cea 100644 --- a/nav.ts +++ b/nav.ts @@ -87,9 +87,6 @@ const nav: Nav = { page('`/database`', 'http/database', 'http/database.md'), page('`/energy`', 'http/energy', 'http/energy.md'), - section('WebSocket API Reference'), - page('WebSocket', 'ws', 'ws/index.md'), - section('Data Format'), page('SATN', 'satn', 'satn.md'), page('BSATN', 'bsatn', 'bsatn.md'), From 6f59ad787fec9959d07cede2eba87c587251e45c Mon Sep 17 00:00:00 2001 From: Zeke Foppa <196249+bfops@users.noreply.github.com> Date: Tue, 18 Feb 2025 17:02:37 -0800 Subject: [PATCH 096/164] CLI docs (#168) * [bfops/cli-docs]: CLI docs * [bfops/cli-docs]: fix? * [bfops/cli-docs]: manual backticks * [bfops/cli-docs]: manual bold * [bfops/cli-docs]: manual bold * [bfops/cli-docs]: add README for maintaining CLI reference docs * [bfops/cli-docs]: maybe fix code? * [bfops/cli-docs]: tweak * [bfops/cli-docs]: tweak code * [bfops/cli-docs]: update --------- Co-authored-by: Zeke Foppa --- README.md | 10 + docs/cli-reference.md | 589 ++++++++++++++++++++++++++++++++++++++++++ docs/nav.js | 2 + nav.ts | 3 + 4 files changed, 604 insertions(+) create mode 100644 docs/cli-reference.md diff --git a/README.md b/README.md index 2165ae62..b5c66551 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,16 @@ git push -u origin a-branch-name-that-describes-my-change > NOTE! If you make a change to `nav.ts` you will have to run `npm run build` to generate a new `docs/nav.js` file. +#### CLI Reference Section +1. Make sure that https://github.com/clockworklabs/SpacetimeDB/pull/2276 is included in your `spacetimedb-cli` binary +1. Run `cargo run --features markdown-docs -p spacetimedb-cli > cli-reference.md` + +We currently don't properly render markdown backticks and bolding that are inside of headers, so do these two manual replacements to make them look okay (these have only been tested on Linux): +```bash +sed -i'' -E 's!^(##) `(.*)`$!\1 \2!' docs/cli-reference.md +sed -i'' -E 's!^(######) \*\*(.*)\*\*$!\1 \2!' docs/cli-reference.md +``` + ### Checking Links We have a CI job which validates internal links. You can run it locally with `npm run check-links`. This will print any internal links (i.e. links to other docs pages) whose targets do not exist, including fragment links (i.e. `#`-ey links to anchors). diff --git a/docs/cli-reference.md b/docs/cli-reference.md new file mode 100644 index 00000000..8396f50a --- /dev/null +++ b/docs/cli-reference.md @@ -0,0 +1,589 @@ +# Command-Line Help for `spacetime` + +This document contains the help content for the `spacetime` command-line program. + +**Command Overview:** + +* [`spacetime`↴](#spacetime) +* [`spacetime publish`↴](#spacetime-publish) +* [`spacetime delete`↴](#spacetime-delete) +* [`spacetime logs`↴](#spacetime-logs) +* [`spacetime call`↴](#spacetime-call) +* [`spacetime describe`↴](#spacetime-describe) +* [`spacetime energy`↴](#spacetime-energy) +* [`spacetime energy balance`↴](#spacetime-energy-balance) +* [`spacetime sql`↴](#spacetime-sql) +* [`spacetime rename`↴](#spacetime-rename) +* [`spacetime generate`↴](#spacetime-generate) +* [`spacetime list`↴](#spacetime-list) +* [`spacetime login`↴](#spacetime-login) +* [`spacetime login show`↴](#spacetime-login-show) +* [`spacetime logout`↴](#spacetime-logout) +* [`spacetime init`↴](#spacetime-init) +* [`spacetime build`↴](#spacetime-build) +* [`spacetime server`↴](#spacetime-server) +* [`spacetime server list`↴](#spacetime-server-list) +* [`spacetime server set-default`↴](#spacetime-server-set-default) +* [`spacetime server add`↴](#spacetime-server-add) +* [`spacetime server remove`↴](#spacetime-server-remove) +* [`spacetime server fingerprint`↴](#spacetime-server-fingerprint) +* [`spacetime server ping`↴](#spacetime-server-ping) +* [`spacetime server edit`↴](#spacetime-server-edit) +* [`spacetime server clear`↴](#spacetime-server-clear) +* [`spacetime subscribe`↴](#spacetime-subscribe) +* [`spacetime start`↴](#spacetime-start) +* [`spacetime version`↴](#spacetime-version) + +## spacetime + +**Usage:** `spacetime [OPTIONS] ` + +###### Subcommands: + +* `publish` — Create and update a SpacetimeDB database +* `delete` — Deletes a SpacetimeDB database +* `logs` — Prints logs from a SpacetimeDB database +* `call` — Invokes a reducer function in a database. WARNING: This command is UNSTABLE and subject to breaking changes. +* `describe` — Describe the structure of a database or entities within it. WARNING: This command is UNSTABLE and subject to breaking changes. +* `energy` — Invokes commands related to database budgets. WARNING: This command is UNSTABLE and subject to breaking changes. +* `sql` — Runs a SQL query on the database. WARNING: This command is UNSTABLE and subject to breaking changes. +* `rename` — Rename a database +* `generate` — Generate client files for a spacetime module. +* `list` — Lists the databases attached to an identity. WARNING: This command is UNSTABLE and subject to breaking changes. +* `login` — Manage your login to the SpacetimeDB CLI +* `logout` — +* `init` — Initializes a new spacetime project. WARNING: This command is UNSTABLE and subject to breaking changes. +* `build` — Builds a spacetime module. +* `server` — Manage the connection to the SpacetimeDB server. WARNING: This command is UNSTABLE and subject to breaking changes. +* `subscribe` — Subscribe to SQL queries on the database. WARNING: This command is UNSTABLE and subject to breaking changes. +* `start` — Start a local SpacetimeDB instance +* `version` — Manage installed spacetime versions + +###### Options: + +* `--root-dir ` — The root directory to store all spacetime files in. +* `--config-path ` — The path to the cli.toml config file + + + +## spacetime publish + +Create and update a SpacetimeDB database + +**Usage:** `spacetime publish [OPTIONS] [name|identity]` + +Run `spacetime help publish` for more detailed information. + +###### Arguments: + +* `` — A valid domain or identity for this database + +###### Options: + +* `-c`, `--delete-data` — When publishing to an existing database identity, first DESTROY all data associated with the module +* `--build-options ` — Options to pass to the build command, for example --build-options='--skip-println-checks' + + Default value: `` +* `-p`, `--project-path ` — The system path (absolute or relative) to the module project + + Default value: `.` +* `-b`, `--bin-path ` — The system path (absolute or relative) to the compiled wasm binary we should publish, instead of building the project. +* `--anonymous` — Perform this action with an anonymous identity +* `-s`, `--server ` — The nickname, domain name or URL of the server to host the database. +* `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). + + + +## spacetime delete + +Deletes a SpacetimeDB database + +**Usage:** `spacetime delete [OPTIONS] ` + +Run `spacetime help delete` for more detailed information. + + +###### Arguments: + +* `` — The name or identity of the database to delete + +###### Options: + +* `-s`, `--server ` — The nickname, host name or URL of the server hosting the database +* `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). + + + +## spacetime logs + +Prints logs from a SpacetimeDB database + +**Usage:** `spacetime logs [OPTIONS] ` + +Run `spacetime help logs` for more detailed information. + + +###### Arguments: + +* `` — The name or identity of the database to print logs from + +###### Options: + +* `-s`, `--server ` — The nickname, host name or URL of the server hosting the database +* `-n`, `--num-lines ` — The number of lines to print from the start of the log of this database. If no num lines is provided, all lines will be returned. +* `-f`, `--follow` — A flag that causes logs to not stop when end of the log file is reached, but rather to wait for additional data to be appended to the input. +* `--format ` — Output format for the logs + + Default value: `text` + + Possible values: `text`, `json` + +* `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). + + + +## spacetime call + +Invokes a reducer function in a database. WARNING: This command is UNSTABLE and subject to breaking changes. + +**Usage:** `spacetime call [OPTIONS] [arguments]...` + +Run `spacetime help call` for more detailed information. + + +###### Arguments: + +* `` — The database name or identity to use to invoke the call +* `` — The name of the reducer to call +* `` — arguments formatted as JSON + +###### Options: + +* `-s`, `--server ` — The nickname, host name or URL of the server hosting the database +* `--anonymous` — Perform this action with an anonymous identity +* `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). + + + +## spacetime describe + +Describe the structure of a database or entities within it. WARNING: This command is UNSTABLE and subject to breaking changes. + +**Usage:** `spacetime describe [OPTIONS] --json [entity_type] [entity_name]` + +Run `spacetime help describe` for more detailed information. + + +###### Arguments: + +* `` — The name or identity of the database to describe +* `` — Whether to describe a reducer or table + + Possible values: `reducer`, `table` + +* `` — The name of the entity to describe + +###### Options: + +* `--json` — Output the schema in JSON format. Currently required; in the future, omitting this will give human-readable output. +* `--anonymous` — Perform this action with an anonymous identity +* `-s`, `--server ` — The nickname, host name or URL of the server hosting the database +* `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). + + + +## spacetime energy + +Invokes commands related to database budgets. WARNING: This command is UNSTABLE and subject to breaking changes. + +**Usage:** `spacetime energy + energy ` + +###### Subcommands: + +* `balance` — Show current energy balance for an identity + + + +## spacetime energy balance + +Show current energy balance for an identity + +**Usage:** `spacetime energy balance [OPTIONS]` + +###### Options: + +* `-i`, `--identity ` — The identity to check the balance for. If no identity is provided, the default one will be used. +* `-s`, `--server ` — The nickname, host name or URL of the server from which to request balance information +* `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). + + + +## spacetime sql + +Runs a SQL query on the database. WARNING: This command is UNSTABLE and subject to breaking changes. + +**Usage:** `spacetime sql [OPTIONS] ` + +###### Arguments: + +* `` — The name or identity of the database you would like to query +* `` — The SQL query to execute + +###### Options: + +* `--interactive` — Instead of using a query, run an interactive command prompt for `SQL` expressions +* `--anonymous` — Perform this action with an anonymous identity +* `-s`, `--server ` — The nickname, host name or URL of the server hosting the database +* `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). + + + +## spacetime rename + +Rename a database + +**Usage:** `spacetime rename [OPTIONS] --to ` + +Run `spacetime rename --help` for more detailed information. + + +###### Arguments: + +* `` — The database identity to rename + +###### Options: + +* `--to ` — The new name you would like to assign +* `-s`, `--server ` — The nickname, host name or URL of the server on which to set the name +* `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). + + + +## spacetime generate + +Generate client files for a spacetime module. + +**Usage:** `spacetime spacetime generate --lang --out-dir [--project-path | --bin-path ]` + +Run `spacetime help publish` for more detailed information. + +###### Options: + +* `-b`, `--bin-path ` — The system path (absolute or relative) to the compiled wasm binary we should inspect +* `-p`, `--project-path ` — The system path (absolute or relative) to the project you would like to inspect + + Default value: `.` +* `-o`, `--out-dir ` — The system path (absolute or relative) to the generate output directory +* `--namespace ` — The namespace that should be used + + Default value: `SpacetimeDB.Types` +* `-l`, `--lang ` — The language to generate + + Possible values: `csharp`, `typescript`, `rust` + +* `--build-options ` — Options to pass to the build command, for example --build-options='--skip-println-checks' + + Default value: `` +* `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). + + + +## spacetime list + +Lists the databases attached to an identity. WARNING: This command is UNSTABLE and subject to breaking changes. + +**Usage:** `spacetime list [OPTIONS]` + +###### Options: + +* `-s`, `--server ` — The nickname, host name or URL of the server from which to list databases +* `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). + + + +## spacetime login + +Manage your login to the SpacetimeDB CLI + +**Usage:** `spacetime login [OPTIONS] + login ` + +###### Subcommands: + +* `show` — Show the current login info + +###### Options: + +* `--auth-host ` — Fetch login token from a different host + + Default value: `https://spacetimedb.com` +* `--server-issued-login ` — Log in to a SpacetimeDB server directly, without going through a global auth server +* `--token ` — Bypass the login flow and use a login token directly + + + +## spacetime login show + +Show the current login info + +**Usage:** `spacetime login show [OPTIONS]` + +###### Options: + +* `--token` — Also show the auth token + + + +## spacetime logout + +**Usage:** `spacetime logout [OPTIONS]` + +###### Options: + +* `--auth-host ` — Log out from a custom auth server + + Default value: `https://spacetimedb.com` + + + +## spacetime init + +Initializes a new spacetime project. WARNING: This command is UNSTABLE and subject to breaking changes. + +**Usage:** `spacetime init --lang [project-path]` + +###### Arguments: + +* `` — The path where we will create the spacetime project + + Default value: `.` + +###### Options: + +* `-l`, `--lang ` — The spacetime module language. + + Possible values: `csharp`, `rust` + + + + +## spacetime build + +Builds a spacetime module. + +**Usage:** `spacetime build [OPTIONS]` + +###### Options: + +* `-p`, `--project-path ` — The system path (absolute or relative) to the project you would like to build + + Default value: `.` +* `--lint-dir ` — The directory to lint for nonfunctional print statements. If set to the empty string, skips linting. + + Default value: `src` +* `-d`, `--debug` — Builds the module using debug instead of release (intended to speed up local iteration, not recommended for CI) + + + +## spacetime server + +Manage the connection to the SpacetimeDB server. WARNING: This command is UNSTABLE and subject to breaking changes. + +**Usage:** `spacetime server + server ` + +###### Subcommands: + +* `list` — List stored server configurations +* `set-default` — Set the default server for future operations +* `add` — Add a new server configuration +* `remove` — Remove a saved server configuration +* `fingerprint` — Show or update a saved server's fingerprint +* `ping` — Checks to see if a SpacetimeDB host is online +* `edit` — Update a saved server's nickname, host name or protocol +* `clear` — Deletes all data from all local databases + + + +## spacetime server list + +List stored server configurations + +**Usage:** `spacetime server list` + + + +## spacetime server set-default + +Set the default server for future operations + +**Usage:** `spacetime server set-default ` + +###### Arguments: + +* `` — The nickname, host name or URL of the new default server + + + +## spacetime server add + +Add a new server configuration + +**Usage:** `spacetime server add [OPTIONS] --url ` + +###### Arguments: + +* `` — Nickname for this server + +###### Options: + +* `--url ` — The URL of the server to add +* `-d`, `--default` — Make the new server the default server for future operations +* `--no-fingerprint` — Skip fingerprinting the server + + + +## spacetime server remove + +Remove a saved server configuration + +**Usage:** `spacetime server remove [OPTIONS] ` + +###### Arguments: + +* `` — The nickname, host name or URL of the server to remove + +###### Options: + +* `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). + + + +## spacetime server fingerprint + +Show or update a saved server's fingerprint + +**Usage:** `spacetime server fingerprint [OPTIONS] ` + +###### Arguments: + +* `` — The nickname, host name or URL of the server + +###### Options: + +* `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). + + + +## spacetime server ping + +Checks to see if a SpacetimeDB host is online + +**Usage:** `spacetime server ping ` + +###### Arguments: + +* `` — The nickname, host name or URL of the server to ping + + + +## spacetime server edit + +Update a saved server's nickname, host name or protocol + +**Usage:** `spacetime server edit [OPTIONS] ` + +###### Arguments: + +* `` — The nickname, host name or URL of the server + +###### Options: + +* `--new-name ` — A new nickname to assign the server configuration +* `--url ` — A new URL to assign the server configuration +* `--no-fingerprint` — Skip fingerprinting the server +* `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). + + + +## spacetime server clear + +Deletes all data from all local databases + +**Usage:** `spacetime server clear [OPTIONS]` + +###### Options: + +* `--data-dir ` — The path to the server data directory to clear [default: that of the selected spacetime instance] +* `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). + + + +## spacetime subscribe + +Subscribe to SQL queries on the database. WARNING: This command is UNSTABLE and subject to breaking changes. + +**Usage:** `spacetime subscribe [OPTIONS] ...` + +###### Arguments: + +* `` — The name or identity of the database you would like to query +* `` — The SQL query to execute + +###### Options: + +* `-n`, `--num-updates ` — The number of subscription updates to receive before exiting +* `-t`, `--timeout ` — The timeout, in seconds, after which to disconnect and stop receiving subscription messages. If `-n` is specified, it will stop after whichever + one comes first. +* `--print-initial-update` — Print the initial update for the queries. +* `--anonymous` — Perform this action with an anonymous identity +* `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). +* `-s`, `--server ` — The nickname, host name or URL of the server hosting the database + + + +## spacetime start + +Start a local SpacetimeDB instance + +Run `spacetime start --help` to see all options. + +**Usage:** `spacetime start [OPTIONS] [args]...` + +###### Arguments: + +* `` — The args to pass to `spacetimedb-{edition} start` + +###### Options: + +* `--edition ` — The edition of SpacetimeDB to start up + + Default value: `standalone` + + Possible values: `standalone`, `cloud` + + + + +## spacetime version + +Manage installed spacetime versions + +Run `spacetime version --help` to see all options. + +**Usage:** `spacetime version [ARGS]...` + +###### Arguments: + +* `` — The args to pass to spacetimedb-update + + + +
+ + + This document was generated automatically by +
clap-markdown. + + diff --git a/docs/nav.js b/docs/nav.js index 244f92b8..9ac2dd5f 100644 --- a/docs/nav.js +++ b/docs/nav.js @@ -19,6 +19,8 @@ const nav = { page('2 - Connecting to SpacetimeDB', 'unity/part-2', 'unity/part-2.md'), page('3 - Gameplay', 'unity/part-3', 'unity/part-3.md'), page('4 - Moving and Colliding', 'unity/part-4', 'unity/part-4.md'), + section('CLI Reference'), + page('CLI Reference', 'cli-reference', 'cli-reference.md'), section('Server Module Languages'), page('Overview', 'modules', 'modules/index.md'), page('Rust Quickstart', 'modules/rust/quickstart', 'modules/rust/quickstart.md'), diff --git a/nav.ts b/nav.ts index 364b8cea..9cc2f295 100644 --- a/nav.ts +++ b/nav.ts @@ -46,6 +46,9 @@ const nav: Nav = { page('3 - Gameplay', 'unity/part-3', 'unity/part-3.md'), page('4 - Moving and Colliding', 'unity/part-4', 'unity/part-4.md'), + section('CLI Reference'), + page('CLI Reference', 'cli-reference', 'cli-reference.md'), + section('Server Module Languages'), page('Overview', 'modules', 'modules/index.md'), page( From 29e5990ea27d2d1284c4a71069d34299499073c8 Mon Sep 17 00:00:00 2001 From: Mario Montoya Date: Thu, 20 Feb 2025 11:17:48 -0500 Subject: [PATCH 097/164] Add link to the `cli` (#171) --- docs/sql/index.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/sql/index.md b/docs/sql/index.md index 09a250f1..1983cbb1 100644 --- a/docs/sql/index.md +++ b/docs/sql/index.md @@ -1,7 +1,7 @@ # SQL Support SpacetimeDB supports two subsets of SQL: -One for queries issued through the cli or [http] api. +One for queries issued through the [cli] or [http] api. Another for subscriptions issued via the [sdk] or WebSocket api. ## Subscriptions @@ -476,6 +476,7 @@ column [sdk]: /docs/sdks/rust/index.md#subscribe-to-queries [http]: /docs/http/database#databasesqlname_or_address-post +[cli]: /docs/cli-reference.md#spacetime-sql [Identity]: /docs/index.md#identity [Address]: /docs/index.md#address From 1dbc7b0aaec3972d039d9d7ba900420affa4d9fe Mon Sep 17 00:00:00 2001 From: Noa Date: Thu, 20 Feb 2025 23:45:22 -0600 Subject: [PATCH 098/164] Rename satn.md -> sats-json.md (#158) --- docs/bsatn.md | 2 +- docs/http/database.md | 12 ++++++------ docs/nav.js | 2 +- docs/{satn.md => sats-json.md} | 12 +++++++++--- nav.ts | 2 +- 5 files changed, 18 insertions(+), 12 deletions(-) rename docs/{satn.md => sats-json.md} (88%) diff --git a/docs/bsatn.md b/docs/bsatn.md index e8e6d945..703e210c 100644 --- a/docs/bsatn.md +++ b/docs/bsatn.md @@ -104,6 +104,6 @@ Where All SATS types are BSATN-encoded by converting them to an `AlgebraicValue`, then BSATN-encoding that meta-value. -See [the SATN JSON Format](/docs/satn) +See [the SATN JSON Format](/docs/sats-json) for more details of the conversion to meta values. Note that these meta values are converted to BSATN and _not JSON_. diff --git a/docs/http/database.md b/docs/http/database.md index 956a0cec..1cff2eab 100644 --- a/docs/http/database.md +++ b/docs/http/database.md @@ -271,7 +271,7 @@ For more information about WebSocket headers, see [RFC 6455](https://datatracker The SpacetimeDB binary WebSocket protocol, `v1.bin.spacetimedb`, encodes messages as well as reducer and row data using [BSATN](/docs/bsatn). Its messages are defined [here](https://github.com/clockworklabs/SpacetimeDB/blob/master/crates/client-api-messages/src/websocket.rs). -The SpacetimeDB text WebSocket protocol, `v1.text.spacetimedb`, encodes messages according to the [SATN JSON format](/docs/satn). +The SpacetimeDB text WebSocket protocol, `v1.text.spacetimedb`, encodes messages according to the [SATS-JSON format](/docs/sats-json). #### Optional Headers @@ -414,9 +414,9 @@ The `"entities"` will be an object whose keys are table and reducer names, and w | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | `arity` | For tables, the number of colums; for reducers, the number of arguments. | | `type` | For tables, `"table"`; for reducers, `"reducer"`. | -| `schema` | A [JSON-encoded `ProductType`](/docs/satn); for tables, the table schema; for reducers, the argument schema. Only present if `expand` is supplied and true. | +| `schema` | A [JSON-encoded `ProductType`](/docs/sats-json); for tables, the table schema; for reducers, the argument schema. Only present if `expand` is supplied and true. | -The `"typespace"` will be a JSON array of [`AlgebraicType`s](/docs/satn) referenced by the module. This can be used to resolve `Ref` types within the schema; the type `{ "Ref": n }` refers to `response["typespace"][n]`. +The `"typespace"` will be a JSON array of [`AlgebraicType`s](/docs/sats-json) referenced by the module. This can be used to resolve `Ref` types within the schema; the type `{ "Ref": n }` refers to `response["typespace"][n]`. ## `/database/schema/:name_or_address/:entity_type/:entity GET` @@ -454,7 +454,7 @@ Returns a single entity in the same format as in the `"entities"` returned by [t | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | `arity` | For tables, the number of colums; for reducers, the number of arguments. | | `type` | For tables, `"table"`; for reducers, `"reducer"`. | -| `schema` | A [JSON-encoded `ProductType`](/docs/satn); for tables, the table schema; for reducers, the argument schema. Only present if `expand` is supplied and true. | +| `schema` | A [JSON-encoded `ProductType`](/docs/sats-json); for tables, the table schema; for reducers, the argument schema. Only present if `expand` is supplied and true. | ## `/database/info/:name_or_address GET` @@ -548,6 +548,6 @@ Returns a JSON array of statement results, each of which takes the form: } ``` -The `schema` will be a [JSON-encoded `ProductType`](/docs/satn) describing the type of the returned rows. +The `schema` will be a [JSON-encoded `ProductType`](/docs/sats-json) describing the type of the returned rows. -The `rows` will be an array of [JSON-encoded `ProductValue`s](/docs/satn), each of which conforms to the `schema`. +The `rows` will be an array of [JSON-encoded `ProductValue`s](/docs/sats-json), each of which conforms to the `schema`. diff --git a/docs/nav.js b/docs/nav.js index 9ac2dd5f..3c514f71 100644 --- a/docs/nav.js +++ b/docs/nav.js @@ -43,7 +43,7 @@ const nav = { page('`/database`', 'http/database', 'http/database.md'), page('`/energy`', 'http/energy', 'http/energy.md'), section('Data Format'), - page('SATN', 'satn', 'satn.md'), + page('SATS-JSON', 'sats-json', 'sats-json.md'), page('BSATN', 'bsatn', 'bsatn.md'), section('SQL'), page('SQL Reference', 'sql', 'sql/index.md'), diff --git a/docs/satn.md b/docs/sats-json.md similarity index 88% rename from docs/satn.md rename to docs/sats-json.md index 3deb4851..d115bad4 100644 --- a/docs/satn.md +++ b/docs/sats-json.md @@ -1,6 +1,6 @@ -# SATN JSON Format +# SATS-JSON Format -The Spacetime Algebraic Type Notation JSON format defines how Spacetime `AlgebraicType`s and `AlgebraicValue`s are encoded as JSON. Algebraic types and values are JSON-encoded for transport via the [HTTP Databases API](/docs/http/database) and the WebSocket text protocol. +The Spacetime Algebraic Type System JSON format defines how Spacetime `AlgebraicType`s and `AlgebraicValue`s are encoded as JSON. Algebraic types and values are JSON-encoded for transport via the [HTTP Databases API](/docs/http/database) and the WebSocket text protocol. Note that SATS-JSON is not self-describing, and so a SATS value represented in JSON requires knowing the value's schema to meaningfully understand it - for example, it's not possible to tell whether a JSON object with a single field is a `ProductValue` with one element or a `SumValue`. ## Values @@ -32,6 +32,8 @@ The tag is an index into the [`SumType.variants`](#sumtype) array of the value's } ``` +The tag may also be the name of one of the variants. + ### `ProductValue` An instance of a [`ProductType`](#producttype). `ProductValue`s are encoded as JSON arrays. Each element of the `ProductValue` array is of the type of the corresponding index in the [`ProductType.elements`](#producttype) array of the value's [`ProductType`](#producttype). @@ -40,6 +42,10 @@ An instance of a [`ProductType`](#producttype). `ProductValue`s are encoded as J array ``` +`ProductValue`s may also be encoded as a JSON object with the keys as the field +names of the `ProductValue` and the values as the corresponding +`AlgebraicValue`s. + ### `BuiltinValue` An instance of a [`BuiltinType`](#builtintype). `BuiltinValue`s are encoded as JSON values of corresponding types. @@ -69,7 +75,7 @@ All SATS types are JSON-encoded by converting them to an `AlgebraicValue`, then | --------------------------------------- | ------------------------------------------------------------------------------------ | | [`AlgebraicType`](#algebraictype) | Any SATS type. | | [`SumType`](#sumtype) | Sum types, i.e. tagged unions. | -| [`ProductType`](#producttype) | Product types, i.e. structures. | +| [`ProductType`](#producttype) | Product types, i.e. structures. | | [`BuiltinType`](#builtintype) | Built-in and primitive types, including booleans, numbers, strings, arrays and maps. | | [`AlgebraicTypeRef`](#algebraictyperef) | An indirect reference to a type, used to implement recursive types. | diff --git a/nav.ts b/nav.ts index 9cc2f295..f9a7d425 100644 --- a/nav.ts +++ b/nav.ts @@ -91,7 +91,7 @@ const nav: Nav = { page('`/energy`', 'http/energy', 'http/energy.md'), section('Data Format'), - page('SATN', 'satn', 'satn.md'), + page('SATS-JSON', 'sats-json', 'sats-json.md'), page('BSATN', 'bsatn', 'bsatn.md'), section('SQL'), From 7e469311d5db69b1dd748cc030b85f8f561bbd8e Mon Sep 17 00:00:00 2001 From: Mario Montoya Date: Fri, 21 Feb 2025 13:06:57 -0500 Subject: [PATCH 099/164] Fix auto_inc attribute name (#175) --- docs/modules/rust/index.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/modules/rust/index.md b/docs/modules/rust/index.md index dba75ab2..30581599 100644 --- a/docs/modules/rust/index.md +++ b/docs/modules/rust/index.md @@ -201,7 +201,7 @@ struct Item { Note that reducers can call non-reducer functions, including standard library functions. -There are several macros which modify the semantics of a column, which are applied to the members of the table struct. `#[primary_key]`, `#[unique]` and `#[autoinc]` are covered below, describing how those attributes affect the semantics of inserting, filtering, and so on. +There are several macros which modify the semantics of a column, which are applied to the members of the table struct. `#[primary_key]`, `#[unique]` and `#[auto_inc]` are covered below, describing how those attributes affect the semantics of inserting, filtering, and so on. #[SpacetimeType] @@ -226,7 +226,7 @@ The `scheduled` attribute adds a couple of default fields and expands as follows struct SendMessageTimer { text: String, // original field #[primary_key] - #[autoinc] + #[auto_inc] scheduled_id: u64, // identifier for internal purpose scheduled_at: ScheduleAt, //schedule details } @@ -343,7 +343,7 @@ Only integer types can be `#[unique]`: `u8`, `u16`, `u32`, `u64`, `u128`, `i8`, ```rust #[table(name = autoinc, public)] struct Autoinc { - #[autoinc] + #[auto_inc] autoinc_field: u64, } ``` @@ -353,7 +353,7 @@ These attributes can be combined, to create an automatically assigned ID usable ```rust #[table(name = identity, public)] struct Identity { - #[autoinc] + #[auto_inc] #[unique] id_field: u64, } @@ -391,7 +391,7 @@ fn insert_unique(ctx: &ReducerContext, value: u64) { } ``` -When inserting a table with an `#[autoinc]` column, the database will automatically overwrite whatever we give it with an atomically increasing value. +When inserting a table with an `#[auto_inc]` column, the database will automatically overwrite whatever we give it with an atomically increasing value. The returned row has the `autoinc` column set to the value that was actually written into the database. From fba0007355555ef287a8acb84d6e804fb3f8b37c Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Mon, 24 Feb 2025 14:08:26 -0800 Subject: [PATCH 100/164] Document LIMIT and COUNT (#178) Closes #177. --- docs/sql/index.md | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/docs/sql/index.md b/docs/sql/index.md index 1983cbb1..b1f70d9c 100644 --- a/docs/sql/index.md +++ b/docs/sql/index.md @@ -157,14 +157,15 @@ SELECT * FROM Inventory WHERE price > {X} AND amount < {Y} ### SELECT ```ebnf -SELECT projection FROM relation [ WHERE predicate ] +SELECT projection FROM relation [ WHERE predicate ] [LIMIT NUM] ``` The query languge is a strict superset of the subscription language. The main differences are seen in column projections and [joins](#from-clause). The subscription api only supports `*` projections, -but the query api supports individual column projections. +but the query api supports both individual column projections, +as well as aggregations in the form of `COUNT`. The subscription api limits the number of tables you can join, and enforces index constraints on the join columns, @@ -177,11 +178,16 @@ projection = '*' | table '.' '*' | projExpr { ',' projExpr } + | aggExpr ; projExpr = column [ [ AS ] alias ] ; + +aggExpr + = COUNT '(' '*' ')' [AS] alias + ; ``` The `SELECT` clause determines the columns that are returned. @@ -196,6 +202,16 @@ SELECT * FROM Inventory; SELECT item_name, price FROM Inventory ``` +It also allows for counting the number of input rows via the `COUNT` function. +`COUNT` always returns a single row, even if the input is empty. + +##### Example + +```sql +-- Count the items in my inventory +SELECT COUNT(*) AS n FROM Inventory +``` + #### FROM Clause ```ebnf @@ -219,6 +235,19 @@ WHERE product.name = {product_name} See [Subscriptions](#where). +#### LIMIT clause + +Limits the number of rows a query returns by specifying an upper bound. +The `LIMIT` may return fewer rows if the query itself returns fewer rows. +`LIMIT` does not order or transform its input in any way. + +##### Examples + +```sql +-- Fetch an example row from my inventory +SELECT * FROM Inventory LIMIT 1 +``` + ### INSERT ```ebnf From 675882c4469e23d9c56b50d5130a7240fb55e78b Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Tue, 25 Feb 2025 08:59:34 -0800 Subject: [PATCH 101/164] Add best practices for Spacetime SQL (#180) Closes #179. --- docs/sql/index.md | 137 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) diff --git a/docs/sql/index.md b/docs/sql/index.md index b1f70d9c..59e90ca7 100644 --- a/docs/sql/index.md +++ b/docs/sql/index.md @@ -479,6 +479,143 @@ SELECT * FROM "Order" SELECT * FROM "Balance$" ``` +## Best Practices for Performance and Scalability + +When designing your schema or crafting your queries, +consider the following best practices to ensure optimal performance: + +- **Add Primary Key and/or Unique Constraints:** + Constrain columns whose values are guaranteed to be distinct as either unique or primary keys. + The query planner can further optimize joins if it knows the join values to be unique. + +- **Index Filtered Columns:** + Index columns frequently used in a `WHERE` clause. + Indexes reduce the number of rows scanned by the query engine. + +- **Index Join Columns:** + Index columns whose values are frequently used as join keys. + These are columns that are used in the `ON` condition of a `JOIN`. + + Again, this reduces the number of rows that must be scanned to answer a query. + It is also critical for the performance of subscription updates -- + so much so that it is a compiler-enforced requirement, + as mentioned in the [subscription](#from) section. + + If a column that has already been constrained as unique or a primary key, + it is not necessary to explicitly index it as well, + since these constraints automatically index the column in question. + +- **Optimize Join Order:** + Place tables with the most selective filters first in your `FROM` clause. + This minimizes intermediate result sizes and improves query efficiency. + +### Example + +Take the following query that was used in a previous example: +```sql +-- Find all customers who ordered a particular product and when they ordered it +SELECT customer.first_name, customer.last_name, o.date +FROM Customers customer +JOIN Orders o ON customer.id = o.customer_id +JOIN Inventory product ON o.product_id = product.id +WHERE product.name = {product_name} +``` + +In order to conform with the best practices for optimizing performance and scalability: + +- An index should be defined on `Inventory.name` because we are filtering on that column. +- `Inventory.id` and `Customers.id` should be defined as primary keys. +- Additionally non-unique indexes should be defined on `Orders.product_id` and `Orders.customer_id`. +- `Inventory` should appear first in the `FROM` clause because it is the only table mentioned in the `WHERE` clause. +- `Orders` should come next because it joins directly with `Inventory`. +- `Customers` should come next because it joins directly with `Orders`. + +:::server-rust +```rust +#[table( + name = Inventory, + index(name = product_name, btree = [name]), + public +)] +struct Inventory { + #[primary_key] + id: u64, + name: String, + .. +} + +#[table( + name = Customers, + public +)] +struct Customers { + #[primary_key] + id: u64, + first_name: String, + last_name: String, + .. +} + +#[table( + name = Orders, + public +)] +struct Orders { + #[primary_key] + id: u64, + #[unique] + product_id: u64, + #[unique] + customer_id: u64, + .. +} +``` +::: +:::server-csharp +```cs +[SpacetimeDB.Table(Name = "Inventory")] +[SpacetimeDB.Index(Name = "product_name", BTree = ["name"])] +public partial struct Inventory +{ + [SpacetimeDB.PrimaryKey] + public long id; + public string name; + .. +} + +[SpacetimeDB.Table(Name = "Customers")] +public partial struct Customers +{ + [SpacetimeDB.PrimaryKey] + public long id; + public string first_name; + public string last_name; + .. +} + +[SpacetimeDB.Table(Name = "Orders")] +public partial struct Orders +{ + [SpacetimeDB.PrimaryKey] + public long id; + [SpacetimeDB.Unique] + public long product_id; + [SpacetimeDB.Unique] + public long customer_id; + .. +} +``` +::: + +```sql +-- Find all customers who ordered a particular product and when they ordered it +SELECT c.first_name, c.last_name, o.date +FROM Inventory product +JOIN Orders o ON product.id = o.product_id +JOIN Customers c ON c.id = o.customer_id +WHERE product.name = {product_name}; +``` + ## Appendix Common production rules that have been used throughout this document. From b555ffb2d0f6e00ce98ee12f9f0f069d308b7b1f Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Tue, 25 Feb 2025 09:57:09 -0800 Subject: [PATCH 102/164] API for mutable subscriptions (#166) Closes #78. Includes rust and csharp examples. --- docs/nav.js | 2 + docs/subscriptions/index.md | 446 ++++++++++++++++++++++++++++++++++++ nav.ts | 3 + 3 files changed, 451 insertions(+) create mode 100644 docs/subscriptions/index.md diff --git a/docs/nav.js b/docs/nav.js index 3c514f71..eba9f496 100644 --- a/docs/nav.js +++ b/docs/nav.js @@ -47,6 +47,8 @@ const nav = { page('BSATN', 'bsatn', 'bsatn.md'), section('SQL'), page('SQL Reference', 'sql', 'sql/index.md'), + section('Subscriptions'), + page('Subscription Reference', 'subscriptions', 'subscriptions/index.md'), ], }; export default nav; diff --git a/docs/subscriptions/index.md b/docs/subscriptions/index.md new file mode 100644 index 00000000..a896f6a6 --- /dev/null +++ b/docs/subscriptions/index.md @@ -0,0 +1,446 @@ +# The SpacetimeDB Subscription API + +The subscription API allows a client to replicate a subset of a database. +It does so by registering SQL queries, which we call subscriptions, through a database connection. +A client will only receive updates for rows that match the subscriptions it has registered. + +For more information on syntax and requirements see the [SQL docs](/docs/sql#subscriptions). + +This guide describes the two main interfaces that comprise the API - `SubscriptionBuilder` and `SubscriptionHandle`. +By using these interfaces, you can create efficient and responsive client applications that only receive the data they need. + +## SubscriptionBuilder + +:::server-rust +```rust +pub struct SubscriptionBuilder { /* private fields */ } + +impl SubscriptionBuilder { + /// Register a callback that runs when the subscription has been applied. + /// This callback receives a context containing the current state of the subscription. + pub fn on_applied(mut self, callback: impl FnOnce(&M::SubscriptionEventContext) + Send + 'static); + + /// Register a callback to run when the subscription fails. + /// + /// Note that this callback may run either when attempting to apply the subscription, + /// in which case [`Self::on_applied`] will never run, + /// or later during the subscription's lifetime if the module's interface changes, + /// in which case [`Self::on_applied`] may have already run. + pub fn on_error(mut self, callback: impl FnOnce(&M::ErrorContext, crate::Error) + Send + 'static); + + /// Subscribe to a subset of the database via a set of SQL queries. + /// Returns a handle which you can use to monitor or drop the subscription later. + pub fn subscribe(self, query_sql: Queries) -> M::SubscriptionHandle; + + /// Subscribe to all rows from all tables. + /// + /// This method is intended as a convenience + /// for applications where client-side memory use and network bandwidth are not concerns. + /// Applications where these resources are a constraint + /// should register more precise queries via [`Self::subscribe`] + /// in order to replicate only the subset of data which the client needs to function. + pub fn subscribe_to_all_tables(self); +} + +/// Types which specify a list of query strings. +pub trait IntoQueries { + fn into_queries(self) -> Box<[Box]>; +} +``` +::: +:::server-csharp +```cs +public sealed class SubscriptionBuilder +{ + /// + /// Register a callback to run when the subscription is applied. + /// + public SubscriptionBuilder OnApplied( + Action callback + ); + + /// + /// Register a callback to run when the subscription fails. + /// + /// Note that this callback may run either when attempting to apply the subscription, + /// in which case Self::on_applied will never run, + /// or later during the subscription's lifetime if the module's interface changes, + /// in which case Self::on_applied may have already run. + /// + public SubscriptionBuilder OnError( + Action callback + ); + + /// + /// Subscribe to the following SQL queries. + /// + /// This method returns immediately, with the data not yet added to the DbConnection. + /// The provided callbacks will be invoked once the data is returned from the remote server. + /// Data from all the provided queries will be returned at the same time. + /// + /// See the SpacetimeDB SQL docs for more information on SQL syntax: + /// https://spacetimedb.com/docs/sql + /// + public SubscriptionHandle Subscribe( + string[] querySqls + ); + + /// + /// Subscribe to all rows from all tables. + /// + /// This method is intended as a convenience + /// for applications where client-side memory use and network bandwidth are not concerns. + /// Applications where these resources are a constraint + /// should register more precise queries via Self.Subscribe + /// in order to replicate only the subset of data which the client needs to function. + /// + public void SubscribeToAllTables(); +} +``` +::: + +A `SubscriptionBuilder` provides an interface for registering subscription queries with a database. +It allows you to register callbacks that run when the subscription is successfully applied or when an error occurs. +Once applied, a client will start receiving row updates to its client cache. +A client can react to these updates by registering row callbacks for the appropriate table. + +### Example Usage + +:::server-rust +```rust +// Establish a database connection +let conn: DbConnection = connect_to_db(); + +// Register a subscription with the database +let subscription_handle = conn + .subscription_builder() + .on_applied(|ctx| { /* handle applied state */ }) + .on_error(|error_ctx, error| { /* handle error */ }) + .subscribe(["SELECT * FROM user", "SELECT * FROM message"]); +``` +::: +:::server-csharp +```cs +// Establish a database connection +var conn = ConnectToDB(); + +// Register a subscription with the database +var userSubscription = conn + .SubscriptionBuilder() + .OnApplied((ctx) => { /* handle applied state */ }) + .OnError((errorCtx, error) => { /* handle error */ }) + .Subscribe(new string[] { "SELECT * FROM user", "SELECT * FROM message" }); +``` +::: + +## SubscriptionHandle + +:::server-rust +```rust +pub trait SubscriptionHandle: InModule + Clone + Send + 'static +where + Self::Module: SpacetimeModule, +{ + /// Returns `true` if the subscription has been ended. + /// That is, if it has been unsubscribed or terminated due to an error. + fn is_ended(&self) -> bool; + + /// Returns `true` if the subscription is currently active. + fn is_active(&self) -> bool; + + /// Unsubscribe from the query controlled by this `SubscriptionHandle`, + /// then run `on_end` when its rows are removed from the client cache. + /// Returns an error if the subscription is already ended, + /// or if unsubscribe has already been called. + fn unsubscribe_then(self, on_end: OnEndedCallback) -> crate::Result<()>; + + /// Unsubscribe from the query controlled by this `SubscriptionHandle`. + /// Returns an error if the subscription is already ended, + /// or if unsubscribe has already been called. + fn unsubscribe(self) -> crate::Result<()>; +} +``` +::: +:::server-csharp +```cs + public class SubscriptionHandle : ISubscriptionHandle + where SubscriptionEventContext : ISubscriptionEventContext + where ErrorContext : IErrorContext + { + /// + /// Whether the subscription has ended. + /// + public bool IsEnded; + + /// + /// Whether the subscription is active. + /// + public bool IsActive; + + /// + /// Unsubscribe from the query controlled by this subscription handle. + /// + /// Calling this more than once will result in an exception. + /// + public void Unsubscribe(); + + /// + /// Unsubscribe from the query controlled by this subscription handle, + /// and call onEnded when its rows are removed from the client cache. + /// + public void UnsubscribeThen(Action? onEnded); + } +``` +::: + +When you register a subscription, you receive a `SubscriptionHandle`. +A `SubscriptionHandle` manages the lifecycle of each subscription you register. +In particular, it provides methods to check the status of the subscription and to unsubscribe if necessary. +Because each subscription has its own independently managed lifetime, +clients can dynamically subscribe to different subsets of the database as their application requires. + +### Example Usage + +:::server-rust +Consider a game client that displays shop items and discounts based on a player's level. +You subscribe to `shop_items` and `shop_discounts` when a player is at level 5: + +```rust +let conn: DbConnection = connect_to_db(); + +let shop_items_subscription = conn + .subscription_builder() + .on_applied(|ctx| { /* handle applied state */ }) + .on_error(|error_ctx, error| { /* handle error */ }) + .subscribe([ + "SELECT * FROM shop_items WHERE required_level <= 5", + "SELECT * FROM shop_discounts WHERE required_level <= 5", + ]); +``` + +Later, when the player reaches level 6 and new items become available, +you can subscribe to the new queries and unsubscribe from the old ones: + +```rust +let new_shop_items_subscription = conn + .subscription_builder() + .on_applied(|ctx| { /* handle applied state */ }) + .on_error(|error_ctx, error| { /* handle error */ }) + .subscribe([ + "SELECT * FROM shop_items WHERE required_level <= 6", + "SELECT * FROM shop_discounts WHERE required_level <= 6", + ]); + +if shop_items_subscription.is_active() { + shop_items_subscription + .unsubscribe() + .expect("Unsubscribing from shop_items failed"); +} +``` + +All other subscriptions continue to remain in effect. +::: +:::server-csharp +Consider a game client that displays shop items and discounts based on a player's level. +You subscribe to `shop_items` and `shop_discounts` when a player is at level 5: + +```cs +var conn = ConnectToDB(); + +var shopItemsSubscription = conn + .SubscriptionBuilder() + .OnApplied((ctx) => { /* handle applied state */ }) + .OnError((errorCtx, error) => { /* handle error */ }) + .Subscribe(new string[] { + "SELECT * FROM shop_items WHERE required_level <= 5", + "SELECT * FROM shop_discounts WHERE required_level <= 5", + }); +``` + +Later, when the player reaches level 6 and new items become available, +you can subscribe to the new queries and unsubscribe from the old ones: + +```cs +var newShopItemsSubscription = conn + .SubscriptionBuilder() + .OnApplied((ctx) => { /* handle applied state */ }) + .OnError((errorCtx, error) => { /* handle error */ }) + .Subscribe(new string[] { + "SELECT * FROM shop_items WHERE required_level <= 6", + "SELECT * FROM shop_discounts WHERE required_level <= 6", + }); + +if (shopItemsSubscription.IsActive) +{ + shopItemsSubscription.Unsubscribe(); +} +``` + +All other subscriptions continue to remain in effect. +::: + +## Best Practices for Optimizing Server Compute and Reducing Serialization Overhead + +### 1. Writing Efficient SQL Queries + +For writing efficient SQL queries, see our [SQL Best Practices Guide](/docs/sql#best-practices-for-performance-and-scalability). + +### 2. Group Subscriptions with the Same Lifetime Together + +Subscriptions with the same lifetime should be grouped together. + +For example, you may have certain data that is required for the lifetime of your application, +but you may have other data that is only sometimes required by your application. + +By managing these sets as two independent subscriptions, +your application can subscribe and unsubscribe from the latter, +without needlessly unsubscribing and resubscribing to the former. + +This will improve throughput by reducing the amount of data transferred from the database to your application. + +#### Example + +:::server-rust +```rust +let conn: DbConnection = connect_to_db(); + +// Never need to unsubscribe from global subscriptions +let global_subscriptions = conn + .subscription_builder() + .subscribe([ + // Global messages the client should always display + "SELECT * FROM announcements", + // A description of rewards for in-game achievements + "SELECT * FROM badges", + ]); + +// May unsubscribe to shop_items as player advances +let shop_subscription = conn + .subscription_builder() + .subscribe([ + "SELECT * FROM shop_items WHERE required_level <= 5", + ]); +``` +::: +:::server-csharp +```cs +var conn = ConnectToDB(); + +// Never need to unsubscribe from global subscriptions +var globalSubscriptions = conn + .SubscriptionBuilder() + .Subscribe(new string[] { + // Global messages the client should always display + "SELECT * FROM announcements", + // A description of rewards for in-game achievements + "SELECT * FROM badges", + }); + +// May unsubscribe to shop_items as player advances +var shopSubscription = conn + .SubscriptionBuilder() + .Subscribe(new string[] { + "SELECT * FROM shop_items WHERE required_level <= 5" + }); +``` +::: + +### 3. Subscribe Before Unsubscribing + +If you want to update or modify a subscription by dropping it and subscribing to a new set, +you should subscribe to the new set before unsubscribing from the old one. + +This is because SpacetimeDB subscriptions are zero-copy. +Subscribing to the same query more than once doesn't incur additional processing or serialization overhead. +Likewise, if a query is subscribed to more than once, +unsubscribing from it does not result in any server processing or data serializtion. + +#### Example + +:::server-rust +```rust +let conn: DbConnection = connect_to_db(); + +// Initial subscription: player at level 5. +let shop_subscription = conn + .subscription_builder() + .subscribe([ + // For displaying the price of shop items in the player's currency of choice + "SELECT * FROM exchange_rates", + "SELECT * FROM shop_items WHERE required_level <= 5", + ]); + +// New subscription: player now at level 6, which overlaps with the previous query. +let new_shop_subscription = conn + .subscription_builder() + .subscribe([ + // For displaying the price of shop items in the player's currency of choice + "SELECT * FROM exchange_rates", + "SELECT * FROM shop_items WHERE required_level <= 6", + ]); + +// Unsubscribe from the old subscription once the new one is active. +if shop_subscription.is_active() { + shop_subscription.unsubscribe(); +} +``` +::: +:::server-csharp +```cs +var conn = ConnectToDB(); + +// Initial subscription: player at level 5. +var shopSubscription = conn + .SubscriptionBuilder() + .Subscribe(new string[] { + // For displaying the price of shop items in the player's currency of choice + "SELECT * FROM exchange_rates", + "SELECT * FROM shop_items WHERE required_level <= 5" + }); + +// New subscription: player now at level 6, which overlaps with the previous query. +var newShopSubscription = conn + .SubscriptionBuilder() + .Subscribe(new string[] { + // For displaying the price of shop items in the player's currency of choice + "SELECT * FROM exchange_rates", + "SELECT * FROM shop_items WHERE required_level <= 6" + }); + +// Unsubscribe from the old subscription once the new one is in place. +if (shopSubscription.IsActive) +{ + shopSubscription.Unsubscribe(); +} +``` +::: + +### 4. Avoid Overlapping Queries + +This refers to distinct queries that return intersecting data sets, +which can result in the server processing and serializing the same row multiple times. +While SpacetimeDB can manage this redundancy, it may lead to unnecessary inefficiencies. + +Consider the following two queries: +```sql +SELECT * FROM User +SELECT * FROM User WHERE id = 5 +``` + +If `User.id` is a unique or primary key column, +the cost of subscribing to both queries is minimal. +This is because the server will use an index when processing the 2nd query, +and it will only serialize a single row for the 2nd query. + +In contrast, consider these two queries: +```sql +SELECT * FROM User +SELECT * FROM User WHERE id != 5 +``` + +The server must now process each row of the `User` table twice, +since the 2nd query cannot be processed using an index. +It must also serialize all but one row of the `User` table twice, +due to the significant overlap between the two queries. + +By following these best practices, you can optimize your data replication strategy and ensure your application remains efficient and responsive. diff --git a/nav.ts b/nav.ts index f9a7d425..53d8c816 100644 --- a/nav.ts +++ b/nav.ts @@ -96,6 +96,9 @@ const nav: Nav = { section('SQL'), page('SQL Reference', 'sql', 'sql/index.md'), + + section('Subscriptions'), + page('Subscription Reference', 'subscriptions', 'subscriptions/index.md'), ], }; From a896a36174209d16734323f4efde34fa07bb58a1 Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Tue, 25 Feb 2025 10:10:48 -0800 Subject: [PATCH 103/164] Remove references to SpacetimeDB 0.6 Closes #118. --- docs/http/database.md | 6 ------ docs/http/energy.md | 2 -- docs/modules/index.md | 2 +- 3 files changed, 1 insertion(+), 9 deletions(-) diff --git a/docs/http/database.md b/docs/http/database.md index 1cff2eab..749bcefb 100644 --- a/docs/http/database.md +++ b/docs/http/database.md @@ -121,8 +121,6 @@ If the top-level domain is registered, but the identity provided in the `Authori } } ``` -> Spacetime top-level domains are an upcoming feature, and are not fully implemented in SpacetimeDB 0.6. For now, database names should not contain slashes. - ## `/database/ping GET` Does nothing and returns no data. Clients can send requests to this endpoint to determine whether they are able to connect to SpacetimeDB. @@ -131,8 +129,6 @@ Does nothing and returns no data. Clients can send requests to this endpoint to Register a new Spacetime top-level domain. A TLD is the part of a database name before the first `/`. For example, in the name `tyler/bitcraft`, the TLD is `tyler`. Each top-level domain is owned by at most one identity, and only the owner can publish databases with that TLD. -> Spacetime top-level domains are an upcoming feature, and are not fully implemented in SpacetimeDB 0.6. For now, database names should not contain slashes. - Accessible through the CLI as `spacetime dns register-tld `. #### Query Parameters @@ -226,8 +222,6 @@ If the top-level domain for the requested name is registered, but the identity p } } ``` -> Spacetime top-level domains are an upcoming feature, and are not fully implemented in SpacetimeDB 0.6. For now, database names should not contain slashes. - ## `/database/delete/:address POST` Delete a database. diff --git a/docs/http/energy.md b/docs/http/energy.md index 6f008314..5eff240a 100644 --- a/docs/http/energy.md +++ b/docs/http/energy.md @@ -39,8 +39,6 @@ Returns JSON in the form: Set the energy balance for an identity. -Note that in the SpacetimeDB 0.6 Testnet, this endpoint always returns code 401, `UNAUTHORIZED`. Testnet energy balances cannot be refilled. - Accessible through the CLI as `spacetime energy set-balance `. #### Parameters diff --git a/docs/modules/index.md b/docs/modules/index.md index d7d13685..93b74cb3 100644 --- a/docs/modules/index.md +++ b/docs/modules/index.md @@ -8,7 +8,7 @@ In the following sections, we'll cover the basics of server modules and how to c ### Rust -As of SpacetimeDB 0.6, Rust is the only fully supported language for server modules. Rust is a great option for server modules because it is fast, safe, and has a small runtime. +Rust is the only fully supported language for server modules. Rust is a great option for server modules because it is fast, safe, and has a small runtime. - [Rust Module Reference](/docs/modules/rust) - [Rust Module Quickstart Guide](/docs/modules/rust/quickstart) From 3477b1c0e7f8f0afcce8a2ef62f20f165d5941e2 Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Tue, 25 Feb 2025 10:17:09 -0800 Subject: [PATCH 104/164] Remove reference to set energy-balance in http api Closes #119. --- docs/http/energy.md | 41 +---------------------------------------- 1 file changed, 1 insertion(+), 40 deletions(-) diff --git a/docs/http/energy.md b/docs/http/energy.md index 5eff240a..fa035c83 100644 --- a/docs/http/energy.md +++ b/docs/http/energy.md @@ -7,13 +7,12 @@ The HTTP endpoints in `/energy` allow clients to query identities' energy balanc | Route | Description | | ------------------------------------------------ | --------------------------------------------------------- | | [`/energy/:identity GET`](#energyidentity-get) | Get the remaining energy balance for the user `identity`. | -| [`/energy/:identity POST`](#energyidentity-post) | Set the energy balance for the user `identity`. | ## `/energy/:identity GET` Get the energy balance of an identity. -Accessible through the CLI as `spacetime energy status `. +Accessible through the CLI as [`spacetime energy balance`](/docs/cli-reference#spacetime-energy-balance). #### Parameters @@ -34,41 +33,3 @@ Returns JSON in the form: | Field | Value | | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `balance` | The identity's energy balance, as a decimal integer. Note that energy balances may be negative, and will frequently be too large to store in a 64-bit integer. | - -## `/energy/:identity POST` - -Set the energy balance for an identity. - -Accessible through the CLI as `spacetime energy set-balance `. - -#### Parameters - -| Name | Value | -| ----------- | ----------------------- | -| `:identity` | The Spacetime identity. | - -#### Query Parameters - -| Name | Value | -| --------- | ------------------------------------------ | -| `balance` | A decimal integer; the new balance to set. | - -#### Required Headers - -| Name | Value | -| --------------- | --------------------------------------------------------------- | -| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http). | - -#### Returns - -Returns JSON in the form: - -```typescript -{ - "balance": number -} -``` - -| Field | Value | -| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `balance` | The identity's new energy balance, as a decimal integer. Note that energy balances may be negative, and will frequently be too large to store in a 64-bit integer. | From dfef2a7c8cd27b7b9852cc0ea6938e6694ad6adb Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Tue, 25 Feb 2025 12:03:32 -0800 Subject: [PATCH 105/164] Remove references to testnet Closes #183. --- STYLE.md | 2 +- docs/deploying/testnet.md | 34 ---------------------------------- docs/nav.js | 2 -- nav.ts | 3 --- 4 files changed, 1 insertion(+), 40 deletions(-) delete mode 100644 docs/deploying/testnet.md diff --git a/STYLE.md b/STYLE.md index 1e958e29..f0ff5e8c 100644 --- a/STYLE.md +++ b/STYLE.md @@ -399,7 +399,7 @@ If this tutorial is the end of a series, or ends with a reasonably complete app, If the tutorial involved writing code, add a link to the complete code. This should be somewhere on GitHub, either as its own repo, or as an example project within an existing repo. Ensure the linked folder has a README.md file which includes: - The name of the tutorial project. -- How to run or interact with the tutorial project, whatever that means (e.g. publish to testnet and then `spacetime call`). +- How to run or interact with the tutorial project, whatever that means (e.g. publish to maincloud and then `spacetime call`). - Links to external dependencies (e.g. for client projects, the module which it runs against). - A back-link to the tutorial that builds this project. diff --git a/docs/deploying/testnet.md b/docs/deploying/testnet.md deleted file mode 100644 index ce648043..00000000 --- a/docs/deploying/testnet.md +++ /dev/null @@ -1,34 +0,0 @@ -# SpacetimeDB Cloud Deployment - -The SpacetimeDB Cloud is a managed cloud service that provides developers an easy way to deploy their SpacetimeDB apps to the cloud. - -Currently only the `testnet` is available for SpacetimeDB cloud which is subject to wipes. The `mainnet` will be available soon. - -## Deploy via CLI - -1. [Install](/install) the SpacetimeDB CLI. -1. Configure your CLI to use the SpacetimeDB Cloud. To do this, run the `spacetime server` command: - -```bash -spacetime server add --default "https://testnet.spacetimedb.com" testnet -``` - -## Connecting your Identity to the Web Dashboard - -By associating an email with your CLI identity, you can view your published modules on the web dashboard. - -1. Get your identity using the `spacetime identity list` command. Copy it to your clipboard. -1. Connect your email address to your identity using the `spacetime identity set-email` command: - -```bash -spacetime identity set-email -``` - -1. Open the SpacetimeDB website and log in using your email address. -1. Choose your identity from the dropdown menu. -1. Validate your email address by clicking the link in the email you receive. -1. You should now be able to see your published modules on the web dashboard. - ---- - -With SpacetimeDB Cloud, you benefit from automatic scaling, robust security, and the convenience of not having to manage the hosting environment. diff --git a/docs/nav.js b/docs/nav.js index eba9f496..edd5a169 100644 --- a/docs/nav.js +++ b/docs/nav.js @@ -9,8 +9,6 @@ const nav = { section('Intro'), page('Overview', 'index', 'index.md'), // TODO(BREAKING): For consistency & clarity, 'index' slug should be renamed 'intro'? page('Getting Started', 'getting-started', 'getting-started.md'), - section('Deploying'), - page('Testnet', 'deploying/testnet', 'deploying/testnet.md'), section('Migration Guides'), page('v0.12', 'migration/v0.12', 'migration/v0.12.md'), section('Unity Tutorial - Basic Multiplayer'), diff --git a/nav.ts b/nav.ts index 53d8c816..a0556dd2 100644 --- a/nav.ts +++ b/nav.ts @@ -33,9 +33,6 @@ const nav: Nav = { page('Overview', 'index', 'index.md'), // TODO(BREAKING): For consistency & clarity, 'index' slug should be renamed 'intro'? page('Getting Started', 'getting-started', 'getting-started.md'), - section('Deploying'), - page('Testnet', 'deploying/testnet', 'deploying/testnet.md'), - section('Migration Guides'), page('v0.12', 'migration/v0.12', 'migration/v0.12.md'), From c9ab802ccb5f0a41a2f922d0ee146aaee43973e8 Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Wed, 26 Feb 2025 09:18:09 -0800 Subject: [PATCH 106/164] Remove 0.12 migration guide --- docs/migration/v0.12.md | 341 ---------------------------------------- docs/nav.js | 2 - nav.ts | 3 - 3 files changed, 346 deletions(-) delete mode 100644 docs/migration/v0.12.md diff --git a/docs/migration/v0.12.md b/docs/migration/v0.12.md deleted file mode 100644 index 9384407f..00000000 --- a/docs/migration/v0.12.md +++ /dev/null @@ -1,341 +0,0 @@ -# Updating your app for SpacetimeDB v0.12 - -We're excited to release SpacetimeDB v0.12, which includes a major overhaul of our Rust, C# and TypeScript APIs for both modules and clients. In no particular order, our goals with this rewrite were: - -- Our APIs should be as similar as possible in all three languages we support, and in clients and modules, so that you don't have to go to a ton of work figuring out why something works in one place but not somewhere else. -- We should be very explicit about what operations interact with the database and how. In addition to good hygiene, this means that a client can now connect to multiple remote modules at the same time without getting confused. (Some day a module will be able to connect to remote modules too, but we're not there yet.) -- Our APIs should expose low level database operations so you can program your applications to have predictable performance characteristics. An indexed lookup should look different in your code from a full scan, and writing the indexed lookup should be easier. This will help you write your apps as efficiently as possible as we add features to SpacetimeDB. (In the future, as we get more sophisticated at optimizing and evaluating queries, we will offer a higher level logical query API which let's us implement very high performance optimizations and abstract away concerns like indices.) - -The new APIs are a significant improvement to the developer experience of SpacetimeDB and enable some amazing features in the future. They're completely new APIs, so if you run into any trouble, please [ask us for help or share your feedback on Discord!](https://discord.gg/spacetimedb) - -To start migrating, update your SpacetimeDB CLI, and bump the `spacetimedb` and `spacetimedb-sdk` dependency versions to 0.12 in your module and client respectively. - -## Modules - -### The reducer context - -All your reducers must now accept a reducer context as their first argument. In Rust, this is now taken by reference, as `&ReducerContext`. All access to tables now go through methods on the `db` or `Db` field of the `ReducerContext`. - -```rust -#[spacetimedb::reducer] -fn my_reducer(ctx: &ReducerContext) { - for row in ctx.db.my_table().iter() { - // Do something with the row... - } -} -``` - -```csharp -[SpacetimeDB.Reducer] -public static void MyReducer(ReducerContext ctx) { - foreach (var row in ctx.Db.MyTable.Iter()) { - // Do something with the row... - } -} -``` - -### Table names and access methods - -You now must specify a name for every table, distinct from the type name. In Rust, write this as `#[spacetimedb::table(name = my_table)]`. The name you specify here will be the method on `ctx.db` you use to access the table. - -```rust -#[spacetimedb::table(name = my_table)] -struct MyTable { - #[primary_key] - #[auto_inc] - id: u64, - other_column: u32, -} -``` - -```csharp -[SpacetimeDB.Table(Name = "MyTable")] -public partial struct MyTable -{ - [SpacetimeDB.PrimaryKey] - [SpacetimeDB.AutoInc] - public long Id; - public int OtherColumn; -} -``` - -One neat upside of this is that you can now have multiple tables with the same row type! - -```rust -#[spacetimedb::table(name = signed_in_user)] -#[spacetimedb::table(name = signed_out_user)] -struct User { - #[primary_key] - id: Identity, - #[unique] - username: String, -} -``` - -```csharp -[SpacetimeDB.Table(Name = "SignedInUser")] -[SpacetimeDB.Table(Name = "SignedOutUser")] -public partial struct User -{ - [SpacetimeDB.PrimaryKey] - public SpacetimeDB.Identity Id; - [SpacetimeDB.Unique] - public String Username; -} -``` - -### Iterating, counting, inserting, deleting - -Each "table handle" `ctx.db.my_table()` has methods: - -| Rust name | C# name | Behavior | -|-----------|----------|-----------------------------------------| -| `iter` | `Iter` | Iterate over all rows in the table. | -| `count` | `Count` | Return the number of rows in the table. | -| `insert` | `Insert` | Add a new row to the table. | -| `delete` | `Delete` | Delete a given row from the table. | - -### Index access - -Each table handle also has a method for each BTree index and/or unique constraint on the table, which allows you to filter, delete or update by that index. BTree indices' filter and delete methods accept both point and range queries. - -```rust -#[spacetimedb::table( - name = entity, - index(name = location, btree = [x, y]), -)] -struct Entity { - #[primary_key] - #[auto_inc] - id: u64, - x: u32, - y: u32, - #[index(btree)] - faction: String, -} - -#[spacetimedb::reducer] -fn move_entity(ctx: &ReducerContext, entity_id: u64, x: u32, y: u32) { - let entity = ctx.db.entity().id().find(entity_id).expect("No such entity"); - ctx.db.entity.id().update(Entity { x, y, ..entity }); -} - -#[spacetimedb::reducer] -fn log_entities_at_point(ctx: &ReducerContext, x: u32, y: u32) { - for entity in ctx.db.entity().location().filter((x, y)) { - log::info!("Entity {} is at ({}, {})", entity.id, x, y); - } -} - -#[spacetimedb::reducer] -fn delete_faction(ctx: &ReducerContext, faction: String) { - ctx.db.entity().faction().delete(&faction); -} -``` - -```csharp -[SpacetimeDB.Table(Name = "Entity")] -[SpacetimeDB.Table(Name = "SignedOutUser")] -[SpacetimeDB.Index(Name = "Location", BTree = ["X", "Y"])] -[SpacetimeDB.Index(Name = "Faction", BTree = ["Faction"])] -public partial struct Entity -{ - [SpacetimeDB.PrimaryKey] - [SpacetimeDB.AutoInc] - public long Id; - public int X; - public int Y; - public string Faction; -} - -[SpacetimeDB.Reducer] -public static void MoveEntity(SpacetimeDB.ReducerContext ctx, long entityId, int x, int y) { - var entity = ctx.Db.Entity.Id.Find(entityId); - ctx.Db.Entity.Id.Update(new Entity { - Id = entityId, - X = x, - Y = y, - Faction = entity.Faction, - }); -} - -[SpacetimeDB.Reducer] -public static void LogEntitiesAtPoint(SpacetimeDB.ReducerContext ctx, int x, int y) { - foreach(var entity in ctx.Db.Entity.Location.Filter((x, y))) { - SpacetimeDB.Log.Info($"Entity {entity.Id} is at ({x}, {y})"); - } -} - -[SpacetimeDB.Reducer] -public static void DeleteFaction(SpacetimeDB.ReducerContext ctx, string Faction) { - ctx.Db.Entity.Faction.Delete(Faction); -} -``` - -### `query` - -Note that the `query!` macro in Rust and the `.Query()` method in C# have been removed. We plan to replace them with something even better in the future, but for now, you should write your query explicitly, either by accessing an index or multi-column index by chaining `ctx.db.my_table().iter().filter(|row| predicate)`. - -### Built-in reducers - -The Rust syntax for declaring builtin lifecycles have changed. They are now: - -- `#[spacetimedb::reducer(client_connected)]` -- `#[spacetimedb::reducer(client_disconnected)]` -- `#[spacetimedb::reducer(init)]` - -In C# they are now: - -- `[SpacetimeDB.Reducer(SpacetimeDB.ReducerKind.ClientConnected)]` -- `[SpacetimeDB.Reducer(SpacetimeDB.ReducerKind.ClientDisconnected)]` -- `[SpacetimeDB.Reducer(SpacetimeDB.ReducerKind.Init)]` - -## Clients - -Make sure to run `spacetime generate` after updating your module! - -### The connection object - -Your connection to a remote module is now represented by a `DbConnection` object, which holds all state associated with the connection. We encourage you to name the variable that holds your connection `ctx`. - -Construct a `DbConnection` via the [builder pattern](https://en.wikipedia.org/wiki/Builder_pattern) with `DbConnection::builder()` or your language's equivalent. Register on-connect and on-disconnect callbacks while constructing the connection via the builder. - -> NOTE: The APIs for the the `DbConnection` and `ReducerContext` are quite similar, allowing you to write the same patterns on both the client and server. - -### Polling the `DbConnection` - -In Rust, you now must explicitly poll your `DbConnection` to advance, where previously it ran automatically in the background. This provides a much greater degree of flexibility to choose your own async runtime and to work under the variety of exciting constraints imposed by game development - for example, you can now arrange it so that all your callbacks run on the main thread if you want to make GUI calls. You can recreate the previous behavior by calling `ctx.run_threaded()` immediately after buidling your connection. You can also call `ctx.run_async()`, or manually call `ctx.frame_tick()` at an appropriate interval. - -In C# the existing API already required you explictly poll your `DbConnection`, so not much has changed there. The `Update()` method is now called `FrameTick()`. - -### Subscribing to queries - -We're planning a major overhaul of the API for subscribing to queries, but we're not quite there yet. This means that our subscription APIs are not yet as consistent as will soon be. - -#### Rust - -Subscribe to a set of queries by creating a subscription builder and calling `subscribe`. - -```rust -ctx.subscription_builder() - .on_applied(|ctx| { ... }) - .subscribe([ - "SELECT * FROM my_table", - "SELECT * FROM other_table WHERE some_column = 123" - ]); -``` - -The `on_applied` callback is optional. A temporarily limitation of this API is that you should add all your subscription queries at one time for any given connection. - -#### C# - -```csharp -ctx.SubscriptionBuilder() - .OnApplied(ctx => { ... }) - .Subscribe( - "SELECT * FROM MyTable", - "SELECT * FROM OtherTable WHERE SomeColumn = 123" - ); -``` - -#### TypeScript - -```ts -ctx.subscriptionBuilder() - .onApplied(ctx => { ... }) - .subscribe([ - "SELECT * FROM my_table", - "SELECT * FROM other_table WHERE some_column = 123" - ]); -``` - -### Accessing tables - -As in modules, all accesses to your connection's client cache now go through the `ctx.db`. Support for client-side indices is not yet consistent across all our SDKs, so for now you may find that you can't make some queries in clients which you could make in modules. The table handles also expose row callbacks. - -### Observing and invoking reducers - -Register reducer callbacks and request reducer invocations by going through `ctx.reducers`. You can also add functions to subscribe to reducer events that the server sends when a particular reducer is executed. - -#### Rust - -```rust -ctx.reducers.my_reducer(my_first_arg, my_second_arg, ...); - -// Add a callback for each reducer event for `my_reducer` -let callback_id = ctx.reducers.on_my_reducer(|ctx, first_arg, second_arg, ...| { - ... -}); - -// Unregister the callback -ctx.reducers.remove_my_reducer(callback_id); -``` - -#### C# - -```cs -ctx.Reducers.MyReducer(myFirstArg, mySecondArg, ...); - -// Add a callback for each reducer event for `MyReducer` -void OnMyReducerCallback(EventContext ctx) { - ... -} -ctx.Reducers.OnMyReducer += OnMyReducerCallback; - -// Unregister the callback -ctx.Reducers.OnMyReducer -= OnMyReducerCallback; -``` - -#### TypeScript - -```ts -ctx.reducers.myReducer(myFirstArg, mySecondArg, ...); - -// Add a callback for each reducer event for `my_reducer` -const callback = (ctx, firstArg, secondArg, ...) => { - ... -}; -ctx.reducers.onMyReducer(callback); - -// Unregister the callback -ctx.reducers.removeMyReducer(callback); -``` - -### The event context - -Most callbacks now take a first argument of type `&EventContext`. This is just like your `DbConnection`, but it has an additional field `event: Event`. `Event` is an enum, tagged union, or sum type which encodes all the different events the SDK can observe. This fills the same role as `ReducerEvent` used to, but `Event` is more specific and more accurate to what actually happened. - -```rust -ctx.reducers.on_my_reducer(|ctx, first_arg, second_arg, ...| { - match ctx.event { - Reducer(reducer_event) => { - ... - }, - _ => unreachable!(); - } -}); -``` - -#### C# - -```csharp -ctx.Reducers.OnMyReducer += (ctx, firstArg, secondArg, ...) => { - switch (ctx.Event) { - case Event.Reducer (var value): - var reducerEvent = value.Reducer; - ... - break; - } -}; -``` - -#### TypeScript - -```ts -ctx.reducers.onMyReducer((ctx, firstArg, secondArg, ...) => { - if (ctx.event.tag === 'Reducer') { - const reducerEvent = ctx.event.value; - ... - } -}); -``` diff --git a/docs/nav.js b/docs/nav.js index edd5a169..930361c4 100644 --- a/docs/nav.js +++ b/docs/nav.js @@ -9,8 +9,6 @@ const nav = { section('Intro'), page('Overview', 'index', 'index.md'), // TODO(BREAKING): For consistency & clarity, 'index' slug should be renamed 'intro'? page('Getting Started', 'getting-started', 'getting-started.md'), - section('Migration Guides'), - page('v0.12', 'migration/v0.12', 'migration/v0.12.md'), section('Unity Tutorial - Basic Multiplayer'), page('Overview', 'unity', 'unity/index.md'), page('1 - Setup', 'unity/part-1', 'unity/part-1.md'), diff --git a/nav.ts b/nav.ts index a0556dd2..40c9c31e 100644 --- a/nav.ts +++ b/nav.ts @@ -33,9 +33,6 @@ const nav: Nav = { page('Overview', 'index', 'index.md'), // TODO(BREAKING): For consistency & clarity, 'index' slug should be renamed 'intro'? page('Getting Started', 'getting-started', 'getting-started.md'), - section('Migration Guides'), - page('v0.12', 'migration/v0.12', 'migration/v0.12.md'), - section('Unity Tutorial - Basic Multiplayer'), page('Overview', 'unity', 'unity/index.md'), page('1 - Setup', 'unity/part-1', 'unity/part-1.md'), From 7319933ee22a3d90c152a3e0dc5daed53996b713 Mon Sep 17 00:00:00 2001 From: rekhoff Date: Wed, 26 Feb 2025 11:32:17 -0800 Subject: [PATCH 107/164] Update to C# Quickstart-Chat Server Module and Client SDK tutorial documents (#170) * Initial code pass on updating server to 1.0.0 * Updated to work with current 1.0.0-rc4, master branches of SpacetimeDB and the CSharpSDK * Minor edit for clarity * No longer optional, ReducerContext is always the first argument Co-authored-by: Phoebe Goldman * Improved description of OnInsert and OnDelete callbacks Co-authored-by: Phoebe Goldman * Fixed capitalization. Co-authored-by: Phoebe Goldman * Fixed capitalization. Co-authored-by: Phoebe Goldman * SDK language corrected and clarified. Co-authored-by: Phoebe Goldman * Added that the example is for the C# client and does not include server examples. Co-authored-by: Phoebe Goldman * Added comma for clarity Co-authored-by: Phoebe Goldman * Added comma for clarity Co-authored-by: Phoebe Goldman * Applied requested changes to improve clarity * Revised the SDK Client Quickstart to be more-in-line with the Rust Client Quickstart flow * Added comments to code * Replaced with quickstart-chat --------- Co-authored-by: Phoebe Goldman --- docs/modules/c-sharp/quickstart.md | 99 ++++---- docs/sdks/c-sharp/quickstart.md | 395 +++++++++++++++++++---------- 2 files changed, 308 insertions(+), 186 deletions(-) diff --git a/docs/modules/c-sharp/quickstart.md b/docs/modules/c-sharp/quickstart.md index 571351c1..5dcb703a 100644 --- a/docs/modules/c-sharp/quickstart.md +++ b/docs/modules/c-sharp/quickstart.md @@ -57,21 +57,18 @@ spacetime init --lang csharp server 2. Open `server/Lib.cs`, a trivial module. 3. Clear it out, so we can write a new module that's still pretty simple: a bare-bones chat server. +To start, we'll need to add `SpacetimeDB` to our using statements. This will give us access to everything we need to author our SpacetimeDB server module. + To the top of `server/Lib.cs`, add some imports we'll be using: ```csharp -using System.Runtime.CompilerServices; -using SpacetimeDB.Module; -using static SpacetimeDB.Runtime; +using SpacetimeDB; ``` -- `SpacetimeDB.Module` contains the special attributes we'll use to define tables and reducers in our module. -- `SpacetimeDB.Runtime` contains the raw API bindings SpacetimeDB uses to communicate with the database. - We also need to create our static module class which all of the module code will live in. In `server/Lib.cs`, add: ```csharp -static partial class Module +public static partial class Module { } ``` @@ -85,10 +82,10 @@ For each `User`, we'll store their `Identity`, an optional name they can set to In `server/Lib.cs`, add the definition of the table `User` to the `Module` class: ```csharp -[SpacetimeDB.Table(Public = true)] +[Table(Name = "User", Public = true)] public partial class User { - [SpacetimeDB.Column(ColumnAttrs.PrimaryKey)] + [PrimaryKey] public Identity Identity; public string? Name; public bool Online; @@ -100,7 +97,7 @@ For each `Message`, we'll store the `Identity` of the user who sent it, the `Tim In `server/Lib.cs`, add the definition of the table `Message` to the `Module` class: ```csharp -[SpacetimeDB.Table(Public = true)] +[Table(Name = "Message", Public = true)] public partial class Message { public Identity Sender; @@ -113,23 +110,23 @@ public partial class Message We want to allow users to set their names, because `Identity` is not a terribly user-friendly identifier. To that effect, we define a reducer `SetName` which clients can invoke to set their `User.Name`. It will validate the caller's chosen name, using a function `ValidateName` which we'll define next, then look up the `User` record for the caller and update it to store the validated name. If the name fails the validation, the reducer will fail. -Each reducer may accept as its first argument a `ReducerContext`, which includes the `Identity` and `Address` of the client that called the reducer, and the `Timestamp` when it was invoked. For now, we only need the `Identity`, `ctx.Sender`. +Each reducer must accept as its first argument a `ReducerContext`, which includes contextual data such as the `Sender` which contains the Identity of the client that called the reducer, and the `Timestamp` when it was invoked. For now, we only need the `Sender`. It's also possible to call `SetName` via the SpacetimeDB CLI's `spacetime call` command without a connection, in which case no `User` record will exist for the caller. We'll return an error in this case, but you could alter the reducer to insert a `User` row for the module owner. You'll have to decide whether the module owner is always online or always offline, though. In `server/Lib.cs`, add to the `Module` class: ```csharp -[SpacetimeDB.Reducer] +[Reducer] public static void SetName(ReducerContext ctx, string name) { name = ValidateName(name); - var user = User.FindByIdentity(ctx.Sender); + var user = ctx.Db.User.Identity.Find(ctx.Sender); if (user is not null) { user.Name = name; - User.UpdateByIdentity(ctx.Sender, user); + ctx.Db.User.Identity.Update(user); } } ``` @@ -146,7 +143,7 @@ In `server/Lib.cs`, add to the `Module` class: ```csharp /// Takes a name and checks if it's acceptable as a user's name. -public static string ValidateName(string name) +private static string ValidateName(string name) { if (string.IsNullOrEmpty(name)) { @@ -163,17 +160,19 @@ We define a reducer `SendMessage`, which clients will call to send messages. It In `server/Lib.cs`, add to the `Module` class: ```csharp -[SpacetimeDB.Reducer] +[Reducer] public static void SendMessage(ReducerContext ctx, string text) { text = ValidateMessage(text); - Log(text); - new Message - { - Sender = ctx.Sender, - Text = text, - Sent = ctx.Time.ToUnixTimeMilliseconds(), - }.Insert(); + Log.Info(text); + ctx.Db.Message.Insert( + new Message + { + Sender = ctx.Sender, + Text = text, + Sent = ctx.Timestamp.MicrosecondsSinceUnixEpoch, + } + ); } ``` @@ -183,7 +182,7 @@ In `server/Lib.cs`, add to the `Module` class: ```csharp /// Takes a message's text and checks if it's acceptable to send. -public static string ValidateMessage(string text) +private static string ValidateMessage(string text) { if (string.IsNullOrEmpty(text)) { @@ -202,58 +201,60 @@ You could extend the validation in `ValidateMessage` in similar ways to `Validat In C# modules, you can register for `Connect` and `Disconnect` events by using a special `ReducerKind`. We'll use the `Connect` event to create a `User` record for the client if it doesn't yet exist, and to set its online status. -We'll use `User.FindByIdentity` to look up a `User` row for `ctx.Sender`, if one exists. If we find one, we'll use `User.UpdateByIdentity` to overwrite it with a row that has `Online: true`. If not, we'll use `User.Insert` to insert a new row for our new user. All three of these methods are generated by the `[SpacetimeDB.Table]` attribute, with rows and behavior based on the row attributes. `FindByIdentity` returns a nullable `User`, because the unique constraint from the `[SpacetimeDB.Column(ColumnAttrs.PrimaryKey)]` attribute means there will be either zero or one matching rows. `Insert` will throw an exception if the insert violates this constraint; if we want to overwrite a `User` row, we need to do so explicitly using `UpdateByIdentity`. +We'll use `reducerContext.Db.User.Identity.Find` to look up a `User` row for `ctx.Sender`, if one exists. If we find one, we'll use `reducerContext.Db.User.Identity.Update` to overwrite it with a row that has `Online: true`. If not, we'll use `User.Insert` to insert a new row for our new user. All three of these methods are generated by the `[SpacetimeDB.Table]` attribute, with rows and behavior based on the row attributes. `User.Identity.Find` returns a nullable `User`, because the unique constraint from the `[PrimaryKey]` attribute means there will be either zero or one matching rows. `Insert` will throw an exception if the insert violates this constraint; if we want to overwrite a `User` row, we need to do so explicitly using `User.Identity.Update`. In `server/Lib.cs`, add the definition of the connect reducer to the `Module` class: ```csharp -[SpacetimeDB.Reducer(ReducerKind.Connect)] -public static void OnConnect(ReducerContext ReducerContext) +[Reducer(ReducerKind.ClientConnected)] +public static void ClientConnected(ReducerContext ctx) { - Log($"Connect {ReducerContext.Sender}"); - var user = User.FindByIdentity(ReducerContext.Sender); + Log.Info($"Connect {ctx.Sender}"); + var user = ctx.Db.User.Identity.Find(ctx.Sender); if (user is not null) { // 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.Online = true; - User.UpdateByIdentity(ReducerContext.Sender, user); + ctx.Db.User.Identity.Update(user); } else { // If this is a new user, create a `User` object for the `Identity`, // which is online, but hasn't set a name. - new User - { - Name = null, - Identity = ReducerContext.Sender, - Online = true, - }.Insert(); + ctx.Db.User.Insert( + new User + { + Name = null, + Identity = ctx.Sender, + Online = true, + } + ); } } ``` -Similarly, whenever a client disconnects, the module will execute the `OnDisconnect` event if it's registered with `ReducerKind.Disconnect`. We'll use it to un-set the `Online` status of the `User` for the disconnected client. +Similarly, whenever a client disconnects, the module will execute the `OnDisconnect` event if it's registered with `ReducerKind.ClientDisconnected`. We'll use it to un-set the `Online` status of the `User` for the disconnected client. Add the following code after the `OnConnect` handler: ```csharp -[SpacetimeDB.Reducer(ReducerKind.Disconnect)] -public static void OnDisconnect(ReducerContext ReducerContext) +[Reducer(ReducerKind.ClientDisconnected)] +public static void ClientDisconnected(ReducerContext ctx) { - var user = User.FindByIdentity(ReducerContext.Sender); + var user = ctx.Db.User.Identity.Find(ctx.Sender); if (user is not null) { // This user should exist, so set `Online: false`. user.Online = false; - User.UpdateByIdentity(ReducerContext.Sender, user); + ctx.Db.User.Identity.Update(user); } else { // User does not exist, log warning - Log("Warning: No user found for disconnected client."); + Log.Warn("Warning: No user found for disconnected client."); } } ``` @@ -264,30 +265,28 @@ If you haven't already started the SpacetimeDB server, run the `spacetime start` ## Publish the module -And that's all of our module code! We'll run `spacetime publish` to compile our module and publish it on SpacetimeDB. `spacetime publish` takes an optional name which will map to the database's unique address. Clients can connect either by name or by address, but names are much more pleasant. Come up with a unique name, and fill it in where we've written ``. +And that's all of our module code! We'll run `spacetime publish` to compile our module and publish it on SpacetimeDB. `spacetime publish` takes an optional name which will map to the database's unique address. Clients can connect either by name or by address, but names are much more pleasant. In this example, we'll be using `quickstart-chat`. Feel free to come up with a unique name, and in the CLI commands, replace where we've written `quickstart-chat` with the name you chose. From the `quickstart-chat` directory, run: ```bash -spacetime publish --project-path server +spacetime publish --project-path server quickstart-chat ``` -```bash -npm i wasm-opt -g -``` +Note: If the WebAssembly optimizer `wasm-opt` is installed, `spacetime publish` will automatically optimize the Web Assembly output of the published module. Instruction for installing the `wasm-opt` binary can be found in [Rust's wasm-opt documentation](https://docs.rs/wasm-opt/latest/wasm_opt/). ## Call Reducers You can use the CLI (command line interface) to run reducers. The arguments to the reducer are passed in JSON format. ```bash -spacetime call SendMessage "Hello, World!" +spacetime call quickstart-chat SendMessage "Hello, World!" ``` Once we've called our `SendMessage` reducer, we can check to make sure it ran by running the `logs` command. ```bash -spacetime logs +spacetime logs quickstart-chat ``` You should now see the output that your module printed in the database. @@ -301,7 +300,7 @@ info: Hello, World! SpacetimeDB supports a subset of the SQL syntax so that you can easily query the data of your database. We can run a query using the `sql` command. ```bash -spacetime sql "SELECT * FROM Message" +spacetime sql quickstart-chat "SELECT * FROM Message" ``` ```bash diff --git a/docs/sdks/c-sharp/quickstart.md b/docs/sdks/c-sharp/quickstart.md index db06d9a4..759accbe 100644 --- a/docs/sdks/c-sharp/quickstart.md +++ b/docs/sdks/c-sharp/quickstart.md @@ -28,6 +28,10 @@ Add the `SpacetimeDB.ClientSDK` [NuGet package](https://www.nuget.org/packages/s dotnet add package SpacetimeDB.ClientSDK ``` +## Clear `client/Program.cs` + +Clear out any data from `client/Program.cs` so we can write our chat client. + ## Generate your module types The `spacetime` CLI's `generate` command will generate client-side interfaces for the tables, reducers and types defined in your server module. @@ -39,15 +43,22 @@ mkdir -p client/module_bindings spacetime generate --lang csharp --out-dir client/module_bindings --project-path server ``` -Take a look inside `client/module_bindings`. The CLI should have generated five files: +Take a look inside `client/module_bindings`. The CLI should have generated three folders and nine files: ``` module_bindings -├── Message.cs -├── ReducerEvent.cs -├── SendMessageReducer.cs -├── SetNameReducer.cs -└── User.cs +├── Reducers +│ ├── ClientConnected.g.cs +│ ├── ClientDisconnected.g.cs +│ ├── SendMessage.g.cs +│ └── SetName.g.cs +├── Tables +│ ├── Message.g.cs +│ └── User.g.cs +├── Types +│ ├── Message.g.cs +│ └── User.g.cs +└── SpacetimeDBClient.g.cs ``` ## Add imports to Program.cs @@ -60,17 +71,16 @@ using SpacetimeDB.Types; using System.Collections.Concurrent; ``` -We will also need to create some global variables that will be explained when we use them later. Add the following to the top of `Program.cs`: +We will also need to create some global variables that will be explained when we use them later. + +To `Program.cs`, add: ```csharp // our local client SpacetimeDB identity Identity? local_identity = null; -// declare a thread safe queue to store commands in format (command, args) -ConcurrentQueue<(string,string)> input_queue = new ConcurrentQueue<(string, string)>(); - -// declare a threadsafe cancel token to cancel the process loop -CancellationTokenSource cancel_token = new CancellationTokenSource(); +// declare a thread safe queue to store commands +var input_queue = new ConcurrentQueue<(string Command, string Args)>(); ``` ## Define Main function @@ -78,58 +88,150 @@ CancellationTokenSource cancel_token = new CancellationTokenSource(); We'll work outside-in, first defining our `Main` function at a high level, then implementing each behavior it needs. We need `Main` to do several things: 1. Initialize the `AuthToken` module, which loads and stores our authentication token to/from local storage. -2. Create the `SpacetimeDBClient` instance. -3. Register callbacks on any events we want to handle. These will print to standard output messages received from the database and updates about users' names and online statuses. +2. Connect to the database. +3. Register a number of callbacks to run in response to various database events. 4. Start our processing thread which connects to the SpacetimeDB module, updates the SpacetimeDB client and processes commands that come in from the input loop running in the main thread. 5. Start the input loop, which reads commands from standard input and sends them to the processing thread. 6. When the input loop exits, stop the processing thread and wait for it to exit. +To `Program.cs`, add: + ```csharp void Main() { + // Initialize the `AuthToken` module AuthToken.Init(".spacetime_csharp_quickstart"); + // Builds and connects to the database + DbConnection? conn = null; + conn = ConnectToDB(); + // Registers to run in response to database events. + RegisterCallbacks(conn); + // Declare a threadsafe cancel token to cancel the process loop + var cancellationTokenSource = new CancellationTokenSource(); + // Spawn a thread to call process updates and process commands + var thread = new Thread(() => ProcessThread(conn, cancellationTokenSource.Token)); + thread.Start(); + // Handles CLI input + InputLoop(); + // This signals the ProcessThread to stop + cancellationTokenSource.Cancel(); + thread.Join(); +} +``` - RegisterCallbacks(); +## Connect to database - // spawn a thread to call process updates and process commands - var thread = new Thread(ProcessThread); - thread.Start(); +Before we connect, we'll store the SpacetimeDB host name and our module name in constants `HOST` and `DB_NAME`. - InputLoop(); +A connection to a SpacetimeDB database is represented by a `DbConnection`. We configure `DbConnection`s using the builder pattern, by calling `DbConnection.Builder()`, chaining method calls to set various connection parameters and register callbacks, then we cap it off with a call to `.Build()` to begin the connection. - // this signals the ProcessThread to stop - cancel_token.Cancel(); - thread.Join(); +In our case, we'll supply the following options: + +1. A `WithUri` call, to specify the URI of the SpacetimeDB host where our module is running. +2. A `WithModuleName` call, to specify the name or `Identity` of our database. Make sure to pass the same name here as you supplied to `spacetime publish`. +3. A `WithToken` call, to supply a token to authenticate with. +4. An `OnConnect` callback, to run when the remote database acknowledges and accepts our connection. +5. An `OnConnectError` callback, to run if the remote database is unreachable or it rejects our connection. +6. An `OnDisconnect` callback, to run when our connection ends. + +To `Program.cs`, add: + +```csharp +/// The URI of the SpacetimeDB instance hosting our chat module. +const string HOST = "http://localhost:3000"; + +/// The module name we chose when we published our module. +const string DBNAME = "quickstart-chat"; + +/// Load credentials from a file and connect to the database. +DbConnection ConnectToDB() +{ + DbConnection? conn = null; + conn = DbConnection.Builder() + .WithUri(HOST) + .WithModuleName(DBNAME) + .WithToken(AuthToken.Token) + .OnConnect(OnConnected) + .OnConnectError(OnConnectError) + .OnDisconnect(OnDisconnect) + .Build(); + return conn; } ``` -## Register callbacks +### Save credentials + +SpacetimeDB will accept any [OpenID Connect](https://openid.net/developers/how-connect-works/) compliant [JSON Web Token](https://jwt.io/) and use it to compute an `Identity` for the user. More complex applications will generally authenticate their user somehow, generate or retrieve a token, and attach it to their connection via `WithToken`. In our case, though, we'll connect anonymously the first time, let SpacetimeDB generate a fresh `Identity` and corresponding JWT for us, and save that token locally to re-use the next time we connect. + +Once we are connected, we'll use the `AuthToken` module to save our token to local storage, so that we can re-authenticate as the same user the next time we connect. We'll also store the identity in a global variable `local_identity` so that we can use it to check if we are the sender of a message or name change. This callback also notifies us of our client's `Address`, an opaque identifier SpacetimeDB modules can use to distinguish connections by the same `Identity`, but we won't use it in our app. + +To `Program.cs`, add: + +```csharp +/// Our `OnConnected` callback: save our credentials to a file. +void OnConnected(DbConnection conn, Identity identity, string authToken) +{ + local_identity = identity; + AuthToken.SaveToken(authToken); +} +``` + +### Connect Error callback -We need to handle several sorts of events: +Should we get an error during connection, we'll be given an `Exception` which contains the details about the exception. To keep things simple, we'll just write the exception to the console. -1. `onConnect`: When we connect, we will call `Subscribe` to tell the module what tables we care about. -2. `onIdentityReceived`: When we receive our credentials, we'll use the `AuthToken` module to save our token so that the next time we connect, we can re-authenticate as the same user. -3. `onSubscriptionApplied`: When we get the onSubscriptionApplied callback, that means our local client cache has been fully populated. At this time we'll print the user menu. -4. `User.OnInsert`: When a new user joins, we'll print a message introducing them. -5. `User.OnUpdate`: When a user is updated, we'll print their new name, or declare their new online status. -6. `Message.OnInsert`: When we receive a new message, we'll print it. -7. `Reducer.OnSetNameEvent`: If the server rejects our attempt to set our name, we'll print an error. -8. `Reducer.OnSendMessageEvent`: If the server rejects a message we send, we'll print an error. +To `Program.cs`, add: ```csharp -void RegisterCallbacks() +/// Our `OnConnectError` callback: print the error, then exit the process. +void OnConnectError(Exception e) { - SpacetimeDBClient.instance.onConnect += OnConnect; - SpacetimeDBClient.instance.onIdentityReceived += OnIdentityReceived; - SpacetimeDBClient.instance.onSubscriptionApplied += OnSubscriptionApplied; + Console.Write($"Error while connecting: {e}"); +} +``` + +### Disconnect callback - User.OnInsert += User_OnInsert; - User.OnUpdate += User_OnUpdate; +When disconnecting, the callback contains the connection details and if an error occurs, it will also contain an `Exception`. If we get an error, we'll write the error to the console, if not, we'll just write that we disconnected. - Message.OnInsert += Message_OnInsert; +To `Program.cs`, add: - Reducer.OnSetNameEvent += Reducer_OnSetNameEvent; - Reducer.OnSendMessageEvent += Reducer_OnSendMessageEvent; +```csharp +/// Our `OnDisconnect` callback: print a note, then exit the process. +void OnDisconnect(DbConnection conn, Exception? e) +{ + if (e != null) + { + Console.Write($"Disconnected abnormally: {e}"); + } else { + Console.Write($"Disconnected normally."); + } +} +``` + +## Register callbacks + +Now we need to handle several sorts of events with Tables and Reducers: + +1. `User.OnInsert`: When a new user joins, we'll print a message introducing them. +2. `User.OnUpdate`: When a user is updated, we'll print their new name, or declare their new online status. +3. `Message.OnInsert`: When we receive a new message, we'll print it. +4. `Reducer.OnSetName`: If the server rejects our attempt to set our name, we'll print an error. +5. `Reducer.OnSendMessage`: If the server rejects a message we send, we'll print an error. + +To `Program.cs`, add: + +```csharp +/// Register all the callbacks our app will use to respond to database events. +void RegisterCallbacks(DbConnection conn) +{ + conn.Db.User.OnInsert += User_OnInsert; + conn.Db.User.OnUpdate += User_OnUpdate; + + conn.Db.Message.OnInsert += Message_OnInsert; + + conn.Reducers.OnSetName += Reducer_OnSetNameEvent; + conn.Reducers.OnSendMessage += Reducer_OnSendMessageEvent; } ``` @@ -144,14 +246,18 @@ These callbacks can fire in two contexts: This second case means that, even though the module only ever inserts online users, the client's `User.OnInsert` callbacks may be invoked with users who are offline. We'll only notify about online users. -`OnInsert` and `OnDelete` callbacks take two arguments: the altered row, and a `ReducerEvent`. This will be `null` for rows inserted when initializing the cache for a subscription. `ReducerEvent` is an enum autogenerated by `spacetime generate` with a variant for each reducer defined by the module. For now, we can ignore this argument. +`OnInsert` and `OnDelete` callbacks take two arguments: an `EventContext` and the altered row. The `EventContext.Event` is an enum which describes the event that caused the row to be inserted or deleted. All SpacetimeDB callbacks accept a context argument, which you can use in place of your top-level `DbConnection`. Whenever we want to print a user, if they have set a name, we'll use that. If they haven't set a name, we'll instead print the first 8 bytes of their identity, encoded as hexadecimal. We'll define a function `UserNameOrIdentity` to handle this. +To `Program.cs`, add: + ```csharp -string UserNameOrIdentity(User user) => user.Name ?? user.Identity.ToString()!.Substring(0, 8); +/// If the user has no set name, use the first 8 characters from their identity. +string UserNameOrIdentity(User user) => user.Name ?? user.Identity.ToString()[..8]; -void User_OnInsert(User insertedValue, ReducerEvent? dbEvent) +/// Our `User.OnInsert` callback: if the user is online, print a notification. +void User_OnInsert(EventContext ctx, User insertedValue) { if (insertedValue.Online) { @@ -162,9 +268,9 @@ void User_OnInsert(User insertedValue, ReducerEvent? dbEvent) ### Notify about updated users -Because we declared a primary key column in our `User` table, we can also register on-update callbacks. These run whenever a row is replaced by a row with the same primary key, like our module's `User::update_by_identity` calls. We register these callbacks using the `OnUpdate` method, which is automatically implemented by `spacetime generate` for any table with a primary key column. +Because we declared a primary key column in our `User` table, we can also register on-update callbacks. These run whenever a row is replaced by a row with the same primary key, like our module's `User.Identity.Update` calls. We register these callbacks using the `OnUpdate` method, which is automatically implemented by `spacetime generate` for any table with a primary key column. -`OnUpdate` callbacks take three arguments: the old row, the new row, and a `ReducerEvent`. +`OnUpdate` callbacks take three arguments: the old row, the new row, and a `EventContext`. In our module, users can be updated for three reasons: @@ -174,24 +280,27 @@ In our module, users can be updated for three reasons: We'll print an appropriate message in each of these cases. +To `Program.cs`, add: + ```csharp -void User_OnUpdate(User oldValue, User newValue, ReducerEvent dbEvent) +/// Our `User.OnUpdate` callback: +/// print a notification about name and status changes. +void User_OnUpdate(EventContext ctx, User oldValue, User newValue) { if (oldValue.Name != newValue.Name) { Console.WriteLine($"{UserNameOrIdentity(oldValue)} renamed to {newValue.Name}"); } - - if (oldValue.Online == newValue.Online) - return; - - if (newValue.Online) - { - Console.WriteLine($"{UserNameOrIdentity(newValue)} connected."); - } - else + if (oldValue.Online != newValue.Online) { - Console.WriteLine($"{UserNameOrIdentity(newValue)} disconnected."); + if (newValue.Online) + { + Console.WriteLine($"{UserNameOrIdentity(newValue)} connected."); + } + else + { + Console.WriteLine($"{UserNameOrIdentity(newValue)} disconnected."); + } } } ``` @@ -200,29 +309,32 @@ void User_OnUpdate(User oldValue, User newValue, ReducerEvent dbEvent) When we receive a new message, we'll print it to standard output, along with the name of the user who sent it. Keep in mind that we only want to do this for new messages, i.e. those inserted by a `SendMessage` reducer invocation. We have to handle the backlog we receive when our subscription is initialized separately, to ensure they're printed in the correct order. To that effect, our `OnInsert` callback will check if its `ReducerEvent` argument is not `null`, and only print in that case. -To find the `User` based on the message's `Sender` identity, we'll use `User::FindByIdentity`, which behaves like the same function on the server. +To find the `User` based on the message's `Sender` identity, we'll use `User.Identity.Find`, which behaves like the same function on the server. We'll print the user's name or identity in the same way as we did when notifying about `User` table events, but here we have to handle the case where we don't find a matching `User` row. This can happen when the module owner sends a message using the CLI's `spacetime call`. In this case, we'll print `unknown`. +To `Program.cs`, add: + ```csharp -void PrintMessage(Message message) +/// Our `Message.OnInsert` callback: print new messages. +void Message_OnInsert(EventContext ctx, Message insertedValue) { - var sender = User.FindByIdentity(message.Sender); - var senderName = "unknown"; - if (sender != null) + if (ctx.Event is not Event.SubscribeApplied) { - senderName = UserNameOrIdentity(sender); + PrintMessage(ctx.Db, insertedValue); } - - Console.WriteLine($"{senderName}: {message.Text}"); } -void Message_OnInsert(Message insertedValue, ReducerEvent? dbEvent) +void PrintMessage(RemoteTables tables, Message message) { - if (dbEvent != null) + var sender = tables.User.Identity.Find(message.Sender); + var senderName = "unknown"; + if (sender != null) { - PrintMessage(insertedValue); + senderName = UserNameOrIdentity(sender); } + + Console.WriteLine($"{senderName}: {message.Text}"); } ``` @@ -232,11 +344,11 @@ We can also register callbacks to run each time a reducer is invoked. We registe Each reducer callback takes one fixed argument: -The ReducerEvent that triggered the callback. It contains several fields. The ones we care about are: +The `ReducerEventContext` of the callback, which contains an `Event` that contains several fields. The ones we care about are: -1. The `Identity` of the client that called the reducer. +1. The `CallerIdentity`, the `Identity` of the client that called the reducer. 2. The `Status` of the reducer run, one of `Committed`, `Failed` or `OutOfEnergy`. -3. The error message, if any, that the reducer returned. +3. If we get a `Status.Failed`, an error message is nested inside that we'll want to write to the console. It also takes a variable amount of additional arguments that match the reducer's arguments. @@ -251,16 +363,16 @@ We already handle successful `SetName` invocations using our `User.OnUpdate` cal We'll test both that our identity matches the sender and that the status is `Failed`, even though the latter implies the former, for demonstration purposes. +To `Program.cs`, add: + ```csharp -void Reducer_OnSetNameEvent(ReducerEvent reducerEvent, string name) +/// Our `OnSetNameEvent` callback: print a warning if the reducer failed. +void Reducer_OnSetNameEvent(ReducerEventContext ctx, string name) { - bool localIdentityFailedToChangeName = - reducerEvent.Identity == local_identity && - reducerEvent.Status == ClientApi.Event.Types.Status.Failed; - - if (localIdentityFailedToChangeName) + var e = ctx.Event; + if (e.CallerIdentity == local_identity && e.Status is Status.Failed(var error)) { - Console.Write($"Failed to change name to {name}"); + Console.Write($"Failed to change name to {name}: {error}"); } } ``` @@ -269,43 +381,42 @@ void Reducer_OnSetNameEvent(ReducerEvent reducerEvent, string name) We handle warnings on rejected messages the same way as rejected names, though the types and the error message are different. +To `Program.cs`, add: + ```csharp -void Reducer_OnSendMessageEvent(ReducerEvent reducerEvent, string text) +/// Our `OnSendMessageEvent` callback: print a warning if the reducer failed. +void Reducer_OnSendMessageEvent(ReducerEventContext ctx, string text) { - bool localIdentityFailedToSendMessage = - reducerEvent.Identity == local_identity && - reducerEvent.Status == ClientApi.Event.Types.Status.Failed; - - if (localIdentityFailedToSendMessage) + var e = ctx.Event; + if (e.CallerIdentity == local_identity && e.Status is Status.Failed(var error)) { - Console.Write($"Failed to send message {text}"); + Console.Write($"Failed to send message {text}: {error}"); } } ``` -## Connect callback +## Subscribe to queries -Once we are connected, we can send our subscription to the SpacetimeDB module. SpacetimeDB is set up so that each client subscribes via SQL queries to some subset of the database, and is notified about changes only to that subset. For complex apps with large databases, judicious subscriptions can save each client significant network bandwidth, memory and computation compared. For example, in [BitCraft](https://bitcraftonline.com), each player's client subscribes only to the entities in the "chunk" of the world where that player currently resides, rather than the entire game world. Our app is much simpler than BitCraft, so we'll just subscribe to the whole database. +SpacetimeDB is set up so that each client subscribes via SQL queries to some subset of the database, and is notified about changes only to that subset. For complex apps with large databases, judicious subscriptions can save each client significant network bandwidth, memory and computation. For example, in [BitCraft](https://bitcraftonline.com), each player's client subscribes only to the entities in the "chunk" of the world where that player currently resides, rather than the entire game world. Our app is much simpler than BitCraft, so we'll just subscribe to the whole database using `SubscribeToAllTables`. -```csharp -void OnConnect() -{ - SpacetimeDBClient.instance.Subscribe(new List - { - "SELECT * FROM User", "SELECT * FROM Message" - }); -} -``` +You can also subscribe to specific tables using SQL syntax, e.g. `SELECT * FROM my_table`. Our [SQL documentation](/docs/sql) enumerates the operations that are accepted in our SQL syntax. -## OnIdentityReceived callback +When we specify our subscriptions, we can supply an `OnApplied` callback. This will run when the subscription is applied and the matching rows become available in our client cache. We'll use this opportunity to print the message backlog in proper order. -This callback is executed when we receive our credentials from the SpacetimeDB module. We'll use the `AuthToken` module to save our token to local storage, so that we can re-authenticate as the same user the next time we connect. We'll also store the identity in a global variable `local_identity` so that we can use it to check if we are the sender of a message or name change. This callback also notifies us of our client's `Address`, an opaque identifier SpacetimeDB modules can use to distinguish connections by the same `Identity`, but we won't use it in our app. +We can also provide an `OnError` callback. This will run if the subscription fails, usually due to an invalid or malformed SQL queries. We can't handle this case, so we'll just print out the error and exit the process. + +In `Program.cs`, update our `OnConnected` function to include `conn.SubscriptionBuilder().OnApplied(OnSubscriptionApplied).SubscribeToAllTables();` so that it reads: ```csharp -void OnIdentityReceived(string authToken, Identity identity, Address _address) +/// Our `OnConnect` callback: save our credentials to a file. +void OnConnected(DbConnection conn, Identity identity, string authToken) { local_identity = identity; AuthToken.SaveToken(authToken); + + conn.SubscriptionBuilder() + .OnApplied(OnSubscriptionApplied) + .SubscribeToAllTables(); } ``` @@ -313,59 +424,60 @@ void OnIdentityReceived(string authToken, Identity identity, Address _address) Once our subscription is applied, we'll print all the previously sent messages. We'll define a function `PrintMessagesInOrder` to do this. `PrintMessagesInOrder` calls the automatically generated `Iter` function on our `Message` table, which returns an iterator over all rows in the table. We'll use the `OrderBy` method on the iterator to sort the messages by their `Sent` timestamp. +To `Program.cs`, add: + ```csharp -void PrintMessagesInOrder() +/// Our `OnSubscriptionApplied` callback: +/// sort all past messages and print them in timestamp order. +void OnSubscriptionApplied(SubscriptionEventContext ctx) { - foreach (Message message in Message.Iter().OrderBy(item => item.Sent)) - { - PrintMessage(message); - } + Console.WriteLine("Connected"); + PrintMessagesInOrder(ctx.Db); } -void OnSubscriptionApplied() +void PrintMessagesInOrder(RemoteTables tables) { - Console.WriteLine("Connected"); - PrintMessagesInOrder(); + foreach (Message message in tables.Message.Iter().OrderBy(item => item.Sent)) + { + PrintMessage(tables, message); + } } ``` - - ## Process thread -Since the input loop will be blocking, we'll run our processing code in a separate thread. This thread will: - -1. Connect to the module. We'll store the SpacetimeDB host name and our module name in constants `HOST` and `DB_NAME`. We will also store if SSL is enabled in a constant called `SSL_ENABLED`. This only needs to be `true` if we are using `SpacetimeDB Cloud`. Replace `` with the name you chose when publishing your module during the module quickstart. +Since the input loop will be blocking, we'll run our processing code in a separate thread. -`Connect` takes an auth token, which is `null` for a new connection, or a stored string for a returning user. We are going to use the optional AuthToken module which uses local storage to store the auth token. If you want to use your own way to associate an auth token with a user, you can pass in your own auth token here. +This thread will loop until the thread is signaled to exit, calling the update function `FrameTick` on the `DbConnection` to process any updates received from the module, and `ProcessCommand` to process any commands received from the input loop. -2. Loop until the thread is signaled to exit, calling `Update` on the SpacetimeDBClient to process any updates received from the module, and `ProcessCommand` to process any commands received from the input loop. +Afterward, close the connection to the module. -3. Finally, Close the connection to the module. +To `Program.cs`, add: ```csharp -const string HOST = "http://localhost:3000"; -const string DBNAME = "module"; - -void ProcessThread() +/// Our separate thread from main, where we can call process updates and process commands without blocking the main thread. +void ProcessThread(DbConnection conn, CancellationToken ct) { - SpacetimeDBClient.instance.Connect(AuthToken.Token, HOST, DBNAME); - - // loop until cancellation token - while (!cancel_token.IsCancellationRequested) + try { - SpacetimeDBClient.instance.Update(); + // loop until cancellation token + while (!ct.IsCancellationRequested) + { + conn.FrameTick(); - ProcessCommands(); + ProcessCommands(conn.Reducers); - Thread.Sleep(100); + Thread.Sleep(100); + } + } + finally + { + conn.Disconnect(); } - - SpacetimeDBClient.instance.Close(); } ``` -## Input loop and ProcessCommands +## Handle user input The input loop will read commands from standard input and send them to the processing thread using the input queue. The `ProcessCommands` function is called every 100ms by the processing thread to process any pending commands. @@ -375,7 +487,10 @@ Supported Commands: 2. Set name: `name`, will send the new name to the module by calling `Reducer.SetName` which is automatically generated by `spacetime generate`. +To `Program.cs`, add: + ```csharp +/// Read each line of standard input, and either set our name or send a message as appropriate. void InputLoop() { while (true) @@ -388,7 +503,7 @@ void InputLoop() if (input.StartsWith("/name ")) { - input_queue.Enqueue(("name", input.Substring(6))); + input_queue.Enqueue(("name", input[6..])); continue; } else @@ -398,18 +513,18 @@ void InputLoop() } } -void ProcessCommands() +void ProcessCommands(RemoteReducers reducers) { // process input queue commands while (input_queue.TryDequeue(out var command)) { - switch (command.Item1) + switch (command.Command) { case "message": - Reducer.SendMessage(command.Item2); + reducers.SendMessage(command.Args); break; case "name": - Reducer.SetName(command.Item2); + reducers.SetName(command.Args); break; } } @@ -418,7 +533,9 @@ void ProcessCommands() ## Run the client -Finally we just need to add a call to `Main` in `Program.cs`: +Finally, we just need to add a call to `Main`. + +To `Program.cs`, add: ```csharp Main(); @@ -432,4 +549,10 @@ dotnet run --project client ## What's next? -Congratulations! You've built a simple chat app using SpacetimeDB. You can look at the C# SDK Reference for more information about the client SDK. If you are interested in developing in the Unity game engine, check out our Unity3d Comprehensive Tutorial and BitcraftMini game example. +Congratulations! You've built a simple chat app using SpacetimeDB. + +You can find the full code for this client [in the C# client SDK's examples](https://github.com/clockworklabs/com.clockworklabs.spacetimedbsdk/tree/master/examples~/quickstart/client). + +Check out the [C# client SDK Reference](/docs/sdks/c-sharp) for a more comprehensive view of the SpacetimeDB C# client SDK. + +If you are interested in developing in the Unity game engine, check out our [Unity Comprehensive Tutorial](/docs/unity) and [Blackholio](https://github.com/ClockworkLabs/Blackholio) game example. From 34138e4b50b9f8be92ca0c83024d55fca08cd58f Mon Sep 17 00:00:00 2001 From: james gilles Date: Wed, 26 Feb 2025 14:49:31 -0500 Subject: [PATCH 108/164] Move Rust Module SDK reference to docs.rs (#114) Move rust reference to rustdoc --- docs/modules/rust/index.md | 525 +------------------------------------ 1 file changed, 2 insertions(+), 523 deletions(-) diff --git a/docs/modules/rust/index.md b/docs/modules/rust/index.md index 30581599..a8681954 100644 --- a/docs/modules/rust/index.md +++ b/docs/modules/rust/index.md @@ -1,525 +1,4 @@ -# SpacetimeDB Rust Modules +# Rust Module SDK Reference -Rust clients of SpacetimeDB use the [Rust SpacetimeDB module library][module library] to write modules which interact with the SpacetimeDB database. +The Rust Module SDK docs are [hosted on docs.rs](https://docs.rs/spacetimedb/latest/spacetimedb/). -First, the `spacetimedb` library provides a number of macros for creating tables and Rust `struct`s corresponding to rows in those tables. - -Then the client API allows interacting with the database inside special functions called reducers. - -This guide assumes you are familiar with some basics of Rust. At the very least, you should be familiar with the idea of using attribute macros. An extremely common example is `derive` macros. - -Derive macros look at the type they are attached to and generate some related code. In this example, `#[derive(Debug)]` generates the formatting code needed to print out a `Location` for debugging purposes. - -```rust -#[derive(Debug)] -struct Location { - x: u32, - y: u32, -} -``` - -## SpacetimeDB Macro basics - -Let's start with a highly commented example, straight from the [demo]. This Rust package defines a SpacetimeDB module, with types we can operate on and functions we can run. - -```rust -// In this small example, we have two Rust imports: -// |spacetimedb::spacetimedb| is the most important attribute we'll be using. -// |spacetimedb::println| is like regular old |println|, but outputting to the module's logs. -use spacetimedb::{spacetimedb, println}; - -// This macro lets us interact with a SpacetimeDB table of Person rows. -// We can insert and delete into, and query, this table by the collection -// of functions generated by the macro. -#[table(name = person, public)] -pub struct Person { - name: String, -} - -// This is the other key macro we will be using. A reducer is a -// stored procedure that lives in the database, and which can -// be invoked remotely. -#[reducer] -pub fn add(ctx: &ReducerContext, name: String) { - // |Person| is a totally ordinary Rust struct. We can construct - // one from the given name as we typically would. - let person = Person { name }; - - // Here's our first generated function! Given a |Person| object, - // we can insert it into the table: - ctx.db.person().insert(person); -} - -// Here's another reducer. Notice that this one doesn't take any arguments, while -// |add| did take one. Reducers can take any number of arguments, as long as -// SpacetimeDB recognizes their types. Reducers also have to be top level -// functions, not methods. -#[reducer] -pub fn say_hello(ctx: &ReducerContext) { - // Here's the next of our generated functions: |iter()|. This - // iterates over all the columns in the |Person| table in SpacetimeDB. - for person in ctx.db.person().iter() { - // Reducers run in a very constrained and sandboxed environment, - // and in particular, can't do most I/O from the Rust standard library. - // We provide an alternative |spacetimedb::println| which is just like - // the std version, excepted it is redirected out to the module's logs. - println!("Hello, {}!", person.name); - } - println!("Hello, World!"); -} - -// Reducers can't return values, but can return errors. To do so, -// the reducer must have a return type of `Result<(), T>`, for any `T` that -// implements `Debug`. Such errors returned from reducers will be formatted and -// printed out to logs. -#[reducer] -pub fn add_person(ctx: &ReducerContext, name: String) -> Result<(), String> { - if name.is_empty() { - return Err("Name cannot be empty"); - } - - ctx.db.person().insert(Person { name }) -} -``` - -## Macro API - -Now we'll get into details on all the macro APIs SpacetimeDB provides, starting with all the variants of the `spacetimedb` attribute. - -### Defining tables - -The `#[table(name = table_name)]` macro is applied to a Rust struct with named fields. -By default, tables are considered **private**. This means that they are only readable by the table owner, and by server module code. -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. - -_Coming soon: We plan to add much more robust access controls than just public or private. Stay tuned!_ - -```rust -#[table(name = my_table, public)] -struct MyTable { - field1: String, - field2: u32, -} -``` - -This attribute is applied to Rust structs in order to create corresponding tables in SpacetimeDB. Fields of the Rust struct correspond to columns of the database table. - -The fields of the struct have to be types that SpacetimeDB knows how to encode into the database. This is captured in Rust by the `SpacetimeType` trait. - -This is automatically defined for built in numeric types: - -- `bool` -- `u8`, `u16`, `u32`, `u64`, `u128` -- `i8`, `i16`, `i32`, `i64`, `i128` -- `f32`, `f64` - -And common data structures: - -- `String` and `&str`, utf-8 string data -- `()`, the unit type -- `Option where T: SpacetimeType` -- `Vec where T: SpacetimeType` - -All `#[table(..)]` types are `SpacetimeType`s, and accordingly, all of their fields have to be. - -```rust -#[table(name = another_table, public)] -struct AnotherTable { - // Fine, some builtin types. - id: u64, - name: Option, - - // Fine, another table type. - table: Table, - - // Fine, another type we explicitly make serializable. - serial: Serial, -} -``` - -If you want to have a field that is not one of the above primitive types, and not a table of its own, you can derive the `SpacetimeType` attribute on it. - -We can derive `SpacetimeType` on `struct`s and `enum`s with members that are themselves `SpacetimeType`s. - -```rust -#[derive(SpacetimeType)] -enum Serial { - Builtin(f64), - Compound { - s: String, - bs: Vec, - } -} -``` - -Once the table is created via the macro, other attributes described below can control more aspects of the table. For instance, a particular column can be indexed, or take on values of an automatically incremented counter. These are described in detail below. - -```rust -#[table(name = person, public)] -struct Person { - #[unique] - id: u64, - - name: String, - address: String, -} -``` - -You can create multiple tables backed by items of the same type by applying it with different names. For example, to store active and archived posts separately and with different privacy rules, you can declare two tables like this: - -```rust -#[table(name = post, public)] -#[table(name = archived_post)] -struct Post { - title: String, - body: String, -} -``` - -### Defining reducers - -`#[reducer]` is always applied to top level Rust functions. They can take arguments of types known to SpacetimeDB (just like fields of structs must be known to SpacetimeDB), and either return nothing, or return a `Result<(), E: Debug>`. - -```rust -#[reducer] -fn give_player_item(ctx: &ReducerContext, player_id: u64, item_id: u64) -> Result<(), GameErr> { - // Notice how the exact name of the filter function derives from - // the name of the field of the struct. - let mut item = ctx.db.item().item_id().find(id).ok_or(GameErr::InvalidId)?; - item.owner = Some(player_id); - ctx.db.item().item_id().update(item); - Ok(()) -} - -#[table(name = item, public)] -struct Item { - #[primary_key] - item_id: u64, - owner: Option, -} -``` - -Note that reducers can call non-reducer functions, including standard library functions. - -There are several macros which modify the semantics of a column, which are applied to the members of the table struct. `#[primary_key]`, `#[unique]` and `#[auto_inc]` are covered below, describing how those attributes affect the semantics of inserting, filtering, and so on. - -#[SpacetimeType] - -#[sats] - -### Defining Scheduler Tables - -Tables can be used to schedule a reducer calls either at a specific timestamp or at regular intervals. - -```rust -// The `scheduled` attribute links this table to a reducer. -#[table(name = send_message_timer, scheduled(send_message)] -struct SendMessageTimer { - text: String, -} -``` - -The `scheduled` attribute adds a couple of default fields and expands as follows: - -```rust -#[table(name = send_message_timer, scheduled(send_message)] - struct SendMessageTimer { - text: String, // original field - #[primary_key] - #[auto_inc] - scheduled_id: u64, // identifier for internal purpose - scheduled_at: ScheduleAt, //schedule details -} - -pub enum ScheduleAt { - /// A specific time at which the reducer is scheduled. - /// Value is a UNIX timestamp in microseconds. - Time(u64), - /// A regular interval at which the repeated reducer is scheduled. - /// Value is a duration in microseconds. - Interval(u64), -} -``` - -Managing timers with a scheduled table is as simple as inserting or deleting rows from the table. - -```rust -#[reducer] -// Reducers linked to the scheduler table should have their first argument as `&ReducerContext` -// and the second as an instance of the table struct it is linked to. -fn send_message(ctx: &ReducerContext, arg: SendMessageTimer) -> Result<(), String> { - // ... -} - -// Scheduling reducers inside `init` reducer -#[reducer(init)] -fn init(ctx: &ReducerContext) { - // Scheduling a reducer for a specific Timestamp - ctx.db.send_message_timer().insert(SendMessageTimer { - scheduled_id: 1, - text:"bot sending a message".to_string(), - //`spacetimedb::Timestamp` implements `From` trait to `ScheduleAt::Time`. - scheduled_at: ctx.timestamp.plus(Duration::from_secs(10)).into() - }); - - // Scheduling a reducer to be called at fixed interval of 100 milliseconds. - ctx.db.send_message_timer().insert(SendMessageTimer { - scheduled_id: 0, - text:"bot sending a message".to_string(), - //`std::time::Duration` implements `From` trait to `ScheduleAt::Duration`. - scheduled_at: duration!(100ms).into(), - }); -} -``` - -## Client API - -Besides the macros for creating tables and reducers, there's two other parts of the Rust SpacetimeDB library. One is a collection of macros for logging, and the other is all the automatically generated functions for operating on those tables. - -### `println!` and friends - -Because reducers run in a WASM sandbox, they don't have access to general purpose I/O from the Rust standard library. There's no filesystem or network access, and no input or output. This means no access to things like `std::println!`, which prints to standard output. - -SpacetimeDB modules have access to logging output. These are exposed as macros, just like their `std` equivalents. The names, and all the Rust formatting machinery, work the same; just the location of the output is different. - -Logs for a module can be viewed with the `spacetime logs` command from the CLI. - -```rust -use spacetimedb::{ - println, - print, - eprintln, - eprint, - dbg, -}; - -#[reducer] -fn output(ctx: &ReducerContext, i: i32) { - // These will be logged at log::Level::Info. - println!("an int with a trailing newline: {i}"); - print!("some more text...\n"); - - // These log at log::Level::Error. - eprint!("Oops..."); - eprintln!(", we hit an error"); - - // Just like std::dbg!, this prints its argument and returns the value, - // as a drop-in way to print expressions. So this will print out |i| - // before passing the value of |i| along to the calling function. - // - // The output is logged log::Level::Debug. - ctx.db.outputted_number().insert(dbg!(i)); -} -``` - -### Generated functions on a SpacetimeDB table - -We'll work off these structs to see what functions SpacetimeDB generates: - -This table has a plain old column. - -```rust -#[table(name = ordinary, public)] -struct Ordinary { - ordinary_field: u64, -} -``` - -This table has a unique column. Every row in the `Unique` table must have distinct values of the `unique_field` column. Attempting to insert a row with a duplicate value will fail. - -```rust -#[table(name = unique, public)] -struct Unique { - // A unique column: - #[unique] - unique_field: u64, -} -``` - -This table has an automatically incrementing column. SpacetimeDB automatically provides an incrementing sequence of values for this field, and sets the field to that value when you insert the row. - -Only integer types can be `#[unique]`: `u8`, `u16`, `u32`, `u64`, `u128`, `i8`, `i16`, `i32`, `i64` and `i128`. - -```rust -#[table(name = autoinc, public)] -struct Autoinc { - #[auto_inc] - autoinc_field: u64, -} -``` - -These attributes can be combined, to create an automatically assigned ID usable for filtering. - -```rust -#[table(name = identity, public)] -struct Identity { - #[auto_inc] - #[unique] - id_field: u64, -} -``` - -### Insertion - -We'll talk about insertion first, as there a couple of special semantics to know about. - -When we define |Ordinary| as a SpacetimeDB table, we get the ability to insert into it with the generated `ctx.db.ordinary().insert(..)` method. - -Inserting takes a single argument, the row to insert. When there are no unique fields in the row, the return value is the inserted row. - -```rust -#[reducer] -fn insert_ordinary(ctx: &ReducerContext, value: u64) { - let ordinary = Ordinary { ordinary_field: value }; - let result = ctx.db.ordinary().insert(ordinary); - assert_eq!(ordinary.ordinary_field, result.ordinary_field); -} -``` - -When there is a unique column constraint on the table, insertion can fail if a uniqueness constraint is violated. - -If we insert two rows which have the same value of a unique column, the second will fail. - -```rust -#[reducer] -fn insert_unique(ctx: &ReducerContext, value: u64) { - let result = ctx.db.unique().insert(Unique { unique_field: value }); - assert!(result.is_ok()); - - let result = ctx.db.unique().insert(Unique { unique_field: value }); - assert!(result.is_err()); -} -``` - -When inserting a table with an `#[auto_inc]` column, the database will automatically overwrite whatever we give it with an atomically increasing value. - -The returned row has the `autoinc` column set to the value that was actually written into the database. - -```rust -#[reducer] -fn insert_autoinc(ctx: &ReducerContext) { - for i in 1..=10 { - // These will have values of 1, 2, ..., 10 - // at rest in the database, regardless of - // what value is actually present in the - // insert call. - let actual = ctx.db.autoinc().insert(Autoinc { autoinc_field: 23 }) - assert_eq!(actual.autoinc_field, i); - } -} - -#[reducer] -fn insert_id(ctx: &ReducerContext) { - for _ in 0..10 { - // These also will have values of 1, 2, ..., 10. - // There's no collision and silent failure to insert, - // because the value of the field is ignored and overwritten - // with the automatically incremented value. - ctx.db.identity().insert(Identity { id_field: 23 }) - } -} -``` - -### Iterating - -Given a table, we can iterate over all the rows in it. - -```rust -#[table(name = person, public)] -struct Person { - #[unique] - id: u64, - - #[index(btree)] - age: u32, - name: String, - address: String, -} -``` - -// Every table structure has a generated iter function, like: - -```rust -ctx.db.my_table().iter() -``` - -`iter()` returns a regular old Rust iterator, giving us a sequence of `Person`. The database sends us over rows, one at a time, for each time through the loop. This means we get them by value, and own the contents of `String` fields and so on. - -``` -#[reducer] -fn iteration(ctx: &ReducerContext) { - let mut addresses = HashSet::new(); - - for person in ctx.db.person().iter() { - addresses.insert(person.address); - } - - for address in addresses.iter() { - println!("{address}"); - } -} -``` - -### Filtering - -Often, we don't need to look at the entire table, and instead are looking for rows with specific values in certain columns. - -Our `Person` table has a unique id column, so we can filter for a row matching that ID. Since it is unique, we will find either 0 or 1 matching rows in the database. This gets represented naturally as an `Option` in Rust. SpacetimeDB automatically creates and uses indexes for filtering on unique columns, so it is very efficient. - -The name of the filter method just corresponds to the column name. - -```rust -#[reducer] -fn filtering(ctx: &ReducerContext, id: u64) { - match ctx.db.person().id().find(id) { - Some(person) => println!("Found {person}"), - None => println!("No person with id {id}"), - } -} -``` - -Our `Person` table also has an index on its `age` column. Unlike IDs, ages aren't unique. Filtering for every person who is 21, then, gives us an `Iterator` rather than an `Option`. - -```rust -#[reducer] -fn filtering_non_unique(ctx: &ReducerContext) { - for person in ctx.db.person().age().filter(21u32) { - println!("{} has turned 21", person.name); - } -} -``` - -> NOTE: An unfortunate interaction between Rust's trait solver and integer literal defaulting rules means that you must specify the types of integer literals passed to `filter` and `find` methods via the suffix syntax, like `21u32`. If you don't, you'll see a compiler error like: -> ``` -> error[E0271]: type mismatch resolving `::Column == u32` -> --> modules/rust-wasm-test/src/lib.rs:356:48 -> | -> 356 | for person in ctx.db.person().age().filter(21) { -> | ------ ^^ expected `u32`, found `i32` -> | | -> | required by a bound introduced by this call -> | -> = note: required for `i32` to implement `BTreeIndexBounds<(u32,), SingleBound>` -> note: required by a bound in `BTreeIndex::::filter` -> | -> 410 | pub fn filter(&self, b: B) -> impl Iterator -> | ------ required by a bound in this associated function -> 411 | where -> 412 | B: BTreeIndexBounds, -> | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `BTreeIndex::::filter` -> ``` - -### Deleting - -Like filtering, we can delete by an indexed or unique column instead of the entire row. - -```rust -#[reducer] -fn delete_id(ctx: &ReducerContext, id: u64) { - ctx.db.person().id().delete(id) -} -``` - -[macro library]: https://github.com/clockworklabs/SpacetimeDB/tree/master/crates/bindings-macro -[module library]: https://github.com/clockworklabs/SpacetimeDB/tree/master/crates/lib -[demo]: /#demo From 656b9baf0df076b5ad2c1edd4955520e8d6db020 Mon Sep 17 00:00:00 2001 From: Mazdak Farrokhzad Date: Thu, 27 Feb 2025 16:32:04 +0100 Subject: [PATCH 109/164] Document reducer semantics wrt. transactionality (#185) document reducer semantics wrt. transactionality --- docs/index.md | 99 +++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 88 insertions(+), 11 deletions(-) diff --git a/docs/index.md b/docs/index.md index 974b543f..e04e3055 100644 --- a/docs/index.md +++ b/docs/index.md @@ -46,7 +46,7 @@ You write SQL queries specifying what information a client is interested in -- f ### Module Libraries -Every SpacetimeDB database contains a collection of stored procedures called a **module**. Modules can be written in C# or Rust. They specify a database schema and the business logic that responds to client requests. Modules are administered using the `spacetime` CLI tool. +Every SpacetimeDB database contains a collection of [stored procedures](https://en.wikipedia.org/wiki/Stored_procedure) and schema definitions. Such a collection is called a **module**, which can be written in C# or Rust. They specify a database schema and the business logic that responds to client requests. Modules are administered using the `spacetime` CLI tool. - [Rust](/docs/modules/rust) - [(Quickstart)](/docs/modules/rust/quickstart) - [C#](/docs/modules/c-sharp) - [(Quickstart)](/docs/modules/c-sharp/quickstart) @@ -111,6 +111,27 @@ Tables marked `public` can also be read by [clients](#client). A **reducer** is a function exported by a [database](#database). Connected [clients](#client-side-sdks) can call reducers to interact with the database. This is a form of [remote procedure call](https://en.wikipedia.org/wiki/Remote_procedure_call). + +:::server-rust +A reducer can be written in Rust like so: + +```rust +#[spacetimedb::reducer] +pub fn set_player_name(ctx: &spacetimedb::ReducerContext, id: u64, name: String) -> Result<(), String> { + // ... +} +``` + +And a Rust [client](#client) can call that reducer: + +```rust +fn main() { + // ...setup code, then... + ctx.reducers.set_player_name(57, "Marceline".into()); +} +``` +::: +:::server-csharp A reducer can be written in C# like so: ```csharp @@ -120,14 +141,6 @@ public static void SetPlayerName(ReducerContext ctx, uint playerId, string name) // ... } ``` - And a C# [client](#client) can call that reducer: @@ -137,10 +150,74 @@ void Main() { Connection.Reducer.SetPlayerName(57, "Marceline"); } ``` +::: + +These look mostly like regular function calls, but under the hood, +the client sends a request over the internet, which the database processes and responds to. + +The `ReducerContext` is a reducer's only mandatory parameter +and includes information about the caller's [identity](#identity). +This can be used to authenticate the caller. + +Reducers are run in their own separate and atomic [database transactions](https://en.wikipedia.org/wiki/Database_transaction). +When a reducer completes successfully, the changes the reducer has made, +such as inserting a table row, are *committed* to the database. +However, if the reducer instead returns an error, or throws an exception, +the database will instead reject the request and *revert* all those changes. +That is, reducers and transactions are all-or-nothing requests. +It's not possible to keep the first half of a reducer's changes and discard the last. + +Transactions are only started by requests from outside the database. +When a reducer calls another reducer directly, as in the example below, +the changes in the called reducer does not happen in its own child transaction. +Instead, when the nested reducer gracefully errors, +and the overall reducer completes successfully, +the changes in the nested one are still persisted. + +:::server-rust +```rust +#[spacetimedb::reducer] +pub fn hello(ctx: &spacetimedb::ReducerContext) -> Result<(), String> { + if world(ctx).is_err() { + other_changes(ctx); + } +} -These look mostly like regular function calls, but under the hood, the client sends a request over the internet, which the database processes and responds to. +#[spacetimedb::reducer] +pub fn world(ctx: &spacetimedb::ReducerContext) -> Result<(), String> { + clear_all_tables(ctx); +} +``` +::: +:::server-csharp +```csharp +[SpacetimeDB.Reducer] +public static void Hello(ReducerContext ctx) +{ + if(!World(ctx)) + { + OtherChanges(ctx); + } +} -The `ReducerContext` passed into a reducer includes information about the caller's [identity](#identity) and [address](#address). The database can reject any request it doesn't approve of. +[SpacetimeDB.Reducer] +public static void World(ReducerContext ctx) +{ + ClearAllTables(ctx); + // ... +} +``` +::: + +While SpacetimeDB doesn't support nested transactions, +a reducer can [schedule another reducer] to run at an interval, +or at a specific time. +:::server-rust +[schedule another reducer]: /docs/modules/rust#defining-scheduler-tables +::: +:::server-csharp +[schedule another reducer]: /docs/modules/c-sharp#scheduler-tables +::: ### Client A **client** is an application that connects to a [database](#database). A client logs in using an [identity](#identity) and receives an [address](#address) to identify the connection. After that, it can call [reducers](#reducer) and query public [tables](#table). From 1b74784429ae2924b5a5b1ff75c46b1efc7ac4fd Mon Sep 17 00:00:00 2001 From: Mario Montoya Date: Thu, 27 Feb 2025 16:59:49 -0500 Subject: [PATCH 110/164] Document behaviour of SEQUENCES (#174) * Document behaviour of SEQUENCES * Update docs/appendix.md Co-authored-by: Tyler Cloutier * Apply suggestions from code review Co-authored-by: Phoebe Goldman --------- Co-authored-by: Tyler Cloutier Co-authored-by: Phoebe Goldman --- docs/appendix.md | 61 +++++++++++++++++++++++++++++++++++ docs/modules/c-sharp/index.md | 5 +++ docs/nav.js | 1 + nav.ts | 1 + 4 files changed, 68 insertions(+) create mode 100644 docs/appendix.md diff --git a/docs/appendix.md b/docs/appendix.md new file mode 100644 index 00000000..bc184c24 --- /dev/null +++ b/docs/appendix.md @@ -0,0 +1,61 @@ +# Appendix + +## SEQUENCE + +For each table containing an `#[auto_inc]` column, SpacetimeDB creates a sequence number generator behind the scenes, which functions similarly to `postgres`'s `SEQUENCE`. + +### How It Works + +* Sequences in SpacetimeDB use Rust’s `i128` integer type. +* The field type marked with `#[auto_inc]` is cast to `i128` and increments by `1` for each new row. +* Sequences are pre-allocated in chunks of `4096` to speed up number generation, and then are only persisted to disk when the pre-allocated chunk is exhausted. + +> **⚠ Warning:** Sequence number generation is not transactional. + +* Numbers are incremented even if a transaction is later rolled back. +* Unused numbers are not reclaimed, meaning sequences may have *gaps*. +* If the server restarts or a transaction rolls back, the sequence continues from the next pre-allocated chunk + `1`: + +**Example:** + +```rust +#[spacetimedb::table(name = users, public)] +struct Users { + #[auto_inc] + user_id: u64, + name: String, +} + +#[spacetimedb::reducer] +pub fn insert_user(ctx: &ReducerContext, count: u8) { + for i in 0..count { + let name = format!("User {}", i); + ctx.db.users().insert(Users { user_id: 0, name }); + } + // Query the table to see the effect of the `[auto_inc]` attribute: + for user in ctx.db.users().iter() { + log::info!("User: {:?}", user); + } +} +``` + +Then: + +```bash +❯ cargo run --bin spacetimedb-cli call sample insert_user 3 + +❯ spacetimedb-cli logs sample +... +.. User: Users { user_id: 1, name: "User 0" } +.. User: Users { user_id: 2, name: "User 1" } +.. User: Users { user_id: 3, name: "User 2" } + +# Database restart, then + +❯ cargo run --bin spacetimedb-cli call sample insert_user 1 + +❯ spacetimedb-cli logs sample +... +.. User: Users { user_id: 3, name: "User 2" } +.. User: Users { user_id: 4098, name: "User 0" } +``` \ No newline at end of file diff --git a/docs/modules/c-sharp/index.md b/docs/modules/c-sharp/index.md index 2c31bb1c..40a25e09 100644 --- a/docs/modules/c-sharp/index.md +++ b/docs/modules/c-sharp/index.md @@ -271,6 +271,9 @@ Attribute `[SpacetimeDB.Column]` can be used on any field of a `SpacetimeDB.Tabl The supported column attributes are: - `ColumnAttrs.AutoInc` - this column should be auto-incremented. + +**Note**: The `AutoInc` number generator is not transactional. See the [SEQUENCE] section for more details. + - `ColumnAttrs.Unique` - this column should be unique. - `ColumnAttrs.PrimaryKey` - this column should be a primary key, it implies `ColumnAttrs.Unique` but also allows clients to subscribe to updates via `OnUpdate` which will use this field to match the old and the new version of the row with each other. @@ -412,3 +415,5 @@ public static void OnDisconnect(DbEventArgs ctx) Log($"{ctx.Sender} has disconnected."); }``` ```` + +[SEQUENCE]: /docs/appendix#sequence \ No newline at end of file diff --git a/docs/nav.js b/docs/nav.js index 930361c4..1c73dba7 100644 --- a/docs/nav.js +++ b/docs/nav.js @@ -45,6 +45,7 @@ const nav = { page('SQL Reference', 'sql', 'sql/index.md'), section('Subscriptions'), page('Subscription Reference', 'subscriptions', 'subscriptions/index.md'), + page('Appendix', 'appendix', 'appendix.md'), ], }; export default nav; diff --git a/nav.ts b/nav.ts index 40c9c31e..5093b9e3 100644 --- a/nav.ts +++ b/nav.ts @@ -93,6 +93,7 @@ const nav: Nav = { section('Subscriptions'), page('Subscription Reference', 'subscriptions', 'subscriptions/index.md'), + page('Appendix', 'appendix', 'appendix.md'), ], }; From 7d64b77b0eed0bbc2c0d27f88d88f0fe423c3e69 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Fri, 28 Feb 2025 14:25:55 -0500 Subject: [PATCH 111/164] Update Rust client SDK quickstart for 1.0 API (#162) * Begin revising rust client quickstart: update the code * Revise Rust client SDK quickstart A whole bunch of stuff has changed since this document was last updated. Notably, I've chosen to re-order a bunch of sections, since the previous structure of the document doesn't make much sense after the 0.12 API rework. * Fix credentials import issue There are still warnings here but it builds now * Fix warnings after pasting all this code into a fresh project --------- Co-authored-by: John Detter <4099508+jdetter@users.noreply.github.com> --- docs/sdks/rust/quickstart.md | 314 +++++++++++++++++++---------------- 1 file changed, 175 insertions(+), 139 deletions(-) diff --git a/docs/sdks/rust/quickstart.md b/docs/sdks/rust/quickstart.md index ced12969..888782e6 100644 --- a/docs/sdks/rust/quickstart.md +++ b/docs/sdks/rust/quickstart.md @@ -28,7 +28,7 @@ cargo new client Below the `[dependencies]` line in `client/Cargo.toml`, add: ```toml -spacetimedb-sdk = "0.12" +spacetimedb-sdk = "1.0" hex = "0.4" ``` @@ -59,7 +59,9 @@ spacetime generate --lang rust --out-dir client/src/module_bindings --project-pa Take a look inside `client/src/module_bindings`. The CLI should have generated a few files: ``` -module_bindings +module_bindings/ +├── identity_connected_reducer.rs +├── identity_disconnected_reducer.rs ├── message_table.rs ├── message_type.rs ├── mod.rs @@ -85,128 +87,174 @@ We'll need additional imports from `spacetimedb_sdk` for interacting with the da To `client/src/main.rs`, add: ```rust -use spacetimedb_sdk::{anyhow, DbContext, Event, Identity, Status, Table, TableWithPrimaryKey}; -use spacetimedb_sdk::credentials::File; +use spacetimedb_sdk::{credentials, DbContext, Error, Event, Identity, Status, Table, TableWithPrimaryKey}; ``` ## Define the main function Our `main` function will do the following: -1. Connect to the database. This will also start a new thread for handling network messages. -2. Handle user input from the command line. +1. Connect to the database. +2. Register a number of callbacks to run in response to various database events. +3. Subscribe to a set of SQL queries, whose results will be replicated and automatically updated in our client. +4. Spawn a background thread where our connection will process messages and invoke callbacks. +5. Enter a loop to handle user input from the command line. We'll see the implementation of these functions a bit later, but for now add to `client/src/main.rs`: ```rust fn main() { // Connect to the database - let conn = connect_to_db(); + let ctx = connect_to_db(); + + // Register callbacks to run in response to database events. + register_callbacks(&ctx); + + // Subscribe to SQL queries in order to construct a local partial replica of the database. + subscribe_to_tables(&ctx); + + // Spawn a thread, where the connection will process messages and invoke callbacks. + ctx.run_threaded(); + // Handle CLI input - user_input_loop(&conn); + user_input_loop(&ctx); } ``` +## Connect to the database -## Register callbacks +A connection to a SpacetimeDB database is represented by a `DbConnection`. We configure `DbConnection`s using the builder pattern, by calling `DbConnection::builder()`, chaining method calls to set various connection parameters and register callbacks, then we cap it off with a call to `.build()` to begin the connection. -We need to handle several sorts of events: +In our case, we'll supply the following options: -1. When we connect and receive our credentials, we'll save them to a file so that the next time we connect, we can re-authenticate as the same user. -2. When a new user joins, we'll print a message introducing them. -3. When a user is updated, we'll print their new name, or declare their new online status. -4. When we receive a new message, we'll print it. -5. When we're informed of the backlog of past messages, we'll sort them and print them in order. -6. If the server rejects our attempt to set our name, we'll print an error. -7. If the server rejects a message we send, we'll print an error. -8. When our connection ends, we'll print a note, then exit the process. +1. An `on_connect` callback, to run when the remote database acknowledges and accepts our connection. +2. An `on_connect_error` callback, to run if the remote database is unreachable or it rejects our connection. +3. An `on_disconnect` callback, to run when our connection ends. +4. A `with_token` call, to supply a token to authenticate with. +5. A `with_module_name` call, to specify the name or `Identity` of our database. Make sure to pass the same name here as you supplied to `spacetime publish`. +6. A `with_uri` call, to specify the URI of the SpacetimeDB host where our module is running. To `client/src/main.rs`, add: ```rust -/// Register all the callbacks our app will use to respond to database events. -fn register_callbacks(conn: &DbConnection) { - // When a new user joins, print a notification. - conn.db.user().on_insert(on_user_inserted); - - // When a user's status changes, print a notification. - conn.db.user().on_update(on_user_updated); - - // When a new message is received, print it. - conn.db.message().on_insert(on_message_inserted); +/// The URI of the SpacetimeDB instance hosting our chat module. +const HOST: &str = "http://localhost:3000"; - // When we receive the message backlog, print it in timestamp order. - conn.subscription_builder().on_applied(on_sub_applied); +/// The database name we chose when we published our module. +const DB_NAME: &str = "quickstart-chat"; - // When we fail to set our name, print a warning. - conn.reducers.on_set_name(on_name_set); - - // When we fail to send a message, print a warning. - conn.reducers.on_send_message(on_message_sent); +/// Load credentials from a file and connect to the database. +fn connect_to_db() -> DbConnection { + DbConnection::builder() + // Register our `on_connect` callback, which will save our auth token. + .on_connect(on_connected) + // Register our `on_connect_error` callback, which will print a message, then exit the process. + .on_connect_error(on_connect_error) + // Our `on_disconnect` callback, which will print a message, then exit the process. + .on_disconnect(on_disconnected) + // If the user has previously connected, we'll have saved a token in the `on_connect` callback. + // In that case, we'll load it and pass it to `with_token`, + // so we can re-authenticate as the same `Identity`. + .with_token(creds_store().load().expect("Error loading credentials")) + // Set the database name we chose when we called `spacetime publish`. + .with_module_name(DB_NAME) + // Set the URI of the SpacetimeDB host that's running our database. + .with_uri(HOST) + // Finalize configuration and connect! + .build() + .expect("Failed to connect") } ``` -## Save credentials +### Save credentials -Each user has a `Credentials`, which consists of two parts: +SpacetimeDB will accept any [OpenID Connect](https://openid.net/developers/how-connect-works/) compliant [JSON Web Token](https://jwt.io/) and use it to compute an `Identity` for the user. More complex applications will generally authenticate their user somehow, generate or retrieve a token, and attach it to their connection via `with_token`. In our case, though, we'll connect anonymously the first time, let SpacetimeDB generate a fresh `Identity` and corresponding JWT for us, and save that token locally to re-use the next time we connect. -- An `Identity`, a unique public identifier. We're using these to identify `User` rows. -- A `Token`, a private key which SpacetimeDB uses to authenticate the client. - -`Credentials` are generated by SpacetimeDB each time a new client connects, and sent to the client so they can be saved, in order to re-connect with the same identity. The Rust SDK provides a pair of functions in `File`, `save` and `load`, for saving and storing these credentials in a file. By default the `save` and `load` will look for credentials in the `$HOME/.spacetimedb_client_credentials/` directory, which should be unintrusive. If saving our credentials fails, we'll print a message to standard error, but otherwise continue normally; even though the user won't be able to reconnect with the same identity, they can still chat normally. +The Rust SDK provides a pair of functions in `File`, `save` and `load`, for saving and storing these credentials in a file. By default the `save` and `load` will look for credentials in the `$HOME/.spacetimedb_client_credentials/` directory, which should be unintrusive. If saving our credentials fails, we'll print a message to standard error, but otherwise continue; even though the user won't be able to reconnect with the same identity, they can still chat normally. To `client/src/main.rs`, add: ```rust +fn creds_store() -> credentials::File { + credentials::File::new("quickstart-chat") +} + /// Our `on_connect` callback: save our credentials to a file. -fn on_connected(conn: &DbConnection, ident: Identity, token: &str) { - let file = File::new(CREDS_NAME); - if let Err(e) = file.save(ident, token) { +fn on_connected(_ctx: &DbConnection, _identity: Identity, token: &str) { + if let Err(e) = creds_store().save(token) { eprintln!("Failed to save credentials: {:?}", e); } - - println!("Connected to SpacetimeDB."); - println!("Use /name to set your username, otherwise enter your message!"); - - // Subscribe to the data we care about - subscribe_to_tables(&conn); - // Register callbacks for reducers - register_callbacks(&conn); } ``` -You can see here that when we connect we're going to register our callbacks, which we defined above. +### Handle errors and disconnections -## Handle errors and disconnections - -We need to handle connection errors and disconnections by printing appropriate messages and exiting the program. +We need to handle connection errors and disconnections by printing appropriate messages and exiting the program. These callbacks take an `ErrorContext`, a `DbConnection` that's been augmented with information about the error that occured. To `client/src/main.rs`, add: ```rust /// Our `on_connect_error` callback: print the error, then exit the process. -fn on_connect_error(err: &anyhow::Error) { +fn on_connect_error(_ctx: &ErrorContext, err: Error) { eprintln!("Connection error: {:?}", err); + std::process::exit(1); } /// Our `on_disconnect` callback: print a note, then exit the process. -fn on_disconnected(_conn: &DbConnection, _err: Option<&anyhow::Error>) { - eprintln!("Disconnected!"); - std::process::exit(0) +fn on_disconnected(_ctx: &ErrorContext, err: Option) { + if let Some(err) = err { + eprintln!("Disconnected: {}", err); + std::process::exit(1); + } else { + println!("Disconnected."); + std::process::exit(0); + } +} +``` + +## Register callbacks + +We need to handle several sorts of events: + +1. When a new user joins, we'll print a message introducing them. +2. When a user is updated, we'll print their new name, or declare their new online status. +3. When we receive a new message, we'll print it. +4. If the server rejects our attempt to set our name, we'll print an error. +5. If the server rejects a message we send, we'll print an error. + +To `client/src/main.rs`, add: + +```rust +/// Register all the callbacks our app will use to respond to database events. +fn register_callbacks(ctx: &DbConnection) { + // When a new user joins, print a notification. + ctx.db.user().on_insert(on_user_inserted); + + // When a user's status changes, print a notification. + ctx.db.user().on_update(on_user_updated); + + // When a new message is received, print it. + ctx.db.message().on_insert(on_message_inserted); + + // When we fail to set our name, print a warning. + ctx.reducers.on_set_name(on_name_set); + + // When we fail to send a message, print a warning. + ctx.reducers.on_send_message(on_message_sent); } ``` -## Notify about new users +### Notify about new users For each table, we can register on-insert and on-delete callbacks to be run whenever a subscribed row is inserted or deleted. We register these callbacks using the `on_insert` and `on_delete`, which is automatically implemented for each table by `spacetime generate`. -These callbacks can fire in two contexts: +These callbacks can fire in several contexts, of which we care about two: - After a reducer runs, when the client's cache is updated about changes to subscribed rows. - After calling `subscribe`, when the client's cache is initialized with all existing matching rows. This second case means that, even though the module only ever inserts online users, the client's `conn.db.user().on_insert(..)` callbacks may be invoked with users who are offline. We'll only notify about online users. -`on_insert` and `on_delete` callbacks take two arguments: `&EventContext` and the row data (in the case of insert it's a new row and in the case of delete it's the row that was deleted). You can determine whether the insert/delete operation was caused by a reducer or subscription update by checking the type of `ctx.event`. If `ctx.event` is a `Event::Reducer` then the row was changed by a reducer call, otherwise it was modified by a subscription update. `Reducer` is an enum autogenerated by `spacetime generate` with a variant for each reducer defined by the module. For now, we can ignore this argument. +`on_insert` and `on_delete` callbacks take two arguments: an `&EventContext` and the modified row. Like the `ErrorContext` above, `EventContext` is a `DbConnection` that's been augmented with information about the event that caused the row to be modified. You can determine whether the insert/delete operation was caused by a reducer, a newly-applied subscription, or some other event by pattern-matching on `ctx.event`. Whenever we want to print a user, if they have set a name, we'll use that. If they haven't set a name, we'll instead print the first 8 bytes of their identity, encoded as hexadecimal. We'll define functions `user_name_or_identity` and `identity_leading_hex` to handle this. @@ -230,9 +278,9 @@ fn user_name_or_identity(user: &User) -> String { ### Notify about updated users -Because we declared a `#[primary_key]` column in our `User` table, we can also register on-update callbacks. These run whenever a row is replaced by a row with the same primary key, like our module's `ctx.db.user().identity().update(..) calls. We register these callbacks using the `on_update` method of the trait `TableWithPrimaryKey`, which is automatically implemented by `spacetime generate` for any table with a `#[primary_key]` column. +Because we declared a `#[primary_key]` column in our `User` table, we can also register on-update callbacks. These run whenever a row is replaced by a row with the same primary key, like our module's `ctx.db.user().identity().update(..)` calls. We register these callbacks using the `on_update` method of the trait `TableWithPrimaryKey`, which is automatically implemented by `spacetime generate` for any table with a `#[primary_key]` column. -`on_update` callbacks take three arguments: the old row, the new row, and an `Option<&ReducerEvent>`. +`on_update` callbacks take three arguments: the `&EventContext`, the old row, and the new row. In our module, users can be updated for three reasons: @@ -247,7 +295,7 @@ To `client/src/main.rs`, add: ```rust /// Our `User::on_update` callback: /// print a notification about name and status changes. -fn on_user_updated(old: &User, new: &User, _: Option<&ReducerEvent>) { +fn on_user_updated(_ctx: &EventContext, old: &User, new: &User) { if old.name != new.name { println!( "User {} renamed to {}.", @@ -264,7 +312,7 @@ fn on_user_updated(old: &User, new: &User, _: Option<&ReducerEvent>) { } ``` -## Print messages +### Print messages When we receive a new message, we'll print it to standard output, along with the name of the user who sent it. Keep in mind that we only want to do this for new messages, i.e. those inserted by a `send_message` reducer invocation. We have to handle the backlog we receive when our subscription is initialized separately, to ensure they're printed in the correct order. To that effect, our `on_message_inserted` callback will check if the ctx.event type is an `Event::Reducer`, and only print in that case. @@ -272,7 +320,7 @@ To find the `User` based on the message's `sender` identity, we'll use `ctx.db.u We'll print the user's name or identity in the same way as we did when notifying about `User` table events, but here we have to handle the case where we don't find a matching `User` row. This can happen when the module owner sends a message using the CLI's `spacetime call`. In this case, we'll print `unknown`. -We'll handle message-related events, such as receiving new messages or loading past messages. +Notice that our `print_message` function takes an `&impl RemoteDbContext` as an argument. This is a trait, defined in our `module_bindings` by `spacetime generate`, which is implemented by `DbConnection`, `EventContext`, `ErrorContext` and a few other similar types. (`RemoteDbContext` is actually a shorthand for `DbContext`, which applies to connections to *any* module, with its associated types locked to module-specific ones.) Later on, we're going to call `print_message` with a `ReducerEventContext`, so we need to be more generic than just accepting `EventContext`. To `client/src/main.rs`, add: @@ -284,40 +332,23 @@ fn on_message_inserted(ctx: &EventContext, message: &Message) { } } -fn print_message(ctx: &EventContext, message: &Message) { - let sender = ctx.db.user().identity().find(&message.sender.clone()) +fn print_message(ctx: &impl RemoteDbContext, message: &Message) { + let sender = ctx + .db() + .user() + .identity() + .find(&message.sender.clone()) .map(|u| user_name_or_identity(&u)) .unwrap_or_else(|| "unknown".to_string()); println!("{}: {}", sender, message.text); } ``` -### Print past messages in order - -Messages we receive live will come in order, but when we connect, we'll receive all the past messages at once. We can't just print these in the order we receive them; the logs would be all shuffled around, and would make no sense. Instead, when we receive the log of past messages, we'll sort them by their sent timestamps and print them in order. - - -We'll handle this in our function `print_messages_in_order`, which we registered as an `on_subscription_applied` callback. `print_messages_in_order` iterates over all the `Message`s we've received, sorts them, and then prints them. `Message::iter()` is defined on the trait `TableType`, and returns an iterator over all the messages in the client's cache. Rust iterators can't be sorted in-place, so we'll collect it to a `Vec`, then use the `sort_by_key` method to sort by timestamp. - -To `client/src/main.rs`, add: - -```rust -/// Our `on_subscription_applied` callback: -/// sort all past messages and print them in timestamp order. -fn on_sub_applied(ctx: &EventContext) { - let mut messages = ctx.db.message().iter().collect::>(); - messages.sort_by_key(|m| m.sent); - for message in messages { - print_message(ctx, &message); - } -} -``` - -## Handle reducer failures +### Handle reducer failures We can also register callbacks to run each time a reducer is invoked. We register these callbacks using the `on_reducer` method of the `Reducer` trait, which is automatically implemented for each reducer by `spacetime generate`. -Each reducer callback first takes an `&EventContext` which contains all of the information from the reducer call including the reducer arguments, the identity of the caller, and whether or not the reducer call suceeded. +Each reducer callback first takes a `&ReducerEventContext` which contains metadata about the reducer call, including the identity of the caller and whether or not the reducer call suceeded. These callbacks will be invoked in one of two cases: @@ -333,69 +364,74 @@ To `client/src/main.rs`, add: ```rust /// Our `on_set_name` callback: print a warning if the reducer failed. -fn on_name_set(ctx: &EventContext, name: &String) { - if let Event::Reducer(reducer) = &ctx.event { - if let Status::Failed(err) = reducer.status.clone() { - eprintln!("Failed to change name to {:?}: {}", name, err); - } +fn on_name_set(ctx: &ReducerEventContext, name: &String) { + if let Status::Failed(err) = &ctx.event.status { + eprintln!("Failed to change name to {:?}: {}", name, err); } } /// Our `on_send_message` callback: print a warning if the reducer failed. -fn on_message_sent(ctx: &EventContext, text: &String) { - if let Event::Reducer(reducer) = &ctx.event { - if let Status::Failed(err) = reducer.status.clone() { - eprintln!("Failed to send message {:?}: {}", text, err); - } +fn on_message_sent(ctx: &ReducerEventContext, text: &String) { + if let Status::Failed(err) = &ctx.event.status { + eprintln!("Failed to send message {:?}: {}", text, err); } } ``` -## Connect to the database +## Subscribe to queries + +SpacetimeDB is set up so that each client subscribes via SQL queries to some subset of the database, and is notified about changes only to that subset. For complex apps with large databases, judicious subscriptions can save each client significant network bandwidth, memory and computation. For example, in [BitCraft](https://bitcraftonline.com), each player's client subscribes only to the entities in the "chunk" of the world where that player currently resides, rather than the entire game world. Our app is much simpler than BitCraft, so we'll just subscribe to the whole database. + +When we specify our subscriptions, we can supply an `on_applied` callback. This will run when the subscription is applied and the matching rows become available in our client cache. We'll use this opportunity to print the message backlog in proper order. -Now that our callbacks are all set up, we can connect to the database. We'll store the URI of the SpacetimeDB instance and our module name in constants `SPACETIMEDB_URI` and `DB_NAME`. Replace `` with the name you chose when publishing your module during the module quickstart. +We'll also provide an `on_error` callback. This will run if the subscription fails, usually due to an invalid or malformed SQL queries. We can't handle this case, so we'll just print out the error and exit the process. To `client/src/main.rs`, add: ```rust -/// The URL of the SpacetimeDB instance hosting our chat module. -const SPACETIMEDB_URI: &str = "http://localhost:3000"; +/// Register subscriptions for all rows of both tables. +fn subscribe_to_tables(ctx: &DbConnection) { + ctx.subscription_builder() + .on_applied(on_sub_applied) + .on_error(on_sub_error) + .subscribe(["SELECT * FROM user", "SELECT * FROM message"]); +} +``` -/// The module name we chose when we published our module. -const DB_NAME: &str = ""; +### Print past messages in order -/// You should change this value to a unique name based on your application. -const CREDS_NAME: &str = "rust-sdk-quickstart"; +Messages we receive live will come in order, but when we connect, we'll receive all the past messages at once. We can't just print these in the order we receive them; the logs would be all shuffled around, and would make no sense. Instead, when we receive the log of past messages, we'll sort them by their sent timestamps and print them in order. -/// Load credentials from a file and connect to the database. -fn connect_to_db() -> DbConnection { - let credentials = File::new(CREDS_NAME); - let conn = DbConnection::builder() - .on_connect(on_connected) - .on_connect_error(on_connect_error) - .on_disconnect(on_disconnected) - .with_uri(SPACETIMEDB_URI) - .with_module_name(DB_NAME) - .with_token(credentials.load().unwrap()) - .build().expect("Failed to connect"); - conn.run_threaded(); - conn +We'll handle this in our function `print_messages_in_order`, which we registered as an `on_applied` callback. `print_messages_in_order` iterates over all the `Message`s we've received, sorts them, and then prints them. `ctx.db.message().iter()` is defined on the trait `Table`, and returns an iterator over all the messages in the client cache. Rust iterators can't be sorted in-place, so we'll collect it to a `Vec`, then use the `sort_by_key` method to sort by timestamp. + +To `client/src/main.rs`, add: + +```rust +/// Our `on_subscription_applied` callback: +/// sort all past messages and print them in timestamp order. +fn on_sub_applied(ctx: &SubscriptionEventContext) { + let mut messages = ctx.db.message().iter().collect::>(); + messages.sort_by_key(|m| m.sent); + for message in messages { + print_message(ctx, &message); + } + println!("Fully connected and all subscriptions applied."); + println!("Use /name to set your name, or type a message!"); } ``` -## Subscribe to queries +### Notify about failed subscriptions -SpacetimeDB is set up so that each client subscribes via SQL queries to some subset of the database, and is notified about changes only to that subset. For complex apps with large databases, judicious subscriptions can save each client significant network bandwidth, memory and computation compared. For example, in [BitCraft](https://bitcraftonline.com), each player's client subscribes only to the entities in the "chunk" of the world where that player currently resides, rather than the entire game world. Our app is much simpler than BitCraft, so we'll just subscribe to the whole database. +It's possible for SpacetimeDB to reject subscriptions. This happens most often because of a typo in the SQL queries, but can be due to use of SQL features that SpacetimeDB doesn't support. See [SQL Support: Subscriptions](/docs/sql#subscriptions) for more information about what subscription queries SpacetimeDB supports. -To `client/src/main.rs`, add: +In our case, we're pretty confident that our queries are valid, but if SpacetimeDB rejects them, we want to know about it. Our callback will print the error, then exit the process. ```rust -/// Register subscriptions for all rows of both tables. -fn subscribe_to_tables(conn: &DbConnection) { - conn.subscription_builder().subscribe([ - "SELECT * FROM user;", - "SELECT * FROM message;", - ]); +/// Or `on_error` callback: +/// print the error, then exit the process. +fn on_sub_error(_ctx: &ErrorContext, err: Error) { + eprintln!("Subscription failed: {}", err); + std::process::exit(1); } ``` @@ -403,21 +439,21 @@ fn subscribe_to_tables(conn: &DbConnection) { Our app should allow the user to interact by typing lines into their terminal. If the line starts with `/name `, we'll change the user's name. Any other line will send a message. -The functions `set_name` and `send_message` are generated from the server module via `spacetime generate`. We pass them a `String`, which gets sent to the server to execute the corresponding reducer. +For each reducer defined by our module, `ctx.reducers` has a method to request an invocation. In our case, we pass `set_name` and `send_message` a `String`, which gets sent to the server to execute the corresponding reducer. To `client/src/main.rs`, add: ```rust /// Read each line of standard input, and either set our name or send a message as appropriate. -fn user_input_loop(conn: &DbConnection) { +fn user_input_loop(ctx: &DbConnection) { 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 ") { - conn.reducers.set_name(name.to_string()).unwrap(); + ctx.reducers.set_name(name.to_string()).unwrap(); } else { - conn.reducers.send_message(line).unwrap(); + ctx.reducers.send_message(line).unwrap(); } } } @@ -466,9 +502,9 @@ User connected. ## What's next? -You can find the full code for this client [in the Rust SDK's examples](https://github.com/clockworklabs/SpacetimeDB/tree/master/crates/sdk/examples/quickstart-chat). +You can find the full code for this client [in the Rust client SDK's examples](https://github.com/clockworklabs/SpacetimeDB/tree/master/crates/sdk/examples/quickstart-chat). -Check out the [Rust SDK Reference](/docs/sdks/rust) for a more comprehensive view of the SpacetimeDB Rust SDK. +Check out the [Rust client SDK Reference](/docs/sdks/rust) for a more comprehensive view of the SpacetimeDB Rust client SDK. Our basic terminal interface has some limitations. Incoming messages can appear while the user is typing, which is less than ideal. Additionally, the user's input gets mixed with the program's output, making messages the user sends appear twice. You might want to try improving the interface by using [Rustyline](https://crates.io/crates/rustyline), [Cursive](https://crates.io/crates/cursive), or even creating a full-fledged GUI. From 469c355ac28b8ec819d6360c012b9df8ca896028 Mon Sep 17 00:00:00 2001 From: Noa Date: Fri, 28 Feb 2025 13:32:12 -0600 Subject: [PATCH 112/164] Add docs for standalone config.toml (#190) * Add docs for standalone config.toml * Update docs/cli-reference/standalone-config.md Co-authored-by: Phoebe Goldman * pre formatting --------- Co-authored-by: Phoebe Goldman --- docs/cli-reference/standalone-config.md | 44 +++++++++++++++++++++++++ docs/nav.js | 1 + nav.ts | 5 +++ 3 files changed, 50 insertions(+) create mode 100644 docs/cli-reference/standalone-config.md diff --git a/docs/cli-reference/standalone-config.md b/docs/cli-reference/standalone-config.md new file mode 100644 index 00000000..0ce6350d --- /dev/null +++ b/docs/cli-reference/standalone-config.md @@ -0,0 +1,44 @@ +# `spacetimedb-standalone` configuration + +A local database instance (as started by `spacetime start`) can be configured in `{data-dir}/config.toml`, where `{data-dir}` is the database's data directory. This directory is printed when you run `spacetime start`: + + +
spacetimedb-standalone version: 1.0.0
+spacetimedb-standalone path: /home/user/.local/share/spacetime/bin/1.0.0/spacetimedb-standalone
+database running in data directory /home/user/.local/share/spacetime/data
+ +On Linux and macOS, this directory is by default `~/.local/share/spacetime/data`. On Windows, it's `%LOCALAPPDATA%\SpacetimeDB\data`. + +## `config.toml` + +- [`certificate-authority`](#certificate-authority) +- [`logs`](#logs) + +### `certificate-authority` + +```toml +[certificate-authority] +jwt-priv-key-path = "/path/to/id_ecdsas" +jwt-pub-key-path = "/path/to/id_ecdsas.pub" +``` + +The `certificate-authority` table lets you configure the public and private keys used by the database to sign tokens. + +### `logs` + +```toml +[logs] +level = "error" +directives = [ + "spacetimedb=warn", + "spacetimedb_standalone=info", +] +``` + +#### `logs.level` + +Can be one of `"error"`, `"warn"`, `"info"`, `"debug"`, `"trace"`, or `"off"`, case-insensitive. Only log messages of the specified level or higher will be output; e.g. if set to `warn`, only `error` and `warn`-level messages will be logged. + +#### `logs.directives` + +A list of filtering directives controlling what messages get logged, which overwrite the global [`logs.level`](#logslevel). See [`tracing documentation`](https://docs.rs/tracing-subscriber/0.3/tracing_subscriber/filter/struct.EnvFilter.html#directives) for syntax. Note that this is primarily intended as a debugging tool, and log message fields and targets are not considered stable. diff --git a/docs/nav.js b/docs/nav.js index 1c73dba7..bed99376 100644 --- a/docs/nav.js +++ b/docs/nav.js @@ -17,6 +17,7 @@ const nav = { page('4 - Moving and Colliding', 'unity/part-4', 'unity/part-4.md'), section('CLI Reference'), page('CLI Reference', 'cli-reference', 'cli-reference.md'), + page('SpacetimeDB Standalone Configuration', 'cli-reference/standalone-config', 'cli-reference/standalone-config.md'), section('Server Module Languages'), page('Overview', 'modules', 'modules/index.md'), page('Rust Quickstart', 'modules/rust/quickstart', 'modules/rust/quickstart.md'), diff --git a/nav.ts b/nav.ts index 5093b9e3..0b0c1020 100644 --- a/nav.ts +++ b/nav.ts @@ -42,6 +42,11 @@ const nav: Nav = { section('CLI Reference'), page('CLI Reference', 'cli-reference', 'cli-reference.md'), + page( + 'SpacetimeDB Standalone Configuration', + 'cli-reference/standalone-config', + 'cli-reference/standalone-config.md' + ), section('Server Module Languages'), page('Overview', 'modules', 'modules/index.md'), From 2d6130a570e85ed8b59eaa4408ec55a53cf40f11 Mon Sep 17 00:00:00 2001 From: Noa Date: Fri, 28 Feb 2025 13:32:33 -0600 Subject: [PATCH 113/164] Update docs for http api (#188) * Update docs for http api * Apply suggestions from code review Co-authored-by: Phoebe Goldman * Remove energy page --------- Co-authored-by: Phoebe Goldman --- docs/http/database.md | 612 ++++++++++++++++++------------------------ docs/http/energy.md | 35 --- docs/http/identity.md | 72 ++--- docs/http/index.md | 37 +-- docs/nav.js | 1 - docs/sats-json.md | 2 +- nav.ts | 1 - 7 files changed, 292 insertions(+), 468 deletions(-) delete mode 100644 docs/http/energy.md diff --git a/docs/http/database.md b/docs/http/database.md index 749bcefb..8a73759c 100644 --- a/docs/http/database.md +++ b/docs/http/database.md @@ -1,496 +1,406 @@ -# `/database` HTTP API +# `/v1/database` HTTP API -The HTTP endpoints in `/database` allow clients to interact with Spacetime databases in a variety of ways, including retrieving information, creating and deleting databases, invoking reducers and evaluating SQL queries. +The HTTP endpoints in `/v1/database` allow clients to interact with Spacetime databases in a variety of ways, including retrieving information, creating and deleting databases, invoking reducers and evaluating SQL queries. ## At a glance -| Route | Description | -| ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | -| [`/database/dns/:name GET`](#databasednsname-get) | Look up a database's address by its name. | -| [`/database/reverse_dns/:address GET`](#databasereverse_dnsaddress-get) | Look up a database's name by its address. | -| [`/database/set_name GET`](#databaseset_name-get) | Set a database's name, given its address. | -| [`/database/ping GET`](#databaseping-get) | No-op. Used to determine whether a client can connect. | -| [`/database/register_tld GET`](#databaseregister_tld-get) | Register a top-level domain. | -| [`/database/publish POST`](#databasepublish-post) | Publish a database given its module code. | -| [`/database/delete/:address POST`](#databasedeleteaddress-post) | Delete a database. | -| [`/database/subscribe/:name_or_address GET`](#databasesubscribename_or_address-get) | Begin a WebSocket connection. | -| [`/database/call/:name_or_address/:reducer POST`](#databasecallname_or_addressreducer-post) | Invoke a reducer in a database. | -| [`/database/schema/:name_or_address GET`](#databaseschemaname_or_address-get) | Get the schema for a database. | -| [`/database/schema/:name_or_address/:entity_type/:entity GET`](#databaseschemaname_or_addressentity_typeentity-get) | Get a schema for a particular table or reducer. | -| [`/database/info/:name_or_address GET`](#databaseinfoname_or_address-get) | Get a JSON description of a database. | -| [`/database/logs/:name_or_address GET`](#databaselogsname_or_address-get) | Retrieve logs from a database. | -| [`/database/sql/:name_or_address POST`](#databasesqlname_or_address-post) | Run a SQL query against a database. | - -## `/database/dns/:name GET` - -Look up a database's address by its name. +| Route | Description | +| -------------------------------------------------------------------------------------------------- | ------------------------------------------------- | +| [`POST /v1/database`](#post-v1database) | Publish a new database given its module code. | +| [`POST /v1/database/:name_or_identity`](#post-v1databasename_or_identity) | Publish to a database given its module code. | +| [`GET /v1/database/:name_or_identity`](#get-v1databasename_or_identity) | Get a JSON description of a database. | +| [`DELETE /v1/database/:name_or_identity`](#post-v1databasename_or_identity) | Delete a database. | +| [`GET /v1/database/:name_or_identity/names`](#get-v1databasename_or_identitynames) | Get the names this database can be identified by. | +| [`POST /v1/database/:name_or_identity/names`](#post-v1databasename_or_identitynames) | Add a new name for this database. | +| [`PUT /v1/database/:name_or_identity/names`](#put-v1databasename_or_identitynames) | Set the list of names for this database. | +| [`GET /v1/database/:name_or_identity/identity`](#get-v1databasename_or_identityidentity) | Get the identity of a database. | +| [`GET /v1/database/:name_or_identity/subscribe`](#get-v1databasename_or_identitysubscribe) | Begin a WebSocket connection. | +| [`POST /v1/database/:name_or_identity/call/:reducer`](#post-v1databasename_or_identitycallreducer) | Invoke a reducer in a database. | +| [`GET /v1/database/:name_or_identity/schema`](#get-v1databasename_or_identityschema) | Get the schema for a database. | +| [`GET /v1/database/:name_or_identity/logs`](#get-v1databasename_or_identitylogs) | Retrieve logs from a database. | +| [`POST /v1/database/:name_or_identity/sql`](#post-v1databasename_or_identitysql) | Run a SQL query against a database. | + +## `POST /v1/database` + +Publish a new database with no name. -Accessible through the CLI as `spacetime dns lookup `. - -#### Parameters - -| Name | Value | -| ------- | ------------------------- | -| `:name` | The name of the database. | - -#### Returns - -If a database with that name exists, returns JSON in the form: - -```typescript -{ "Success": { - "domain": string, - "address": string -} } -``` - -If no database with that name exists, returns JSON in the form: - -```typescript -{ "Failure": { - "domain": string -} } -``` - -## `/database/reverse_dns/:address GET` +Accessible through the CLI as `spacetime publish`. -Look up a database's name by its address. +#### Required Headers -Accessible through the CLI as `spacetime dns reverse-lookup
`. +| Name | Value | +| --------------- | --------------------------------------------------------------------- | +| `Authorization` | A Spacetime token [as Bearer auth](/docs/http#authorization-headers). | -#### Parameters +#### Data -| Name | Value | -| ---------- | ---------------------------- | -| `:address` | The address of the database. | +A WebAssembly module in the [binary format](https://webassembly.github.io/spec/core/binary/index.html). #### Returns -Returns JSON in the form: +If the database was successfully published, returns JSON in the form: ```typescript -{ "names": array } +{ "Success": { + "database_identity": string, + "op": "created" | "updated" +} } ``` -where `` is a JSON array of strings, each of which is a name which refers to the database. - -## `/database/set_name GET` +## `POST /v1/database/:name_or_identity` -Set the name associated with a database. +Publish to a database with the specified name or identity. If the name doesn't exist, creates a new database. -Accessible through the CLI as `spacetime dns set-name
`. +Accessible through the CLI as `spacetime publish`. #### Query Parameters -| Name | Value | -| -------------- | ------------------------------------------------------------------------- | -| `address` | The address of the database to be named. | -| `domain` | The name to register. | -| `register_tld` | A boolean; whether to register the name as a TLD. Should usually be true. | +| Name | Value | +| ------- | --------------------------------------------------------------------------------- | +| `clear` | A boolean; whether to clear any existing data when updating an existing database. | #### Required Headers -| Name | Value | -| --------------- | --------------------------------------------------------------- | -| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http). | +| Name | Value | +| --------------- | --------------------------------------------------------------------- | +| `Authorization` | A Spacetime token [as Bearer auth](/docs/http#authorization-headers). | + +#### Data + +A WebAssembly module in the [binary format](https://webassembly.github.io/spec/core/binary/index.html). #### Returns -If the name was successfully set, returns JSON in the form: +If the database was successfully published, returns JSON in the form: ```typescript { "Success": { - "domain": string, - "address": string + "domain": null | string, + "database_identity": string, + "op": "created" | "updated" } } ``` -If the top-level domain is not registered, and `register_tld` was not specified, returns JSON in the form: +If a database with the given name exists, but the identity provided in the `Authorization` header does not have permission to edit it, returns `401 UNAUTHORIZED` along with JSON in the form: ```typescript -{ "TldNotRegistered": { - "domain": string +{ "PermissionDenied": { + "name": string } } ``` -If the top-level domain is registered, but the identity provided in the `Authorization` header does not have permission to insert into it, returns JSON in the form: +## `GET /v1/database/:name_or_identity` -```typescript -{ "PermissionDenied": { - "domain": string -} } -``` +Get a database's identity, owner identity, host type, number of replicas and a hash of its WASM module. -## `/database/ping GET` +#### Returns -Does nothing and returns no data. Clients can send requests to this endpoint to determine whether they are able to connect to SpacetimeDB. +Returns JSON in the form: -## `/database/register_tld GET` +```typescript +{ + "database_identity": string, + "owner_identity": string, + "host_type": "wasm", + "initial_program": string +} +``` -Register a new Spacetime top-level domain. A TLD is the part of a database name before the first `/`. For example, in the name `tyler/bitcraft`, the TLD is `tyler`. Each top-level domain is owned by at most one identity, and only the owner can publish databases with that TLD. +| Field | Type | Meaning | +| --------------------- | ------ | ---------------------------------------------------------------- | +| `"database_identity"` | String | The Spacetime identity of the database. | +| `"owner_identity"` | String | The Spacetime identity of the database's owner. | +| `"host_type"` | String | The module host type; currently always `"wasm"`. | +| `"initial_program"` | String | Hash of the WASM module with which the database was initialized. | -Accessible through the CLI as `spacetime dns register-tld `. +## `DELETE /v1/database/:name_or_identity` -#### Query Parameters +Delete a database. -| Name | Value | -| ----- | -------------------------------------- | -| `tld` | New top-level domain name to register. | +Accessible through the CLI as `spacetime delete `. #### Required Headers -| Name | Value | -| --------------- | --------------------------------------------------------------- | -| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http). | +| Name | Value | +| --------------- | --------------------------------------------------------------------- | +| `Authorization` | A Spacetime token [as Bearer auth](/docs/http#authorization-headers). | -#### Returns +## `GET /v1/database/:name_or_identity/names` -If the domain is successfully registered, returns JSON in the form: - -```typescript -{ "Success": { - "domain": string -} } -``` +Get the names this datbase can be identified by. -If the domain is already registered to the caller, returns JSON in the form: +Accessible through the CLI as `spacetime dns reverse-lookup `. -```typescript -{ "AlreadyRegistered": { - "domain": string -} } -``` +#### Returns -If the domain is already registered to another identity, returns JSON in the form: +Returns JSON in the form: ```typescript -{ "Unauthorized": { - "domain": string -} } +{ "names": array } ``` -## `/database/publish POST` - -Publish a database. - -Accessible through the CLI as `spacetime publish`. +where `` is a JSON array of strings, each of which is a name which refers to the database. -#### Query Parameters +## `POST /v1/database/:name_or_identity/names` -| Name | Value | -| ----------------- | ------------------------------------------------------------------------------------------------ | -| `host_type` | Optional; a SpacetimeDB module host type. Currently, only `"wasmer"` is supported. | -| `clear` | A boolean; whether to clear any existing data when updating an existing database. | -| `name_or_address` | The name of the database to publish or update, or the address of an existing database to update. | -| `register_tld` | A boolean; whether to register the database's top-level domain. | +Add a new name for this database. #### Required Headers -| Name | Value | -| --------------- | --------------------------------------------------------------- | -| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http). | +| Name | Value | +| --------------- | --------------------------------------------------------------------- | +| `Authorization` | A Spacetime token [as Bearer auth](/docs/http#authorization-headers). | #### Data -A WebAssembly module in the [binary format](https://webassembly.github.io/spec/core/binary/index.html). +Takes as the request body a string containing the new name of the database. #### Returns -If the database was successfully published, returns JSON in the form: +If the name was successfully set, returns JSON in the form: ```typescript { "Success": { - "domain": null | string, - "address": string, - "op": "created" | "updated" + "domain": string, + "database_result": string } } ``` -If the top-level domain for the requested name is not registered, returns JSON in the form: +If the new name already exists but the identity provided in the `Authorization` header does not have permission to edit it, returns JSON in the form: ```typescript -{ "TldNotRegistered": { +{ "PermissionDenied": { "domain": string } } ``` -If the top-level domain for the requested name is registered, but the identity provided in the `Authorization` header does not have permission to insert into it, returns JSON in the form: +## `PUT /v1/database/:name_or_identity/names` + +Set the list of names for this database. + +#### Required Headers + +| Name | Value | +| --------------- | --------------------------------------------------------------------- | +| `Authorization` | A Spacetime token [as Bearer auth](/docs/http#authorization-headers). | + +#### Data + +Takes as the request body a list of names, as a JSON array of strings. + +#### Returns + +If the name was successfully set, returns JSON in the form: ```typescript -{ "PermissionDenied": { - "domain": string -} } +{ "Success": null } ``` -## `/database/delete/:address POST` +If any of the new names already exist but the identity provided in the `Authorization` header does not have permission to edit it, returns `401 UNAUTHORIZED` along with JSON in the form: -Delete a database. +```typescript +{ "PermissionDenied": null } +``` -Accessible through the CLI as `spacetime delete
`. +## `GET /v1/database/:name_or_identity/identity` -#### Parameters +Get the identity of a database. -| Name | Address | -| ---------- | ---------------------------- | -| `:address` | The address of the database. | +Accessible through the CLI as `spacetime dns lookup `. -#### Required Headers +#### Returns -| Name | Value | -| --------------- | --------------------------------------------------------------- | -| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http). | +Returns a hex string of the specified database's identity. -## `/database/subscribe/:name_or_address GET` +## `GET /v1/database/:name_or_identity/subscribe` Begin a WebSocket connection with a database. -#### Parameters - -| Name | Value | -| ------------------ | ---------------------------- | -| `:name_or_address` | The address of the database. | - #### Required Headers For more information about WebSocket headers, see [RFC 6455](https://datatracker.ietf.org/doc/html/rfc6455). -| Name | Value | -| ------------------------ | ---------------------------------------------------------------------------------------------------- | -| `Sec-WebSocket-Protocol` | `v1.bin.spacetimedb` or `v1.text.spacetimedb` | -| `Connection` | `Updgrade` | -| `Upgrade` | `websocket` | -| `Sec-WebSocket-Version` | `13` | -| `Sec-WebSocket-Key` | A 16-byte value, generated randomly by the client, encoded as Base64. | +| Name | Value | +| ------------------------ | --------------------------------------------------------------------- | +| `Sec-WebSocket-Protocol` | `v1.bsatn.spacetimedb` or `v1.json.spacetimedb` | +| `Connection` | `Updgrade` | +| `Upgrade` | `websocket` | +| `Sec-WebSocket-Version` | `13` | +| `Sec-WebSocket-Key` | A 16-byte value, generated randomly by the client, encoded as Base64. | -The SpacetimeDB binary WebSocket protocol, `v1.bin.spacetimedb`, encodes messages as well as reducer and row data using [BSATN](/docs/bsatn). +The SpacetimeDB binary WebSocket protocol, `v1.bsatn.spacetimedb`, encodes messages as well as reducer and row data using [BSATN](/docs/bsatn). Its messages are defined [here](https://github.com/clockworklabs/SpacetimeDB/blob/master/crates/client-api-messages/src/websocket.rs). -The SpacetimeDB text WebSocket protocol, `v1.text.spacetimedb`, encodes messages according to the [SATS-JSON format](/docs/sats-json). +The SpacetimeDB text WebSocket protocol, `v1.json.spacetimedb`, encodes messages according to the [SATS-JSON format](/docs/sats-json). #### Optional Headers -| Name | Value | -| --------------- | --------------------------------------------------------------- | -| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http). | +| Name | Value | +| --------------- | --------------------------------------------------------------------- | +| `Authorization` | A Spacetime token [as Bearer auth](/docs/http#authorization-headers). | -## `/database/call/:name_or_address/:reducer POST` +## `POST /v1/database/:name_or_identity/call/:reducer` Invoke a reducer in a database. -#### Parameters +#### Path parameters -| Name | Value | -| ------------------ | ------------------------------------ | -| `:name_or_address` | The name or address of the database. | -| `:reducer` | The name of the reducer. | +| Name | Value | +| ---------- | ------------------------ | +| `:reducer` | The name of the reducer. | #### Required Headers -| Name | Value | -| --------------- | --------------------------------------------------------------- | -| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http). | +| Name | Value | +| --------------- | --------------------------------------------------------------------- | +| `Authorization` | A Spacetime token [as Bearer auth](/docs/http#authorization-headers). | #### Data A JSON array of arguments to the reducer. -## `/database/schema/:name_or_address GET` +## `GET /v1/database/:name_or_identity/schema` Get a schema for a database. -Accessible through the CLI as `spacetime describe `. - -#### Parameters - -| Name | Value | -| ------------------ | ------------------------------------ | -| `:name_or_address` | The name or address of the database. | +Accessible through the CLI as `spacetime describe `. #### Query Parameters -| Name | Value | -| -------- | ----------------------------------------------------------- | -| `expand` | A boolean; whether to include full schemas for each entity. | +| Name | Value | +| --------- | ------------------------------------------------ | +| `version` | The version of `RawModuleDef` to return, e.g. 9. | #### Returns -Returns a JSON object with two properties, `"entities"` and `"typespace"`. For example, on the default module generated by `spacetime init` with `expand=true`, returns: +Returns a `RawModuleDef` in JSON form. -```typescript +
+Example response from `/schema?version=9` for the default module generated by `spacetime init` + +```json { - "entities": { - "Person": { - "arity": 1, - "schema": { - "elements": [ - { - "algebraic_type": { - "Builtin": { + "typespace": { + "types": [ + { + "Product": { + "elements": [ + { + "name": { + "some": "name" + }, + "algebraic_type": { "String": [] } - }, - "name": { - "some": "name" } - } - ] + ] + } + } + ] + }, + "tables": [ + { + "name": "person", + "product_type_ref": 0, + "primary_key": [], + "indexes": [], + "constraints": [], + "sequences": [], + "schedule": { + "none": [] }, - "type": "table" - }, - "__init__": { - "arity": 0, - "schema": { - "elements": [], - "name": "__init__" + "table_type": { + "User": [] }, - "type": "reducer" - }, - "add": { - "arity": 1, - "schema": { + "table_access": { + "Private": [] + } + } + ], + "reducers": [ + { + "name": "add", + "params": { "elements": [ { - "algebraic_type": { - "Builtin": { - "String": [] - } - }, "name": { "some": "name" + }, + "algebraic_type": { + "String": [] } } - ], - "name": "add" + ] }, - "type": "reducer" + "lifecycle": { + "none": [] + } }, - "say_hello": { - "arity": 0, - "schema": { - "elements": [], - "name": "say_hello" + { + "name": "identity_connected", + "params": { + "elements": [] }, - "type": "reducer" - } - }, - "typespace": [ + "lifecycle": { + "some": { + "OnConnect": [] + } + } + }, { - "Product": { - "elements": [ - { - "algebraic_type": { - "Builtin": { - "String": [] - } - }, - "name": { - "some": "name" - } - } - ] + "name": "identity_disconnected", + "params": { + "elements": [] + }, + "lifecycle": { + "some": { + "OnDisconnect": [] + } + } + }, + { + "name": "init", + "params": { + "elements": [] + }, + "lifecycle": { + "some": { + "Init": [] + } + } + }, + { + "name": "say_hello", + "params": { + "elements": [] + }, + "lifecycle": { + "none": [] } } - ] -} -``` - -The `"entities"` will be an object whose keys are table and reducer names, and whose values are objects of the form: - -```typescript -{ - "arity": number, - "type": "table" | "reducer", - "schema"?: ProductType -} -``` - -| Entity field | Value | -| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `arity` | For tables, the number of colums; for reducers, the number of arguments. | -| `type` | For tables, `"table"`; for reducers, `"reducer"`. | -| `schema` | A [JSON-encoded `ProductType`](/docs/sats-json); for tables, the table schema; for reducers, the argument schema. Only present if `expand` is supplied and true. | - -The `"typespace"` will be a JSON array of [`AlgebraicType`s](/docs/sats-json) referenced by the module. This can be used to resolve `Ref` types within the schema; the type `{ "Ref": n }` refers to `response["typespace"][n]`. - -## `/database/schema/:name_or_address/:entity_type/:entity GET` - -Get a schema for a particular table or reducer in a database. - -Accessible through the CLI as `spacetime describe `. - -#### Parameters - -| Name | Value | -| ------------------ | ---------------------------------------------------------------- | -| `:name_or_address` | The name or address of the database. | -| `:entity_type` | `reducer` to describe a reducer, or `table` to describe a table. | -| `:entity` | The name of the reducer or table. | - -#### Query Parameters - -| Name | Value | -| -------- | ------------------------------------------------------------- | -| `expand` | A boolean; whether to include the full schema for the entity. | - -#### Returns - -Returns a single entity in the same format as in the `"entities"` returned by [the `/database/schema/:name_or_address GET` endpoint](#databaseschemaname_or_address-get): - -```typescript -{ - "arity": number, - "type": "table" | "reducer", - "schema"?: ProductType, -} -``` - -| Field | Value | -| -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `arity` | For tables, the number of colums; for reducers, the number of arguments. | -| `type` | For tables, `"table"`; for reducers, `"reducer"`. | -| `schema` | A [JSON-encoded `ProductType`](/docs/sats-json); for tables, the table schema; for reducers, the argument schema. Only present if `expand` is supplied and true. | - -## `/database/info/:name_or_address GET` - -Get a database's address, owner identity, host type, number of replicas and a hash of its WASM module. - -#### Parameters - -| Name | Value | -| ------------------ | ------------------------------------ | -| `:name_or_address` | The name or address of the database. | - -#### Returns - -Returns JSON in the form: - -```typescript -{ - "address": string, - "owner_identity": string, - "host_type": "wasm", - "initial_program": string + ], + "types": [ + { + "name": { + "scope": [], + "name": "Person" + }, + "ty": 0, + "custom_ordering": true + } + ], + "misc_exports": [], + "row_level_security": [] } ``` -| Field | Type | Meaning | -| ------------------- | ------ | ---------------------------------------------------------------- | -| `"address"` | String | The address of the database. | -| `"owner_identity"` | String | The Spacetime identity of the database's owner. | -| `"host_type"` | String | The module host type; currently always `"wasm"`. | -| `"initial_program"` | String | Hash of the WASM module with which the database was initialized. | +
-## `/database/logs/:name_or_address GET` +## `GET /v1/database/:name_or_identity/logs` Retrieve logs from a database. -Accessible through the CLI as `spacetime logs `. - -#### Parameters - -| Name | Value | -| ------------------ | ------------------------------------ | -| `:name_or_address` | The name or address of the database. | +Accessible through the CLI as `spacetime logs `. #### Query Parameters @@ -501,31 +411,25 @@ Accessible through the CLI as `spacetime logs `. #### Required Headers -| Name | Value | -| --------------- | --------------------------------------------------------------- | -| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http). | +| Name | Value | +| --------------- | --------------------------------------------------------------------- | +| `Authorization` | A Spacetime token [as Bearer auth](/docs/http#authorization-headers). | #### Returns Text, or streaming text if `follow` is supplied, containing log lines. -## `/database/sql/:name_or_address POST` +## `POST /v1/database/:name_or_identity/sql` Run a SQL query against a database. -Accessible through the CLI as `spacetime sql `. - -#### Parameters - -| Name | Value | -| ------------------ | --------------------------------------------- | -| `:name_or_address` | The name or address of the database to query. | +Accessible through the CLI as `spacetime sql `. #### Required Headers -| Name | Value | -| --------------- | --------------------------------------------------------------- | -| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http). | +| Name | Value | +| --------------- | --------------------------------------------------------------------- | +| `Authorization` | A Spacetime token [as Bearer auth](/docs/http#authorization-headers). | #### Data diff --git a/docs/http/energy.md b/docs/http/energy.md deleted file mode 100644 index fa035c83..00000000 --- a/docs/http/energy.md +++ /dev/null @@ -1,35 +0,0 @@ -# `/energy` HTTP API - -The HTTP endpoints in `/energy` allow clients to query identities' energy balances. Spacetime databases expend energy from their owners' balances while executing reducers. - -## At a glance - -| Route | Description | -| ------------------------------------------------ | --------------------------------------------------------- | -| [`/energy/:identity GET`](#energyidentity-get) | Get the remaining energy balance for the user `identity`. | - -## `/energy/:identity GET` - -Get the energy balance of an identity. - -Accessible through the CLI as [`spacetime energy balance`](/docs/cli-reference#spacetime-energy-balance). - -#### Parameters - -| Name | Value | -| ----------- | ----------------------- | -| `:identity` | The Spacetime identity. | - -#### Returns - -Returns JSON in the form: - -```typescript -{ - "balance": string -} -``` - -| Field | Value | -| --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `balance` | The identity's energy balance, as a decimal integer. Note that energy balances may be negative, and will frequently be too large to store in a 64-bit integer. | diff --git a/docs/http/identity.md b/docs/http/identity.md index 6f1e22c9..3cec4eb9 100644 --- a/docs/http/identity.md +++ b/docs/http/identity.md @@ -1,59 +1,23 @@ -# `/identity` HTTP API +# `/v1/identity` HTTP API -The HTTP endpoints in `/identity` allow clients to generate and manage Spacetime public identities and private tokens. +The HTTP endpoints in `/v1/identity` allow clients to generate and manage Spacetime public identities and private tokens. ## At a glance -| Route | Description | -| ----------------------------------------------------------------------- | ------------------------------------------------------------------ | -| [`/identity GET`](#identity-get) | Look up an identity by email. | -| [`/identity POST`](#identity-post) | Generate a new identity and token. | -| [`/identity/websocket_token POST`](#identitywebsocket_token-post) | Generate a short-lived access token for use in untrusted contexts. | -| [`/identity/:identity/set-email POST`](#identityidentityset-email-post) | Set the email for an identity. | -| [`/identity/:identity/databases GET`](#identityidentitydatabases-get) | List databases owned by an identity. | -| [`/identity/:identity/verify GET`](#identityidentityverify-get) | Verify an identity and token. | +| Route | Description | +| -------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| [`POST /v1/identity`](#post-v1identity) | Generate a new identity and token. | +| [`POST /v1/identity/websocket-token`](#post-v1identitywebsocket-token) | Generate a short-lived access token for use in untrusted contexts. | +| [`GET /v1/identity/public-key`](#get-v1identitypublic-key) | Get the public key used for verifying tokens. | +| [`GET /v1/identity/:identity/databases`](#get-v1identityidentitydatabases) | List databases owned by an identity. | +| [`GET /v1/identity/:identity/verify`](#get-v1identityidentityverify) | Verify an identity and token. | -## `/identity GET` - -Look up Spacetime identities associated with an email. - -Accessible through the CLI as `spacetime identity find `. - -#### Query Parameters - -| Name | Value | -| ------- | ------------------------------- | -| `email` | An email address to search for. | - -#### Returns - -Returns JSON in the form: - -```typescript -{ - "identities": [ - { - "identity": string, - "email": string - } - ] -} -``` - -The `identities` value is an array of zero or more objects, each of which has an `identity` and an `email`. Each `email` will be the same as the email passed as a query parameter. - -## `/identity POST` +## `POST /v1/identity` Create a new identity. Accessible through the CLI as `spacetime identity new`. -#### Query Parameters - -| Name | Value | -| ------- | ----------------------------------------------------------------------------------------------------------------------- | -| `email` | An email address to associate with the new identity. If unsupplied, the new identity will not have an associated email. | - #### Returns Returns JSON in the form: @@ -65,7 +29,7 @@ Returns JSON in the form: } ``` -## `/identity/websocket_token POST` +## `POST /v1/identity/websocket-token` Generate a short-lived access token which can be used in untrusted contexts, e.g. embedded in URLs. @@ -87,7 +51,15 @@ Returns JSON in the form: The `token` value is a short-lived [JSON Web Token](https://datatracker.ietf.org/doc/html/rfc7519). -## `/identity/:identity/set-email POST` +## `GET /v1/identity/public-key` + +Fetches the public key used by the database to verify tokens. + +#### Returns + +Returns a response of content-type `application/pem-certificate-chain`. + +## `POST /v1/identity/:identity/set-email` Associate an email with a Spacetime identity. @@ -111,7 +83,7 @@ Accessible through the CLI as `spacetime identity set-email `. | --------------- | --------------------------------------------------------------- | | `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http). | -## `/identity/:identity/databases GET` +## `GET /v1/identity/:identity/databases` List all databases owned by an identity. @@ -133,7 +105,7 @@ Returns JSON in the form: The `addresses` value is an array of zero or more strings, each of which is the address of a database owned by the identity passed as a parameter. -## `/identity/:identity/verify GET` +## `GET /v1/identity/:identity/verify` Verify the validity of an identity/token pair. diff --git a/docs/http/index.md b/docs/http/index.md index 3f790b10..64196fb6 100644 --- a/docs/http/index.md +++ b/docs/http/index.md @@ -6,37 +6,22 @@ Rather than a password, each Spacetime identity is associated with a private tok ### Generating identities and tokens -Clients can request a new identity and token via [the `/identity POST` HTTP endpoint](/docs/http/identity#identity-post). +SpacetimeDB can derive an identity from the `sub` and `iss` claims of any [OpenID Connect](https://openid.net/developers/how-connect-works/) compliant [JSON Web Token](https://jwt.io/). -Alternately, a new identity and token will be generated during an anonymous connection via the WebSocket API, and passed to the client as an `IdentityToken` message. - -### Encoding `Authorization` headers +Clients can request a new identity and token signed by the SpacetimeDB host via [the `POST /v1/identity` HTTP endpoint](/docs/http/identity#post-v1identity). Such a token will not be portable to other SpacetimeDB clusters. -Many SpacetimeDB HTTP endpoints either require or optionally accept a token in the `Authorization` header. SpacetimeDB authorization headers use `Basic` authorization with the username `token` and the token as the password. Because Spacetime tokens are not passwords, and SpacetimeDB Cloud uses TLS, usual security concerns about HTTP `Basic` authorization do not apply. +Alternately, a new identity and token will be generated during an anonymous connection via the WebSocket API, and passed to the client as an `IdentityToken` message. -To construct an appropriate `Authorization` header value for a `token`: +### `Authorization` headers -1. Prepend the string `token:`. -2. Base64-encode. -3. Prepend the string `Basic `. +Many SpacetimeDB HTTP endpoints either require or optionally accept a token in the `Authorization` header. SpacetimeDB authorization headers are of the form `Authorization: Bearer ${token}`, where `token` is an [OpenID Connect](https://openid.net/developers/how-connect-works/) compliant [JSON Web Token](https://jwt.io/), such as the one returned from [the `POST /v1/identity` HTTP endpoint](/docs/http/identity#post-v1identity). -#### Rust +# Top level routes -```rust -fn auth_header_value(token: &str) -> String { - let username_and_password = format!("token:{}", token); - let base64_encoded = base64::prelude::BASE64_STANDARD.encode(username_and_password); - format!("Basic {}", encoded) -} -``` +| Route | Description | +| ----------------------------- | ------------------------------------------------------ | +| [`GET /v1/ping`](#get-v1ping) | No-op. Used to determine whether a client can connect. | -#### C# +## `GET /v1/ping` -```csharp -public string AuthHeaderValue(string token) -{ - var username_and_password = Encoding.UTF8.GetBytes($"token:{auth}"); - var base64_encoded = Convert.ToBase64String(username_and_password); - return "Basic " + base64_encoded; -} -``` +Does nothing and returns no data. Clients can send requests to this endpoint to determine whether they are able to connect to SpacetimeDB. diff --git a/docs/nav.js b/docs/nav.js index bed99376..85697a68 100644 --- a/docs/nav.js +++ b/docs/nav.js @@ -38,7 +38,6 @@ const nav = { page('HTTP', 'http', 'http/index.md'), page('`/identity`', 'http/identity', 'http/identity.md'), page('`/database`', 'http/database', 'http/database.md'), - page('`/energy`', 'http/energy', 'http/energy.md'), section('Data Format'), page('SATS-JSON', 'sats-json', 'sats-json.md'), page('BSATN', 'bsatn', 'bsatn.md'), diff --git a/docs/sats-json.md b/docs/sats-json.md index d115bad4..38f08756 100644 --- a/docs/sats-json.md +++ b/docs/sats-json.md @@ -166,4 +166,4 @@ SATS array and map types are homogeneous, meaning that each array has a single e ### `AlgebraicTypeRef` -`AlgebraicTypeRef`s are JSON-encoded as non-negative integers. These are indices into a typespace, like the one returned by the [`/database/schema/:name_or_address GET` HTTP endpoint](/docs/http/database#databaseschemaname_or_address-get). +`AlgebraicTypeRef`s are JSON-encoded as non-negative integers. These are indices into a typespace, like the one returned by the [`GET /v1/database/:name_or_identity/schema` HTTP endpoint](/docs/http/database#get-v1databasename_or_identityschema). diff --git a/nav.ts b/nav.ts index 0b0c1020..2de94ab3 100644 --- a/nav.ts +++ b/nav.ts @@ -87,7 +87,6 @@ const nav: Nav = { page('HTTP', 'http', 'http/index.md'), page('`/identity`', 'http/identity', 'http/identity.md'), page('`/database`', 'http/database', 'http/database.md'), - page('`/energy`', 'http/energy', 'http/energy.md'), section('Data Format'), page('SATS-JSON', 'sats-json', 'sats-json.md'), From 0a71acb0c0043ced8eabf1fecf80f53b386bd1c0 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Mon, 3 Mar 2025 12:58:32 -0500 Subject: [PATCH 114/164] Update Rust SDK ref, and also a few small fixes (#172) * *Must* accept `ReducerContext`, not *may* * Small fixes to Rust docs for database Identity and rename Address * Update Rust SDK reference for various 1.0 API changes * Fix broken links * TOC and TODOs * Rename `Address` to `ConnectionId` in index, fix some links * Minor fixes I found while working through converting this to typescript * Link to SQL ref * Additional fixups found while rewriting TS ref * Remove references to BitCraftMini We no longer use this as an example. Also, I'm pretty sure we stopped using that name ages ago. * No UB from mixing `subscribe` and `subscribe_to_all_tables` Co-authored-by: joshua-spacetime * Update TypeScript SDK reference (#181) * Begin updating TypeScript SDK ref to match the new rust one * Link to SQL ref from `subscribe` method * Fill in the rest of the TypeScript SDK ref * Fix copy-paste error: `subscribeToAllTables` should be camelCase Co-authored-by: joshua-spacetime * Copy change from Rust SDK docs: no UB in `subscribeToAllTables` Co-authored-by: joshua-spacetime * Fix casing of `withModuleName` Co-authored-by: Tyler Cloutier * Address Tyler's review --------- Co-authored-by: joshua-spacetime Co-authored-by: Tyler Cloutier --------- Co-authored-by: joshua-spacetime Co-authored-by: Tyler Cloutier --- STYLE.md | 2 +- docs/index.md | 10 +- docs/modules/c-sharp/quickstart.md | 2 +- docs/modules/rust/quickstart.md | 14 +- docs/sdks/rust/index.md | 547 +++++++++++-- docs/sdks/typescript/index.md | 1191 +++++++++++++--------------- docs/sql/index.md | 4 +- 7 files changed, 1063 insertions(+), 707 deletions(-) diff --git a/STYLE.md b/STYLE.md index f0ff5e8c..4fe1f676 100644 --- a/STYLE.md +++ b/STYLE.md @@ -375,7 +375,7 @@ Start the conclusion with a sentence or paragraph that reminds the reader what t If this tutorial is part of a series, link to the next entry: -> You can use any of SpacetimDB's supported client languages to do this. Take a look at the quickstart guide for your client language of choice: [Rust](/docs/sdks/rust/quickstart), [C#](/docs/sdks/c-sharp/quickstart), or [TypeScript](/docs/sdks/typescript/quickstart). If you are planning to use SpacetimeDB with the Unity game engine, you can skip right to the [Unity Comprehensive Tutorial](/docs/unity/part-1) or check out our example game, [BitcraftMini](/docs/unity/part-3). +> You can use any of SpacetimDB's supported client languages to do this. Take a look at the quickstart guide for your client language of choice: [Rust](/docs/sdks/rust/quickstart), [C#](/docs/sdks/c-sharp/quickstart), or [TypeScript](/docs/sdks/typescript/quickstart). If you are planning to use SpacetimeDB with the Unity game engine, you can skip right to the [Unity Comprehensive Tutorial](/docs/unity/part-1). If this tutorial is about a specific component, link to its reference page: diff --git a/docs/index.md b/docs/index.md index e04e3055..9375f847 100644 --- a/docs/index.md +++ b/docs/index.md @@ -220,7 +220,7 @@ or at a specific time. ::: ### Client -A **client** is an application that connects to a [database](#database). A client logs in using an [identity](#identity) and receives an [address](#address) to identify the connection. After that, it can call [reducers](#reducer) and query public [tables](#table). +A **client** is an application that connects to a [database](#database). A client logs in using an [identity](#identity) and receives an [connection id](#connectionid) to identify the connection. After that, it can call [reducers](#reducer) and query public [tables](#table). Clients are written using the [client-side SDKs](#client-side-sdks). The `spacetime` CLI tool allows automatically generating code that works with the client-side SDKs to talk to a particular database. @@ -238,13 +238,11 @@ Identities are issued using the [OpenID Connect](https://openid.net/developers/h -### Address +### ConnectionId - +A `ConnectionId` identifies client connections to a SpacetimeDB module. -An `Address` identifies client connections to a SpacetimeDB module. - -A user has a single [`Identity`](#identity), but may open multiple connections to your module. Each of these will receive a unique `Address`. +A user has a single [`Identity`](#identity), but may open multiple connections to your module. Each of these will receive a unique `ConnectionId`. ### Energy **Energy** is the currency used to pay for data storage and compute operations in a SpacetimeDB host. diff --git a/docs/modules/c-sharp/quickstart.md b/docs/modules/c-sharp/quickstart.md index 5dcb703a..e0fbf33e 100644 --- a/docs/modules/c-sharp/quickstart.md +++ b/docs/modules/c-sharp/quickstart.md @@ -313,4 +313,4 @@ spacetime sql quickstart-chat "SELECT * FROM Message" You've just set up your first database in SpacetimeDB! The next step would be to create a client module that interacts with this module. You can use any of SpacetimDB's supported client languages to do this. Take a look at the quick start guide for your client language of choice: [Rust](/docs/sdks/rust/quickstart), [C#](/docs/sdks/c-sharp/quickstart), or [TypeScript](/docs/sdks/typescript/quickstart). -If you are planning to use SpacetimeDB with the Unity game engine, you can skip right to the [Unity Comprehensive Tutorial](/docs/unity/part-1) or check out our example game, [BitcraftMini](/docs/unity/part-3). +If you are planning to use SpacetimeDB with the Unity game engine, you can skip right to the [Unity Comprehensive Tutorial](/docs/unity/part-1). diff --git a/docs/modules/rust/quickstart.md b/docs/modules/rust/quickstart.md index 9fcfe30d..057b3ad8 100644 --- a/docs/modules/rust/quickstart.md +++ b/docs/modules/rust/quickstart.md @@ -100,7 +100,7 @@ pub struct Message { We want to allow users to set their names, because `Identity` is not a terribly user-friendly identifier. To that effect, we define a reducer `set_name` which clients can invoke to set their `User.name`. It will validate the caller's chosen name, using a function `validate_name` which we'll define next, then look up the `User` record for the caller and update it to store the validated name. If the name fails the validation, the reducer will fail. -Each reducer may accept as its first argument a `ReducerContext`, which includes the `Identity` and `Address` of the client that called the reducer, and the `Timestamp` when it was invoked. It also allows us access to the `db`, which is used to read and manipulate rows in our tables. For now, we only need the `db`, `Identity`, and `ctx.sender`. +Each reducer must accept as its first argument a `ReducerContext`, which includes the `Identity` and `ConnectionId` of the client that called the reducer, and the `Timestamp` when it was invoked. It also allows us access to the `db`, which is used to read and manipulate rows in our tables. For now, we only need the `db`, `Identity`, and `ctx.sender`. It's also possible to call `set_name` via the SpacetimeDB CLI's `spacetime call` command without a connection, in which case no `User` record will exist for the caller. We'll return an error in this case, but you could alter the reducer to insert a `User` row for the module owner. You'll have to decide whether the module owner is always online or always offline, though. @@ -227,12 +227,12 @@ pub fn identity_disconnected(ctx: &ReducerContext) { ## Publish the module -And that's all of our module code! We'll run `spacetime publish` to compile our module and publish it on SpacetimeDB. `spacetime publish` takes an optional name which will map to the database's unique address. Clients can connect either by name or by address, but names are much more user-friendly. Come up with a unique name that contains only URL-safe characters (letters, numbers, hyphens and underscores), and fill it in where we've written ``. +And that's all of our module code! We'll run `spacetime publish` to compile our module and publish it on SpacetimeDB. `spacetime publish` takes an optional name which will map to the database's unique `Identity`. Clients can connect either by name or by `Identity`, but names are much more user-friendly. If you'd like, come up with a unique name that contains only URL-safe characters (letters, numbers, hyphens and underscores), and fill it in where we've written `quickstart-chat`. From the `quickstart-chat` directory, run: ```bash -spacetime publish --project-path server +spacetime publish --project-path server quickstart-chat ``` ## Call Reducers @@ -240,13 +240,13 @@ spacetime publish --project-path server You can use the CLI (command line interface) to run reducers. The arguments to the reducer are passed in JSON format. ```bash -spacetime call send_message 'Hello, World!' +spacetime call quickstart-chat send_message 'Hello, World!' ``` Once we've called our `send_message` reducer, we can check to make sure it ran by running the `logs` command. ```bash -spacetime logs +spacetime logs quickstart-chat ``` You should now see the output that your module printed in the database. @@ -263,7 +263,7 @@ You should now see the output that your module printed in the database. SpacetimeDB supports a subset of the SQL syntax so that you can easily query the data of your database. We can run a query using the `sql` command. ```bash -spacetime sql "SELECT * FROM message" +spacetime sql quickstart-chat "SELECT * FROM message" ``` ```bash @@ -278,4 +278,4 @@ You can find the full code for this module [in the SpacetimeDB module examples]( You've just set up your first database in SpacetimeDB! The next step would be to create a client module that interacts with this module. You can use any of SpacetimDB's supported client languages to do this. Take a look at the quickstart guide for your client language of choice: [Rust](/docs/sdks/rust/quickstart), [C#](/docs/sdks/c-sharp/quickstart), or [TypeScript](/docs/sdks/typescript/quickstart). -If you are planning to use SpacetimeDB with the Unity game engine, you can skip right to the [Unity Comprehensive Tutorial](/docs/unity/part-1) or check out our example game, [BitcraftMini](/docs/unity/part-3). +If you are planning to use SpacetimeDB with the Unity game engine, you can skip right to the [Unity Comprehensive Tutorial](/docs/unity/part-1). diff --git a/docs/sdks/rust/index.md b/docs/sdks/rust/index.md index 71d40b5a..a6dd23bb 100644 --- a/docs/sdks/rust/index.md +++ b/docs/sdks/rust/index.md @@ -2,7 +2,21 @@ The SpacetimeDB client SDK for Rust contains all the tools you need to build native clients for SpacetimeDB modules using Rust. -## Install the SDK +| Name | Description | +|-------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------| +| [Project setup](#project-setup) | Configure a Rust crate to use the SpacetimeDB Rust client SDK. | +| [Generate module bindings](#generate-module-bindings) | Use the SpacetimeDB CLI to generate module-specific types and interfaces. | +| [`DbConnection` type](#type-dbconnection) | A connection to a remote database. | +| [`DbContext` trait](#trait-dbcontext) | Methods for interacting with the remote database. Implemented by [`DbConnection`](#type-dbconnection) and various event context types. | +| [`EventContext` type](#type-eventcontext) | [`DbContext`](#trait-dbcontext) available in [row callbacks](#callback-on_insert). | +| [`ReducerEventContext` type](#type-reducereventcontext) | [`DbContext`](#trait-dbcontext) available in [reducer callbacks](#observe-and-invoke-reducers). | +| [`SubscriptionEventContext` type](#type-subscriptioneventcontext) | [`DbContext`](#trait-dbcontext) available in [subscription-related callbacks](#subscribe-to-queries). | +| [`ErrorContext` type](#type-errorcontext) | [`DbContext`](#trait-dbcontext) available in error-related callbacks. | +| [Access the client cache](#access-the-client-cache) | Make local queries against subscribed rows, and register [row callbacks](#callback-on_insert) to run when subscribed rows change. | +| [Observe and invoke reducers](#observe-and-invoke-reducers) | Send requests to the database to run reducers, and register callbacks to run when notified of reducers. | +| [Identify a client](#identify-a-client) | Types for identifying users and client connections. | + +## Project setup First, create a new project using `cargo new` and add the SpacetimeDB SDK to your dependencies: @@ -37,7 +51,13 @@ module_bindings::DbConnection A connection to a remote database is represented by the `module_bindings::DbConnection` type. This type is generated per-module, and contains information about the types, tables and reducers defined by your module. -### Connect to a module - `DbConnection::builder()` and `.build()` +| Name | Description | +|------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------| +| [Connect to a module](#connect-to-a-module) | Construct a `DbConnection`. | +| [Advance the connection](#advance-the-connection-and-process-messages) | Poll the `DbConnection`, or set up a background worker to run it. | +| [Access tables and reducers](#access-tables-and-reducers) | Access subscribed rows in the client cache, request reducer invocations, and register callbacks. | + +### Connect to a module ```rust impl DbConnection { @@ -45,7 +65,17 @@ impl DbConnection { } ``` -Construct a `DbConnection` by calling `DbConnection::builder()` and chaining configuration methods, then calling `.build()`. You must at least specify `with_uri`, to supply the URI of the SpacetimeDB to which you published your module, and `with_module_name`, to supply the human-readable SpacetimeDB domain name or the raw address which identifies the module. +Construct a `DbConnection` by calling `DbConnection::builder()` and chaining configuration methods, then calling `.build()`. You must at least specify `with_uri`, to supply the URI of the SpacetimeDB to which you published your module, and `with_module_name`, to supply the human-readable SpacetimeDB domain name or the raw `Identity` which identifies the module. + +| Name | Description | +|-----------------------------------------------------------|--------------------------------------------------------------------------------------| +| [`with_uri` method](#method-with_uri) | Set the URI of the SpacetimeDB instance which hosts the remote database. | +| [`with_module_name` method](#method-with_module_name) | Set the name or `Identity` of the remote database. | +| [`on_connect` callback](#callback-on_connect) | Register a callback to run when the connection is successfully established. | +| [`on_connect_error` callback](#callback-on_connect_error) | Register a callback to run if the connection is rejected or the host is unreachable. | +| [`on_disconnect` callback](#callback-on_disconnect) | Register a callback to run when the connection ends. | +| [`with_token` method](#method-with_token) | Supply a token to authenticate with the remote database. | +| [`build` method](#method-build) | Finalize configuration and connect. | #### Method `with_uri` @@ -61,11 +91,11 @@ Configure the URI of the SpacetimeDB instance or cluster which hosts the remote ```rust impl DbConnectionBuilder { - fn with_module_name(self, name_or_address: impl ToString) -> Self; + fn with_module_name(self, name_or_identity: impl ToString) -> Self; } ``` -Configure the SpacetimeDB domain name or address of the remote module which identifies it within the SpacetimeDB instance or cluster. +Configure the SpacetimeDB domain name or `Identity` of the remote module which identifies it within the SpacetimeDB instance or cluster. #### Callback `on_connect` @@ -81,17 +111,31 @@ This interface may change in an upcoming release as we rework SpacetimeDB's auth #### Callback `on_connect_error` -Currently unused. +```rust +impl DbConnectionBuilder { + fn on_connect_error( + self, + callback: impl FnOnce(&ErrorContext, spacetimedb_sdk::Error), + ) -> DbConnectionBuilder; +} +``` + +Chain a call to `.on_connect_error(callback)` to your builder to register a callback to run when your connection fails. + +A known bug in the SpacetimeDB Rust client SDK currently causes this callback never to be invoked. [`on_disconnect`](#callback-on_disconnect) callbacks are invoked instead. #### Callback `on_disconnect` ```rust impl DbConnectionBuilder { - fn on_disconnect(self, callback: impl FnOnce(&DbConnection, Option<&anyhow::Error>)) -> DbConnectionBuilder; + fn on_disconnect( + self, + callback: impl FnOnce(&ErrorContext, Option), + ) -> DbConnectionBuilder; } ``` -Chain a call to `.on_connect(callback)` to your builder to register a callback to run when your `DbConnection` disconnects from the remote module, either as a result of a call to [`disconnect`](#method-disconnect) or due to an error. +Chain a call to `.on_disconnect(callback)` to your builder to register a callback to run when your `DbConnection` disconnects from the remote module, either as a result of a call to [`disconnect`](#method-disconnect) or due to an error. #### Method `with_token` @@ -103,13 +147,12 @@ impl DbConnectionBuilder { Chain a call to `.with_token(token)` to your builder to provide an OpenID Connect compliant JSON Web Token to authenticate with, or to explicitly select an anonymous connection. If this method is not called or `None` is passed, SpacetimeDB will generate a new `Identity` and sign a new private access token for the connection. -This interface may change in an upcoming release as we rework SpacetimeDB's authentication model. #### Method `build` ```rust impl DbConnectionBuilder { - fn build(self) -> anyhow::Result; + fn build(self) -> Result; } ``` @@ -119,7 +162,13 @@ After configuring the connection and registering callbacks, attempt to open the In the interest of supporting a wide variety of client applications with different execution strategies, the SpacetimeDB SDK allows you to choose when the `DbConnection` spends compute time and processes messages. If you do not arrange for the connection to advance by calling one of these methods, the `DbConnection` will never advance, and no callbacks will ever be invoked. -#### Run in the background - method `run_threaded` +| Name | Description | +|-----------------------------------------------|-------------------------------------------------------| +| [`run_threaded` method](#method-run_threaded) | Spawn a thread to process messages in the background. | +| [`run_async` method](#method-run_async) | Process messages in an async task. | +| [`frame_tick` method](#method-frame_tick) | Process messages on the main thread without blocking. | + +#### Method `run_threaded` ```rust impl DbConnection { @@ -129,45 +178,150 @@ impl DbConnection { `run_threaded` spawns a thread which will continuously advance the connection, sleeping when there is no work to do. The thread will panic if the connection disconnects erroneously, or return if it disconnects as a result of a call to [`disconnect`](#method-disconnect). -#### Run asynchronously - method `run_async` +#### Method `run_async` ```rust impl DbConnection { - async fn run_async(&self) -> anyhow::Result<()>; + async fn run_async(&self) -> Result<(), spacetimedb_sdk::Error>; } ``` `run_async` will continuously advance the connection, `await`-ing when there is no work to do. The task will return an `Err` if the connection disconnects erroneously, or return `Ok(())` if it disconnects as a result of a call to [`disconnect`](#method-disconnect). -#### Run on the main thread without blocking - method `frame_tick` +#### Method `frame_tick` ```rust impl DbConnection { - fn frame_tick(&self) -> anyhow::Result<()>; + fn frame_tick(&self) -> Result<(), spacetimedb_sdk::Error>; } ``` `frame_tick` will advance the connection until no work remains, then return rather than blocking or `await`-ing. Games might arrange for this message to be called every frame. `frame_tick` returns `Ok` if the connection remains active afterwards, or `Err` if the connection disconnected before or during the call. +### Access tables and reducers + +#### Field `db` + +```rust +struct DbConnection { + pub db: RemoteTables, + /* other members */ +} +``` + +The `db` field of the `DbConnection` provides access to the subscribed view of the remote database's tables. See [Access the client cache](#access-the-client-cache). + +#### Field `reducers` + +```rust +struct DbConnection { + pub reducers: RemoteReducers, + /* other members */ +} +``` + +The `reducers` field of the `DbConnection` provides access to reducers exposed by the remote module. See [Observe and invoke reducers](#observe-and-invoke-reducers). + ## Trait `DbContext` -[`DbConnection`](#type-dbconnection) and [`EventContext`](#type-eventcontext) both implement `DbContext`, which allows +```rust +trait spacetimedb_sdk::DbContext { + /* methods */ +} +``` + +[`DbConnection`](#type-dbconnection), [`EventContext`](#type-eventcontext), [`ReducerEventContext`](#type-reducereventcontext), [`SubscriptionEventContext`](#type-subscriptioneventcontext) and [`ErrorContext`](#type-errorcontext) all implement `DbContext`. `DbContext` has methods for inspecting and configuring your connection to the remote database, including [`ctx.db()`](#method-db), a trait-generic alternative to reading the `.db` property on a concrete-typed context object. + +The `DbContext` trait is implemented by connections and contexts to *every* module. This means that its [`DbView`](#method-db) and [`Reducers`](#method-reducers) are associated types. + +| Name | Description | +|-------------------------------------------------------|--------------------------------------------------------------------------| +| [`RemoteDbContext` trait](#trait-remotedbcontext) | Module-specific `DbContext` extension trait with associated types bound. | +| [`db` method](#method-db) | Trait-generic alternative to the `db` field of `DbConnection`. | +| [`reducers` method](#method-reducers) | Trait-generic alternative to the `reducers` field of `DbConnection`. | +| [`disconnect` method](#method-disconnect) | End the connection. | +| [Subscribe to queries](#subscribe-to-queries) | Register SQL queries to receive updates about matching rows. | +| [Read connection metadata](#read-connection-metadata) | Access the connection's `Identity` and `ConnectionId` | + +### Trait `RemoteDbContext` + +```rust +trait module_bindings::RemoteDbContext + : spacetimedb_sdk::DbContext {} +``` + +Each module's `module_bindings` exports a trait `RemoteDbContext` which extends `DbContext`, with the associated types `DbView` and `Reducers` bound to the types defined for that module. This can be more convenient when creating functions that can be called from any callback for a specific module, but which access the database or invoke reducers, and so must know the type of the `DbView` or `Reducers`. + +### Method `db` + +```rust +trait DbContext { + fn db(&self) -> &Self::DbView; +} +``` + +When operating in trait-generic contexts, it is necessary to call the `ctx.db()` method, rather than accessing the `ctx.db` field, as Rust traits cannot expose fields. + +#### Example + +```rust +fn print_users(ctx: &impl RemoteDbContext) { + for user in ctx.db().user().iter() { + println!("{}", user.name); + } +} +``` + +### Method `reducers` + +```rust +trait DbContext { + fn reducerrs(&self) -> &Self::Reducers; +} +``` + +When operating in trait-generic contexts, it is necessary to call the `ctx.reducers()` method, rather than accessing the `ctx.reducers` field, as Rust traits cannot expose fields. + +#### Example + +```rust +fn call_say_hello(ctx: &impl RemoteDbContext) { + ctx.reducers.say_hello(); +} +``` ### Method `disconnect` ```rust trait DbContext { - fn disconnect(&self) -> anyhow::Result<()>; + fn disconnect(&self) -> spacetimedb_sdk::Result<()>; } ``` Gracefully close the `DbConnection`. Returns an `Err` if the connection is already disconnected. -### Subscribe to queries - `DbContext::subscription_builder` and `.subscribe()` +### Subscribe to queries -This interface is subject to change in an upcoming SpacetimeDB release. +| Name | Description | +|---------------------------------------------------------|-------------------------------------------------------------| +| [`SubscriptionBuilder` type](#type-subscriptionbuilder) | Builder-pattern constructor to register subscribed queries. | +| [`SubscriptionHandle` type](#type-subscriptionhandle) | Manage an active subscripion. | -A known issue in the SpacetimeDB Rust SDK causes inconsistent behaviors after re-subscribing. This will be fixed in an upcoming SpacetimeDB release. For now, Rust clients should issue only one subscription per `DbConnection`. +#### Type `SubscriptionBuilder` + +```rust +spacetimedb_sdk::SubscriptionBuilder +``` + +| Name | Description | +|----------------------------------------------------------------------------------|-----------------------------------------------------------------| +| [`ctx.subscription_builder()` constructor](#constructor-ctxsubscription_builder) | Begin configuring a new subscription. | +| [`on_applied` callback](#callback-on_applied) | Register a callback to run when matching rows become available. | +| [`on_error` callback](#callback-on_error) | Register a callback to run if the subscription fails. | +| [`subscribe` method](#method-subscribe) | Finish configuration and subscribe to one or more SQL queries. | +| [`subscribe_to_all_tables` method](#method-subscribe_to_all_tables) | Convenience method to subscribe to the entire database. | + +##### Constructor `ctx.subscription_builder()` ```rust trait DbContext { @@ -177,17 +331,28 @@ trait DbContext { Subscribe to queries by calling `ctx.subscription_builder()` and chaining configuration methods, then calling `.subscribe(queries)`. -#### Callback `on_applied` +##### Callback `on_applied` + +```rust +impl SubscriptionBuilder { + fn on_applied(self, callback: impl FnOnce(&SubscriptionEventContext)) -> Self; +} +``` + +Register a callback to run when the subscription is applied and the matching rows are inserted into the client cache. + +##### Callback `on_error` ```rust impl SubscriptionBuilder { - fn on_applied(self, callback: impl FnOnce(&EventContext)) -> Self; + fn on_error(self, callback: impl FnOnce(&ErrorContext, spacetimedb_sdk::Error)) -> Self; } ``` -Register a callback to run when the subscription is applied and the matching rows are inserted into the client cache. The [`EventContext`](#type-eventcontext) passed to the callback will have `Event::SubscribeApplied` as its `event`. +Register a callback to run if the subscription is rejected or unexpectedly terminated by the server. This is most frequently caused by passing an invalid query to [`subscribe`](#method-subscribe). -#### Method `subscribe` + +##### Method `subscribe` ```rust impl SubscriptionBuilder { @@ -195,11 +360,87 @@ impl SubscriptionBuilder { } ``` -Subscribe to a set of queries. `queries` should be an array or slice of strings. +Subscribe to a set of queries. `queries` should be a string or an array, vec or slice of strings. + +See [the SpacetimeDB SQL Reference](/docs/sql#subscriptions) for information on the queries SpacetimeDB supports as subscriptions. + +##### Method `subscribe_to_all_tables` + +```rust +impl SubscriptionBuilder { + fn subscribe_to_all_tables(self); +} +``` + +Subscribe to all rows from all public tables. This method is provided as a convenience for simple clients. The subscription initiated by `subscribe_to_all_tables` cannot be canceled after it is initiated. You should [`subscribe` to specific queries](#method-subscribe) if you need fine-grained control over the lifecycle of your subscriptions. + +#### Type `SubscriptionHandle` + +```rust +module_bindings::SubscriptionHandle +``` + +A `SubscriptionHandle` represents a subscribed query or a group of subscribed queries. + +The `SubscriptionHandle` does not contain or provide access to the subscribed rows. Subscribed rows of all subscriptions by a connection are contained within that connection's [`ctx.db`](#field-db). See [Access the client cache](#access-the-client-cache). + +| Name | Description | +|-------------------------------------------------------|------------------------------------------------------------------------------------------------------------------| +| [`is_ended` method](#method-is_ended) | Determine whether the subscription has ended. | +| [`is_active` method](#method-is_active) | Determine whether the subscription is active and its matching rows are present in the client cache. | +| [`unsubscribe` method](#method-unsubscribe) | Discard a subscription. | +| [`unsubscribe_then` method](#method-unsubscribe_then) | Discard a subscription, and register a callback to run when its matching rows are removed from the client cache. | + +##### Method `is_ended` + +```rust +impl SubscriptionHandle { + fn is_ended(&self) -> bool; +} +``` + +Returns true if this subscription has been terminated due to an unsubscribe call or an error. + +##### Method `is_active` + +```rust +impl SubscriptionHandle { + fn is_active(&self) -> bool; +} +``` + +Returns true if this subscription has been applied and has not yet been unsubscribed. + +##### Method `unsubscribe` + +```rust +impl SubscriptionHandle { + fn unsubscribe(&self) -> Result<(), spacetimedb_sdk::Error>; +} +``` + +Terminate this subscription, causing matching rows to be removed from the client cache. Any rows removed from the client cache this way will have [`on_delete` callbacks](#callback-on_delete) run for them. -The returned `SubscriptionHandle` is currently not useful, but will become significant in a future version of SpacetimeDB. +Unsubscribing is an asynchronous operation. Matching rows are not removed from the client cache immediately. Use [`unsubscribe_then`](#method-unsubscribe_then) to run a callback once the unsubscribe operation is completed. -### Identity a client +Returns an error if the subscription has already ended, either due to a previous call to `unsubscribe` or [`unsubscribe_then`](#method-unsubscribe_then), or due to an error. + +##### Method `unsubscribe_then` + +```rust +impl SubscriptionHandle { + fn unsubscribe_then( + self, + on_end: impl FnOnce(&SubscriptionEventContext), + ) -> Result<(), spacetimedb_sdk::Error>; +} +``` + +Terminate this subscription, and run the `on_end` callback when the subscription is ended and its matching rows are removed from the client cache. Any rows removed from the client cache this way will have [`on_delete` callbacks](#callback-on_delete) run for them. + +Returns an error if the subscription has already ended, either due to a previous call to [`unsubscribe`](#method-unsubscribe) or `unsubscribe_then`, or due to an error. + +### Read connection metadata #### Method `identity` @@ -221,6 +462,16 @@ trait DbContext { Like [`DbContext::identity`](#method-identity), but returns `None` instead of panicking if the `Identity` is not yet available. +#### Method `connection_id` + +```rust +trait DbContext { + fn connection_id(&self) -> ConnectionId; +} +``` + +Get the [`ConnectionId`](#type-connectionid) with which SpacetimeDB identifies the connection. + #### Method `is_active` ```rust @@ -237,7 +488,47 @@ trait DbContext { module_bindings::EventContext ``` -An `EventContext` is a [`DbContext`](#trait-dbcontext) augmented with a field `event: Event`. +An `EventContext` is a [`DbContext`](#trait-dbcontext) augmented with a field [`event: Event`](#enum-event). `EventContext`s are passed as the first argument to row callbacks [`on_insert`](#callback-on_insert), [`on_delete`](#callback-on_delete) and [`on_update`](#callback-on_update). + +| Name | Description | +|-------------------------------------|---------------------------------------------------------------| +| [`event` field](#field-event) | Enum describing the cause of the current row callback. | +| [`db` field](#field-db) | Provides access to the client cache. | +| [`reducers` field](#field-reducers) | Allows requesting reducers run on the remote database. | +| [`Event` enum](#enum-event) | Possible events which can cause a row callback to be invoked. | + +### Field `event` + +```rust +struct EventContext { + pub event: spacetimedb_sdk::Event, + /* other fields */ +} +``` + +The [`Event`](#enum-event) contained in the `EventContext` describes what happened to cause the current row callback to be invoked. + +### Field `db` + +```rust +struct EventContext { + pub db: RemoteTables, + /* other members */ +} +``` + +The `db` field of the context provides access to the subscribed view of the remote database's tables. See [Access the client cache](#access-the-client-cache). + +### Field `reducers` + +```rust +struct EventContext { + pub reducers: RemoteReducers, + /* other members */ +} +``` + +The `reducers` field of the context provides access to reducers exposed by the remote module. See [Observe and invoke reducers](#observe-and-invoke-reducers). ### Enum `Event` @@ -245,6 +536,17 @@ An `EventContext` is a [`DbContext`](#trait-dbcontext) augmented with a field `e spacetimedb_sdk::Event ``` +| Name | Description | +|-------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------| +| [`Reducer` variant](#variant-reducer) | A reducer ran in the remote database. | +| [`SubscribeApplied` variant](#variant-subscribeapplied) | A new subscription was applied to the client cache. | +| [`UnsubscribeApplied` variant](#variant-unsubscribeapplied) | A previous subscription was removed from the client cache after a call to [`unsubscribe`](#method-unsubscribe). | +| [`SubscribeError` variant](#variant-subscribeerror) | A previous subscription was removed from the client cache due to an error. | +| [`UnknownTransaction` variant](#variant-unknowntransaction) | A transaction ran in the remote database, but was not attributed to a known reducer. | +| [`ReducerEvent` struct](#struct-reducerevent) | Metadata about a reducer run. Contained in [`Event::Reducer`](#variant-reducer) and [`ReducerEventContext`](#type-reducereventcontext). | +| [`Status` enum](#enum-status) | Completion status of a reducer run. | +| [`Reducer` enum](#enum-reducer) | Module-specific generated enum with a variant for each reducer defined by the module. | + #### Variant `Reducer` ```rust @@ -253,7 +555,7 @@ spacetimedb_sdk::Event::Reducer(spacetimedb_sdk::ReducerEvent { /// The `Identity` of the SpacetimeDB actor which invoked the reducer. caller_identity: Identity, - /// The `Address` of the SpacetimeDB actor which invoked the reducer, - /// or `None` if the actor did not supply an address. - caller_address: Option
, + /// The `ConnectionId` of the SpacetimeDB actor which invoked the reducer, + /// or `None` for scheduled reducers. + caller_connection_id: Option, /// The amount of energy consumed by the reducer run, in eV. /// (Not literal eV, but our SpacetimeDB energy unit eV.) @@ -321,6 +635,12 @@ struct spacetimedb_sdk::ReducerEvent { spacetimedb_sdk::Status ``` +| Name | Description | +|-----------------------------------------------|-----------------------------------------------------| +| [`Committed` variant](#variant-committed) | The reducer ran successfully. | +| [`Failed` variant](#variant-failed) | The reducer errored. | +| [`OutOfEnergy` variant](#variant-outofenergy) | The reducer was aborted due to insufficient energy. | + #### Variant `Committed` ```rust @@ -349,12 +669,135 @@ module_bindings::Reducer The module bindings contains an enum `Reducer` with a variant for each reducer defined by the module. Each variant has a payload containing the arguments to the reducer. +## Type `ReducerEventContext` + +A `ReducerEventContext` is a [`DbContext`](#trait-dbcontext) augmented with a field [`event: ReducerEvent`](#struct-reducerevent). `ReducerEventContext`s are passed as the first argument to [reducer callbacks](#observe-and-invoke-reducers). + +| Name | Description | +|-------------------------------------|---------------------------------------------------------------------| +| [`event` field](#field-event) | [`ReducerEvent`](#struct-reducerevent) containing reducer metadata. | +| [`db` field](#field-db) | Provides access to the client cache. | +| [`reducers` field](#field-reducers) | Allows requesting reducers run on the remote database. | + +### Field `event` + +```rust +struct ReducerEventContext { + pub event: spacetimedb_sdk::ReducerEvent, + /* other fields */ +} +``` + +The [`ReducerEvent`](#struct-reducerevent) contained in the `ReducerEventContext` has metadata about the reducer which ran. + +### Field `db` + +```rust +struct ReducerEventContext { + pub db: RemoteTables, + /* other members */ +} +``` + +The `db` field of the context provides access to the subscribed view of the remote database's tables. See [Access the client cache](#access-the-client-cache). + +### Field `reducers` + +```rust +struct ReducerEventContext { + pub reducers: RemoteReducers, + /* other members */ +} +``` + +The `reducers` field of the context provides access to reducers exposed by the remote module. See [Observe and invoke reducers](#observe-and-invoke-reducers). + +## Type `SubscriptionEventContext` + +A `SubscriptionEventContext` is a [`DbContext`](#trait-dbcontext). Unlike the other context types, `SubscriptionEventContext` doesn't have an `event` field. `SubscriptionEventContext`s are passed to subscription [`on_applied`](#callback-on_applied) and [`unsubscribe_then`](#method-unsubscribe_then) callbacks. + +| Name | Description | +|-------------------------------------|------------------------------------------------------------| +| [`db` field](#field-db) | Provides access to the client cache. | +| [`reducers` field](#field-reducers) | Allows requesting reducers run on the remote database. | + +### Field `db` + +```rust +struct SubscriptionEventContext { + pub db: RemoteTables, + /* other members */ +} +``` + +The `db` field of the context provides access to the subscribed view of the remote database's tables. See [Access the client cache](#access-the-client-cache). + +### Field `reducers` + +```rust +struct SubscriptionEventContext { + pub reducers: RemoteReducers, + /* other members */ +} +``` + +The `reducers` field of the context provides access to reducers exposed by the remote module. See [Observe and invoke reducers](#observe-and-invoke-reducers). + +## Type `ErrorContext` + +An `ErrorContext` is a [`DbContext`](#trait-dbcontext) augmented with a field `event: spacetimedb_sdk::Error`. `ErrorContext`s are to connections' [`on_disconnect`](#callback-on_disconnect) and [`on_connect_error`](#callback-on_connect_error) callbacks, and to subscriptions' [`on_error`](#callback-on_error) callbacks. + +| Name | Description | +|-------------------------------------|--------------------------------------------------------| +| [`event` field](#field-event) | The error which caused the current error callback. | +| [`db` field](#field-db) | Provides access to the client cache. | +| [`reducers` field](#field-reducers) | Allows requesting reducers run on the remote database. | + + +### Field `event` + +```rust +struct ErrorContext { + pub event: spacetimedb_sdk::Error, + /* other fields */ +} +``` + +### Field `db` + +```rust +struct ErrorContext { + pub db: RemoteTables, + /* other members */ +} +``` + +The `db` field of the context provides access to the subscribed view of the remote database's tables. See [Access the client cache](#access-the-client-cache). + +### Field `reducers` + +```rust +struct ErrorContext { + pub reducers: RemoteReducers, + /* other members */ +} +``` + +The `reducers` field of the context provides access to reducers exposed by the remote module. See [Observe and invoke reducers](#observe-and-invoke-reducers). + ## Access the client cache -Both [`DbConnection`](#type-dbconnection) and [`EventContext`](#type-eventcontext) have fields `.db`, which in turn has methods for accessing tables in the client cache. The trait method `DbContext::db(&self)` can also be used in contexts with an `impl DbContext` rather than a concrete-typed `EventContext` or `DbConnection`. +All [`DbContext`](#trait-dbcontext) implementors, including [`DbConnection`](#type-dbconnection) and [`EventContext`](#type-eventcontext), have fields `.db`, which in turn has methods for accessing tables in the client cache. The trait method `DbContext::db(&self)` can also be used in contexts with an `impl DbContext` rather than a concrete-typed `EventContext` or `DbConnection`. Each table defined by a module has an accessor method, whose name is the table name converted to `snake_case`, on this `.db` field. The methods are defined via extension traits, which `rustc` or your IDE should help you identify and import where necessary. The table accessor methods return table handles, which implement [`Table`](#trait-table), may implement [`TableWithPrimaryKey`](#trait-tablewithprimarykey), and have methods for searching by unique index. +| Name | Description | +|-------------------------------------------------------------------|---------------------------------------------------------------------------------| +| [`Table` trait](#trait-table) | Provides access to subscribed rows of a specific table within the client cache. | +| [`TableWithPrimaryKey` trait](#trait-tablewithprimarykey) | Extension trait for tables which have a column designated as a primary key. | +| [Unique constraint index access](#unique-constraint-index-access) | Seek a subscribed row by the value in its unique or primary key column. | +| [BTree index access](#btree-index-access) | Not supported. | + ### Trait `Table` ```rust @@ -363,6 +806,14 @@ spacetimedb_sdk::Table Implemented by all table handles. +| Name | Description | +|-----------------------------------------------|------------------------------------------------------------------------------| +| [`Row` associated type](#associated-type-row) | The type of rows in the table. | +| [`count` method](#method-count) | The number of subscribed rows in the table. | +| [`iter` method](#method-iter) | Iterate over all subscribed rows in the table. | +| [`on_insert` callback](#callback-on_insert) | Register a callback to run whenever a row is inserted into the client cache. | +| [`on_delete` callback](#callback-on_delete) | Register a callback to run whenever a row is deleted from the client cache. | + #### Associated type `Row` ```rust @@ -431,7 +882,11 @@ spacetimedb_sdk::TableWithPrimaryKey Implemented for table handles whose tables have a primary key. -#### Callback `on_delete` +| Name | Description | +|---------------------------------------------|--------------------------------------------------------------------------------------| +| [`on_update` callback](#callback-on_update) | Register a callback to run whenever a subscribed row is replaced with a new version. | + +#### Callback `on_update` ```rust trait spacetimedb_sdk::TableWithPrimaryKey { @@ -451,17 +906,17 @@ For each unique constraint on a table, its table handle has a method whose name ### BTree index access -Not currently implemented in the Rust SDK. Coming soon! +The SpacetimeDB Rust client SDK does not support non-unique BTree indexes. ## Observe and invoke reducers -Both [`DbConnection`](#type-dbconnection) and [`EventContext`](#type-eventcontext) have fields `.reducers`, which in turn has methods for invoking reducers defined by the module and registering callbacks on it. The trait method `DbContext::reducers(&self)` can also be used in contexts with an `impl DbContext` rather than a concrete-typed `EventContext` or `DbConnection`. +All [`DbContext`](#trait-dbcontext) implementors, including [`DbConnection`](#type-dbconnection) and [`EventContext`](#type-eventcontext), have fields `.reducers`, which in turn has methods for invoking reducers defined by the module and registering callbacks on it. The trait method `DbContext::reducers(&self)` can also be used in contexts with an `impl DbContext` rather than a concrete-typed `EventContext` or `DbConnection`. Each reducer defined by the module has three methods on the `.reducers`: -- An invoke method, whose name is the reducer's name converted to snake case. This requests that the module run the reducer. -- A callback registation method, whose name is prefixed with `on_`. This registers a callback to run whenever we are notified that the reducer ran, including successfully committed runs and runs we requested which failed. This method returns a callback id, which can be passed to the callback remove method. -- A callback remove method, whose name is prefixed with `remove_`. This cancels a callback previously registered via the callback registration method. +- An invoke method, whose name is the reducer's name converted to snake case, like `set_name`. This requests that the module run the reducer. +- A callback registation method, whose name is prefixed with `on_`, like `on_set_name`. This registers a callback to run whenever we are notified that the reducer ran, including successfully committed runs and runs we requested which failed. This method returns a callback id, which can be passed to the callback remove method. +- A callback remove method, whose name is prefixed with `remove_on_`, like `remove_on_set_name`. This cancels a callback previously registered via the callback registration method. ## Identify a client @@ -473,10 +928,10 @@ spacetimedb_sdk::Identity A unique public identifier for a client connected to a database. -### Type `Address` +### Type `ConnectionId` ```rust -spacetimedb_sdk::Address +spacetimedb_sdk::ConnectionId ``` -An opaque identifier for a client connection to a database, intended to differentiate between connections from the same [`Identity`](#type-identity). This will be removed in a future SpacetimeDB version in favor of a connection or session ID. +An opaque identifier for a client connection to a database, intended to differentiate between connections from the same [`Identity`](#type-identity). diff --git a/docs/sdks/typescript/index.md b/docs/sdks/typescript/index.md index 34d9edef..322443c9 100644 --- a/docs/sdks/typescript/index.md +++ b/docs/sdks/typescript/index.md @@ -2,9 +2,21 @@ The SpacetimeDB client SDK for TypeScript contains all the tools you need to build clients for SpacetimeDB modules using Typescript, either in the browser or with NodeJS. -> You need a database created before use the client, so make sure to follow the Rust or C# Module Quickstart guides if need one. - -## Install the SDK +| Name | Description | +|-------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------| +| [Project setup](#project-setup) | Configure a Rust crate to use the SpacetimeDB Rust client SDK. | +| [Generate module bindings](#generate-module-bindings) | Use the SpacetimeDB CLI to generate module-specific types and interfaces. | +| [`DbConnection` type](#type-dbconnection) | A connection to a remote database. | +| [`DbContext` interface](#interface-dbcontext) | Methods for interacting with the remote database. Implemented by [`DbConnection`](#type-dbconnection) and various event context types. | +| [`EventContext` type](#type-eventcontext) | [`DbContext`](#interface-dbcontext) available in [row callbacks](#callback-oninsert). | +| [`ReducerEventContext` type](#type-reducereventcontext) | [`DbContext`](#interface-dbcontext) available in [reducer callbacks](#observe-and-invoke-reducers). | +| [`SubscriptionEventContext` type](#type-subscriptioneventcontext) | [`DbContext`](#interface-dbcontext) available in [subscription-related callbacks](#subscribe-to-queries). | +| [`ErrorContext` type](#type-errorcontext) | [`DbContext`](#interface-dbcontext) available in error-related callbacks. | +| [Access the client cache](#access-the-client-cache) | Make local queries against subscribed rows, and register [row callbacks](#callback-oninsert) to run when subscribed rows change. | +| [Observe and invoke reducers](#observe-and-invoke-reducers) | Send requests to the database to run reducers, and register callbacks to run when notified of reducers. | +| [Identify a client](#identify-a-client) | Types for identifying users and client connections. | + +## Project setup First, create a new client project, and add the following to your `tsconfig.json` file: @@ -55,927 +67,818 @@ Each SpacetimeDB client depends on some bindings specific to your module. Create mkdir -p client/src/module_bindings spacetime generate --lang typescript \ --out-dir client/src/module_bindings \ - --project-path server -``` - -And now you will get the files for the `reducers` & `tables`: - -```bash -quickstart-chat -├── client -│ ├── node_modules -│ ├── public -│ └── src -| └── module_bindings -| ├── add_reducer.ts -| ├── person.ts -| └── say_hello_reducer.ts -└── server - └── src + --project-path PATH-TO-MODULE-DIRECTORY ``` Import the `module_bindings` in your client's _main_ file: ```typescript -import { SpacetimeDBClient, Identity } from '@clockworklabs/spacetimedb-sdk'; - -import Person from './module_bindings/person'; -import AddReducer from './module_bindings/add_reducer'; -import SayHelloReducer from './module_bindings/say_hello_reducer'; -console.log(Person, AddReducer, SayHelloReducer); +import * as moduleBindings from './module_bindings/index'; ``` -> There is a known issue where if you do not use every type in your file, it will not pull them into the published build. To fix this, we are using `console.log` to force them to get pulled in. +You may also need to import some definitions from the SDK library: -## API at a glance - -### Classes - -| Class | Description | -| ----------------------------------------------- | ---------------------------------------------------------------------------- | -| [`SpacetimeDBClient`](#class-spacetimedbclient) | The database client connection to a SpacetimeDB server. | -| [`Identity`](#class-identity) | The user's public identity. | -| [`Address`](#class-address) | An opaque identifier for differentiating connections by the same `Identity`. | -| [`{Table}`](#class-table) | `{Table}` is a placeholder for each of the generated tables. | -| [`{Reducer}`](#class-reducer) | `{Reducer}` is a placeholder for each of the generated reducers. | - -### Class `SpacetimeDBClient` +```typescript +import { + Identity, ConnectionId, Event, ReducerEvent +} from '@clockworklabs/spacetimedb-sdk'; +``` -The database client connection to a SpacetimeDB server. +## Type `DbConnection` -Defined in [spacetimedb-sdk.spacetimedb](https://github.com/clockworklabs/spacetimedb-typescript-sdk/blob/main/src/spacetimedb.ts): +```typescript +DbConnection +``` -| Constructors | Description | -| ----------------------------------------------------------------- | ------------------------------------------------------------------------ | -| [`SpacetimeDBClient.constructor`](#spacetimedbclient-constructor) | Creates a new `SpacetimeDBClient` database client. | -| Properties | -| [`SpacetimeDBClient.identity`](#spacetimedbclient-identity) | The user's public identity. | -| [`SpacetimeDBClient.live`](#spacetimedbclient-live) | Whether the client is connected. | -| [`SpacetimeDBClient.token`](#spacetimedbclient-token) | The user's private authentication token. | -| Methods | | -| [`SpacetimeDBClient.connect`](#spacetimedbclient-connect) | Connect to a SpacetimeDB module. | -| [`SpacetimeDBClient.disconnect`](#spacetimedbclient-disconnect) | Close the current connection. | -| [`SpacetimeDBClient.subscribe`](#spacetimedbclient-subscribe) | Subscribe to a set of queries. | -| Events | | -| [`SpacetimeDBClient.onConnect`](#spacetimedbclient-onconnect) | Register a callback to be invoked upon authentication with the database. | -| [`SpacetimeDBClient.onError`](#spacetimedbclient-onerror) | Register a callback to be invoked upon a error. | +A connection to a remote database is represented by the `DbConnection` type. This type is generated per-module, and contains information about the types, tables and reducers defined by your module. -## Constructors +| Name | Description | +|-----------------------------------------------------------|--------------------------------------------------------------------------------------------------| +| [Connect to a module](#connect-to-a-module) | Construct a `DbConnection`. | +| [Access tables and reducers](#access-tables-and-reducers) | Access subscribed rows in the client cache, request reducer invocations, and register callbacks. | -### `SpacetimeDBClient` constructor -Creates a new `SpacetimeDBClient` database client and set the initial parameters. +### Connect to a module -```ts -new SpacetimeDBClient(host: string, name_or_address: string, auth_token?: string, protocol?: "binary" | "json") +```typescript +class DbConnection { + public static builder(): DbConnectionBuilder +} ``` -#### Parameters +Construct a `DbConnection` by calling `DbConnection.builder()` and chaining configuration methods, then calling `.build()`. You must at least specify `withUri`, to supply the URI of the SpacetimeDB to which you published your module, and `withModuleName`, to supply the human-readable SpacetimeDB domain name or the raw `Identity` which identifies the module. -| Name | Type | Description | -| :---------------- | :--------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------ | -| `host` | `string` | The host of the SpacetimeDB server. | -| `name_or_address` | `string` | The name or address of the SpacetimeDB module. | -| `auth_token?` | `string` | The credentials to use to connect to authenticate with SpacetimeDB. | -| `protocol?` | `"binary"` \| `"json"` | Define how encode the messages: `"binary"` \| `"json"`. Binary is more efficient and compact, but JSON provides human-readable debug information. | +| Name | Description | +|-------------------------------------------------------|--------------------------------------------------------------------------------------| +| [`withUri` method](#method-withuri) | Set the URI of the SpacetimeDB instance which hosts the remote database. | +| [`withModuleName` method](#method-withmodulename) | Set the name or `Identity` of the remote database. | +| [`onConnect` callback](#callback-onconnect) | Register a callback to run when the connection is successfully established. | +| [`onConnectError` callback](#callback-onconnecterror) | Register a callback to run if the connection is rejected or the host is unreachable. | +| [`onDisconnect` callback](#callback-ondisconnect) | Register a callback to run when the connection ends. | +| [`withToken` method](#method-withtoken) | Supply a token to authenticate with the remote database. | +| [`build` method](#method-build) | Finalize configuration and connect. | -#### Example +#### Method `withUri` -```ts -const host = 'ws://localhost:3000'; -const name_or_address = 'database_name'; -const auth_token = undefined; -const protocol = 'binary'; - -var spacetimeDBClient = new SpacetimeDBClient( - host, - name_or_address, - auth_token, - protocol -); +```typescript +class DbConnectionBuilder { + public withUri(uri: string): DbConnectionBuilder +} ``` -## Class methods +Configure the URI of the SpacetimeDB instance or cluster which hosts the remote module. -### `SpacetimeDBClient.registerReducers` +#### Method `withModuleName` -Registers reducer classes for use with a SpacetimeDBClient +```typescript +class DbConnectionBuilder { + public withModuleName(name_or_identity: string): DbConnectionBuilder +} -```ts -registerReducers(...reducerClasses: ReducerClass[]) ``` -#### Parameters - -| Name | Type | Description | -| :--------------- | :------------- | :---------------------------- | -| `reducerClasses` | `ReducerClass` | A list of classes to register | +Configure the SpacetimeDB domain name or hex string encoded `Identity` of the remote module which identifies it within the SpacetimeDB instance or cluster. -#### Example +#### Callback `onConnect` -```ts -import SayHelloReducer from './types/say_hello_reducer'; -import AddReducer from './types/add_reducer'; - -SpacetimeDBClient.registerReducers(SayHelloReducer, AddReducer); +```typescript +class DbConnectionBuilder { + public onConnect( + callback: (ctx: DbConnection, identity: Identity, token: string) => void + ): DbConnectionBuilder +} ``` ---- +Chain a call to `.onConnect(callback)` to your builder to register a callback to run when your new `DbConnection` successfully initiates its connection to the remote module. The callback accepts three arguments: a reference to the `DbConnection`, the `Identity` by which SpacetimeDB identifies this connection, and a private access token which can be saved and later passed to [`withToken`](#method-withtoken) to authenticate the same user in future connections. -### `SpacetimeDBClient.registerTables` +#### Callback `onConnectError` -Registers table classes for use with a SpacetimeDBClient - -```ts -registerTables(...reducerClasses: TableClass[]) +```typescript +class DbConnectionBuilder { + public onConnectError( + callback: (ctx: ErrorContext, error: Error) => void + ): DbConnectionBuilder +} ``` -#### Parameters +Chain a call to `.onConnectError(callback)` to your builder to register a callback to run when your connection fails. -| Name | Type | Description | -| :------------- | :----------- | :---------------------------- | -| `tableClasses` | `TableClass` | A list of classes to register | +#### Callback `onDisconnect` -#### Example - -```ts -import User from './types/user'; -import Player from './types/player'; - -SpacetimeDBClient.registerTables(User, Player); +```typescript +class DbConnectionBuilder { + public onDisconnect( + callback: (ctx: ErrorContext, error: Error | null) => void + ): DbConnectionBuilder +} ``` ---- - -## Properties - -### `SpacetimeDBClient` identity +Chain a call to `.onDisconnect(callback)` to your builder to register a callback to run when your `DbConnection` disconnects from the remote module, either as a result of a call to [`disconnect`](#method-disconnect) or due to an error. -The user's public [Identity](#class-identity). +#### Method `withToken` -``` -identity: Identity | undefined +```typescript +class DbConnectionBuilder { + public withToken(token?: string): DbConnectionBuilder +} ``` ---- +Chain a call to `.withToken(token)` to your builder to provide an OpenID Connect compliant JSON Web Token to authenticate with, or to explicitly select an anonymous connection. If this method is not called or `null` is passed, SpacetimeDB will generate a new `Identity` and sign a new private access token for the connection. -### `SpacetimeDBClient` live -Whether the client is connected. +#### Method `build` -```ts -live: boolean; +```typescript +class DbConnectionBuilder { + public build(): DbConnection +} ``` ---- +After configuring the connection and registering callbacks, attempt to open the connection. -### `SpacetimeDBClient` token +### Access tables and reducers -The user's private authentication token. +#### Field `db` -``` -token: string | undefined +```typescript +class DbConnection { + public db: RemoteTables +} ``` -#### Parameters +The `db` field of the `DbConnection` provides access to the subscribed view of the remote database's tables. See [Access the client cache](#access-the-client-cache). -| Name | Type | Description | -| :------------ | :----------- | :------------------------------ | -| `reducerName` | `string` | The name of the reducer to call | -| `serializer` | `Serializer` | - | +#### Field `reducers` ---- +```typescript +class DbConnection { + public reducers: RemoteReducers +} +``` -### `SpacetimeDBClient` connect +The `reducers` field of the `DbConnection` provides access to reducers exposed by the remote module. See [Observe and invoke reducers](#observe-and-invoke-reducers). -Connect to The SpacetimeDB Websocket For Your Module. By default, this will use a secure websocket connection. The parameters are optional, and if not provided, will use the values provided on construction of the client. +## Interface `DbContext` -```ts -connect(host: string?, name_or_address: string?, auth_token: string?): Promise +```typescript +interface DbContext< + DbView, + Reducers, +> ``` -#### Parameters +[`DbConnection`](#type-dbconnection), [`EventContext`](#type-eventcontext), [`ReducerEventContext`](#type-reducereventcontext), [`SubscriptionEventContext`](#type-subscriptioneventcontext) and [`ErrorContext`](#type-errorcontext) all implement `DbContext`. `DbContext` has fields and methods for inspecting and configuring your connection to the remote database. -| Name | Type | Description | -| :----------------- | :------- | :------------------------------------------------------------------------------------------------------------------------------------------ | -| `host?` | `string` | The hostname of the SpacetimeDB server. Defaults to the value passed to the [constructor](#spacetimedbclient-constructor). | -| `name_or_address?` | `string` | The name or address of the SpacetimeDB module. Defaults to the value passed to the [constructor](#spacetimedbclient-constructor). | -| `auth_token?` | `string` | The credentials to use to authenticate with SpacetimeDB. Defaults to the value passed to the [constructor](#spacetimedbclient-constructor). | +The `DbContext` interface is implemented by connections and contexts to *every* module. This means that its [`DbView`](#field-db) and [`Reducers`](#field-reducers) are generic types. -#### Returns +| Name | Description | +|-------------------------------------------------------|--------------------------------------------------------------------------| +| [`db` field](#field-db) | Access subscribed rows of tables and register row callbacks. | +| [`reducers` field](#field-reducers) | Request reducer invocations and register reducer callbacks. | +| [`disconnect` method](#method-disconnect) | End the connection. | +| [Subscribe to queries](#subscribe-to-queries) | Register SQL queries to receive updates about matching rows. | +| [Read connection metadata](#read-connection-metadata) | Access the connection's `Identity` and `ConnectionId` | -`Promise`<`void`\> +#### Field `db` -#### Example - -```ts -const host = 'ws://localhost:3000'; -const name_or_address = 'database_name'; -const auth_token = undefined; - -var spacetimeDBClient = new SpacetimeDBClient( - host, - name_or_address, - auth_token -); -// Connect with the initial parameters -spacetimeDBClient.connect(); -//Set the `auth_token` -spacetimeDBClient.connect(undefined, undefined, NEW_TOKEN); +```typescript +interface DbContext { + db: DbView +} ``` ---- - -### `SpacetimeDBClient` disconnect +The `db` field of a `DbContext` provides access to the subscribed view of the remote database's tables. See [Access the client cache](#access-the-client-cache). -Close the current connection. +#### Field `reducers` -```ts -disconnect(): void +```typescript +interface DbContext { + reducers: Reducers +} ``` -#### Example +The `reducers` field of a `DbContext` provides access to reducers exposed by the remote module. See [Observe and invoke reducers](#observe-and-invoke-reducers). -```ts -var spacetimeDBClient = new SpacetimeDBClient( - 'ws://localhost:3000', - 'database_name' -); +### Method `disconnect` -spacetimeDBClient.disconnect(); +```typescript +interface DbContext { + disconnect(): void +} ``` ---- +Gracefully close the `DbConnection`. Throws an error if the connection is already disconnected. -### `SpacetimeDBClient` subscribe +### Subscribe to queries -Subscribe to a set of queries, to be notified when rows which match those queries are altered. +| Name | Description | +|---------------------------------------------------------|-------------------------------------------------------------| +| [`SubscriptionBuilder` type](#type-subscriptionbuilder) | Builder-pattern constructor to register subscribed queries. | +| [`SubscriptionHandle` type](#type-subscriptionhandle) | Manage an active subscripion. | -> A new call to `subscribe` will remove all previous subscriptions and replace them with the new `queries`. -> If any rows matched the previous subscribed queries but do not match the new queries, -> those rows will be removed from the client cache, and [`{Table}.on_delete`](#table-ondelete) callbacks will be invoked for them. +#### Type `SubscriptionBuilder` -```ts -subscribe(queryOrQueries: string | string[]): void +```typescript +SubscriptionBuilder ``` -#### Parameters - -| Name | Type | Description | -| :--------------- | :--------------------- | :------------------------------- | -| `queryOrQueries` | `string` \| `string`[] | A `SQL` query or list of queries | +| Name | Description | +|--------------------------------------------------------------------------------|-----------------------------------------------------------------| +| [`ctx.subscriptionBuilder()` constructor](#constructor-ctxsubscriptionbuilder) | Begin configuring a new subscription. | +| [`onApplied` callback](#callback-onapplied) | Register a callback to run when matching rows become available. | +| [`onError` callback](#callback-onerror) | Register a callback to run if the subscription fails. | +| [`subscribe` method](#method-subscribe) | Finish configuration and subscribe to one or more SQL queries. | +| [`subscribeToAllTables` method](#method-subscribetoalltables) | Convenience method to subscribe to the entire database. | -#### Example +##### Constructor `ctx.subscriptionBuilder()` -```ts -spacetimeDBClient.subscribe(['SELECT * FROM User', 'SELECT * FROM Message']); +```typescript +interface DbContext { + subscriptionBuilder(): SubscriptionBuilder +} ``` -## Events - -### `SpacetimeDBClient` onConnect +Subscribe to queries by calling `ctx.subscription_builder()` and chaining configuration methods, then calling `.subscribe(queries)`. -Register a callback to be invoked upon authentication with the database. +##### Callback `onApplied` -```ts -onConnect(callback: (token: string, identity: Identity) => void): void +```typescript +class SubscriptionBuilder { + public onApplied( + callback: (ctx: SubscriptionEventContext) => void + ): SubscriptionBuilder +} ``` -The callback will be invoked with the public user [Identity](#class-identity), private authentication token and connection [`Address`](#class-address) provided by the database. If credentials were supplied to [connect](#spacetimedbclient-connect), those passed to the callback will be equivalent to the ones used to connect. If the initial connection was anonymous, a new set of credentials will be generated by the database to identify this user. - -The credentials passed to the callback can be saved and used to authenticate the same user in future connections. - -#### Parameters +Register a callback to run when the subscription is applied and the matching rows are inserted into the client cache. -| Name | Type | -| :--------- | :--------------------------------------------------------------------------------------------------------------- | -| `callback` | (`token`: `string`, `identity`: [`Identity`](#class-identity), `address`: [`Address`](#class-address)) => `void` | +##### Callback `onError` -#### Example - -```ts -spacetimeDBClient.onConnect((token, identity, address) => { - console.log('Connected to SpacetimeDB'); - console.log('Token', token); - console.log('Identity', identity); - console.log('Address', address); -}); +```typescript +class SubscriptionBuilder { + public onError( + callback: (ctx: ErrorContext, error: Error) => void + ): SubscriptionBuilder +} ``` ---- +Register a callback to run if the subscription is rejected or unexpectedly terminated by the server. This is most frequently caused by passing an invalid query to [`subscribe`](#method-subscribe). -### `SpacetimeDBClient` onError -Register a callback to be invoked upon an error. +##### Method `subscribe` -```ts -onError(callback: (...args: any[]) => void): void +```typescript +class SubscriptionBuilder { + subscribe(queries: string | string[]): SubscriptionHandle +} ``` -#### Parameters +Subscribe to a set of queries. -| Name | Type | -| :--------- | :----------------------------- | -| `callback` | (...`args`: `any`[]) => `void` | +See [the SpacetimeDB SQL Reference](/docs/sql#subscriptions) for information on the queries SpacetimeDB supports as subscriptions. -#### Example +##### Method `subscribeToAllTables` -```ts -spacetimeDBClient.onError((...args: any[]) => { - console.error('ERROR', args); -}); +```typescript +class SubscriptionBuilder { + subscribeToAllTables(): void +} ``` -### Class `Identity` - -A unique public identifier for a user of a database. - -Defined in [spacetimedb-sdk.identity](https://github.com/clockworklabs/spacetimedb-typescript-sdk/blob/main/src/identity.ts): +Subscribe to all rows from all public tables. This method is provided as a convenience for simple clients. The subscription initiated by `subscribeToAllTables` cannot be canceled after it is initiated. You should [`subscribe` to specific queries](#method-subscribe) if you need fine-grained control over the lifecycle of your subscriptions. -| Constructors | Description | -| ----------------------------------------------- | -------------------------------------------- | -| [`Identity.constructor`](#identity-constructor) | Creates a new `Identity`. | -| Methods | | -| [`Identity.isEqual`](#identity-isequal) | Compare two identities for equality. | -| [`Identity.toHexString`](#identity-tohexstring) | Print the identity as a hexadecimal string. | -| Static methods | | -| [`Identity.fromString`](#identity-fromstring) | Parse an Identity from a hexadecimal string. | +#### Type `SubscriptionHandle` -## Constructors - -### `Identity` constructor - -```ts -new Identity(data: Uint8Array) +```typescript +SubscriptionHandle ``` -#### Parameters +A `SubscriptionHandle` represents a subscribed query or a group of subscribed queries. -| Name | Type | -| :----- | :----------- | -| `data` | `Uint8Array` | +The `SubscriptionHandle` does not contain or provide access to the subscribed rows. Subscribed rows of all subscriptions by a connection are contained within that connection's [`ctx.db`](#field-db). See [Access the client cache](#access-the-client-cache). -## Methods +| Name | Description | +|-----------------------------------------------------|------------------------------------------------------------------------------------------------------------------| +| [`isEnded` method](#method-isended) | Determine whether the subscription has ended. | +| [`isActive` method](#method-isactive) | Determine whether the subscription is active and its matching rows are present in the client cache. | +| [`unsubscribe` method](#method-unsubscribe) | Discard a subscription. | +| [`unsubscribeThen` method](#method-unsubscribethen) | Discard a subscription, and register a callback to run when its matching rows are removed from the client cache. | -### `Identity` isEqual +##### Method `isEnded` -Compare two identities for equality. - -```ts -isEqual(other: Identity): boolean +```typescript +class SubscriptionHandle { + public isEnded(): bool +} ``` -#### Parameters +Returns true if this subscription has been terminated due to an unsubscribe call or an error. -| Name | Type | -| :------ | :---------------------------- | -| `other` | [`Identity`](#class-identity) | +##### Method `isActive` -#### Returns - -`boolean` - ---- - -### `Identity` toHexString - -Print an `Identity` as a hexadecimal string. - -```ts -toHexString(): string +```typescript +class SubscriptionHandle { + public isActive(): bool +} ``` -#### Returns - -`string` - ---- +Returns true if this subscription has been applied and has not yet been unsubscribed. -### `Identity` fromString +##### Method `unsubscribe` -Static method; parse an Identity from a hexadecimal string. - -```ts -Identity.fromString(str: string): Identity +```typescript +class SubscriptionHandle { + public unsubscribe(): void +} ``` -#### Parameters +Terminate this subscription, causing matching rows to be removed from the client cache. Any rows removed from the client cache this way will have [`onDelete` callbacks](#callback-ondelete) run for them. -| Name | Type | -| :---- | :------- | -| `str` | `string` | +Unsubscribing is an asynchronous operation. Matching rows are not removed from the client cache immediately. Use [`unsubscribeThen`](#method-unsubscribethen) to run a callback once the unsubscribe operation is completed. -#### Returns +Throws an error if the subscription has already ended, either due to a previous call to `unsubscribe` or [`unsubscribeThen`](#method-unsubscribethen), or due to an error. -[`Identity`](#class-identity) +##### Method `unsubscribeThen` -### Class `Address` - -An opaque identifier for a client connection to a database, intended to differentiate between connections from the same [`Identity`](#class-identity). +```typescript +class SubscriptionHandle { + public unsubscribeThen( + on_end: (ctx: SubscriptionEventContext) => void + ): void +} +``` -Defined in [spacetimedb-sdk.address](https://github.com/clockworklabs/spacetimedb-typescript-sdk/blob/main/src/address.ts): +Terminate this subscription, and run the `onEnd` callback when the subscription is ended and its matching rows are removed from the client cache. Any rows removed from the client cache this way will have [`onDelete` callbacks](#callback-ondelete) run for them. -| Constructors | Description | -| --------------------------------------------- | ------------------------------------------- | -| [`Address.constructor`](#address-constructor) | Creates a new `Address`. | -| Methods | | -| [`Address.isEqual`](#address-isequal) | Compare two identities for equality. | -| [`Address.toHexString`](#address-tohexstring) | Print the address as a hexadecimal string. | -| Static methods | | -| [`Address.fromString`](#address-fromstring) | Parse an Address from a hexadecimal string. | +Returns an error if the subscription has already ended, either due to a previous call to [`unsubscribe`](#method-unsubscribe) or `unsubscribeThen`, or due to an error. -## Constructors +### Read connection metadata -### `Address` constructor +#### Field `isActive` -```ts -new Address(data: Uint8Array) +```typescript +interface DbContext { + isActive: bool +} ``` -#### Parameters +`true` if the connection has not yet disconnected. Note that a connection `isActive` when it is constructed, before its [`onConnect` callback](#callback-onconnect) is invoked. -| Name | Type | -| :----- | :----------- | -| `data` | `Uint8Array` | +## Type `EventContext` -## Methods - -### `Address` isEqual - -Compare two addresses for equality. - -```ts -isEqual(other: Address): boolean +```typescript +EventContext ``` -#### Parameters +An `EventContext` is a [`DbContext`](#interface-dbcontext) augmented with a field [`event: Event`](#type-event). `EventContext`s are passed as the first argument to row callbacks [`onInsert`](#callback-oninsert), [`onDelete`](#callback-ondelete) and [`onUpdate`](#callback-onupdate). -| Name | Type | -| :------ | :-------------------------- | -| `other` | [`Address`](#class-address) | +| Name | Description | +|-------------------------------------|---------------------------------------------------------------| +| [`event` field](#field-event) | Enum describing the cause of the current row callback. | +| [`db` field](#field-db) | Provides access to the client cache. | +| [`reducers` field](#field-reducers) | Allows requesting reducers run on the remote database. | +| [`Event` type](#type-event) | Possible events which can cause a row callback to be invoked. | -#### Returns +### Field `event` -`boolean` +```typescript +class EventContext { + public event: Event +} +/* other fields */ ---- +``` -### `Address` toHexString +The [`Event`](#type-event) contained in the `EventContext` describes what happened to cause the current row callback to be invoked. -Print an `Address` as a hexadecimal string. +### Field `db` -```ts -toHexString(): string +```typescript +class EventContext { + public db: RemoteTables +} ``` -#### Returns +The `db` field of the context provides access to the subscribed view of the remote database's tables. See [Access the client cache](#access-the-client-cache). -`string` +### Field `reducers` ---- +```typescript +class EventContext { + public reducers: RemoteReducers +} +``` -### `Address` fromString +The `reducers` field of the context provides access to reducers exposed by the remote module. See [Observe and invoke reducers](#observe-and-invoke-reducers). -Static method; parse an Address from a hexadecimal string. +### Type `Event` -```ts -Address.fromString(str: string): Address +```rust +type Event = + | { tag: 'Reducer'; value: ReducerEvent } + | { tag: 'SubscribeApplied' } + | { tag: 'UnsubscribeApplied' } + | { tag: 'Error'; value: Error } + | { tag: 'UnknownTransaction' }; ``` -#### Parameters +| Name | Description | +|-------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------| +| [`Reducer` variant](#variant-reducer) | A reducer ran in the remote database. | +| [`SubscribeApplied` variant](#variant-subscribeapplied) | A new subscription was applied to the client cache. | +| [`UnsubscribeApplied` variant](#variant-unsubscribeapplied) | A previous subscription was removed from the client cache after a call to [`unsubscribe`](#method-unsubscribe). | +| [`Error` variant](#variant-error) | A previous subscription was removed from the client cache due to an error. | +| [`UnknownTransaction` variant](#variant-unknowntransaction) | A transaction ran in the remote database, but was not attributed to a known reducer. | +| [`ReducerEvent` type](#type-reducerevent) | Metadata about a reducer run. Contained in [`Event::Reducer`](#variant-reducer) and [`ReducerEventContext`](#type-reducereventcontext). | +| [`UpdateStatus` type](#type-updatestatus) | Completion status of a reducer run. | +| [`Reducer` type](#type-reducer) | Module-specific generated enum with a variant for each reducer defined by the module. | -| Name | Type | -| :---- | :------- | -| `str` | `string` | +#### Variant `Reducer` -#### Returns +```typescript +{ tag: 'Reducer'; value: ReducerEvent } +``` -[`Address`](#class-address) +Event when we are notified that a reducer ran in the remote module. The [`ReducerEvent`](#type-reducerevent) contains metadata about the reducer run, including its arguments and termination status(#type-updatestatus). -### Class `{Table}` +This event is passed to row callbacks resulting from modifications by the reducer. -For each table defined by a module, `spacetime generate` generates a `class` in the `module_bindings` folder whose name is that table's name converted to `PascalCase`. +#### Variant `SubscribeApplied` -The generated class has a field for each of the table's columns, whose names are the column names converted to `snake_case`. +```typescript +{ tag: 'SubscribeApplied' } +``` -| Properties | Description | -| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -| [`Table.name`](#table-name) | The name of the class. | -| [`Table.tableName`](#table-tablename) | The name of the table in the database. | -| Methods | | -| [`Table.all`](#table-all) | Return all the subscribed rows in the table. | -| [`Table.filterBy{COLUMN}`](#table-filterbycolumn) | Autogenerated; return subscribed rows with a given value in a particular column. `{COLUMN}` is a placeholder for a column name. | -| [`Table.findBy{COLUMN}`](#table-findbycolumn) | Autogenerated; return a subscribed row with a given value in a particular unique column. `{COLUMN}` is a placeholder for a column name. | -| Events | | -| [`Table.onInsert`](#table-oninsert) | Register an `onInsert` callback for when a subscribed row is newly inserted into the database. | -| [`Table.removeOnInsert`](#table-removeoninsert) | Unregister a previously-registered [`onInsert`](#table-oninsert) callback. | -| [`Table.onUpdate`](#table-onupdate) | Register an `onUpdate` callback for when an existing row is modified. | -| [`Table.removeOnUpdate`](#table-removeonupdate) | Unregister a previously-registered [`onUpdate`](#table-onupdate) callback. | -| [`Table.onDelete`](#table-ondelete) | Register an `onDelete` callback for when a subscribed row is removed from the database. | -| [`Table.removeOnDelete`](#table-removeondelete) | Unregister a previously-registered [`onDelete`](#table-removeondelete) callback. | +Event when our subscription is applied and its rows are inserted into the client cache. -## Properties +This event is passed to [row `onInsert` callbacks](#callback-oninsert) resulting from the new subscription. -### {Table} name +#### Variant `UnsubscribeApplied` -• **name**: `string` +```typescript +{ tag: 'UnsubscribeApplied' } +``` -The name of the `Class`. +Event when our subscription is removed after a call to [`SubscriptionHandle.unsubscribe`](#method-unsubscribe) or [`SubscriptionHandle.unsubscribeThen`](#method-unsubscribethen) and its matching rows are deleted from the client cache. ---- +This event is passed to [row `onDelete` callbacks](#callback-ondelete) resulting from the subscription ending. -### {Table} tableName +#### Variant `Error` -The name of the table in the database. +```typescript +{ tag: 'Error'; value: Error } -▪ `Static` **tableName**: `string` = `"Person"` +``` -## Methods +Event when a subscription ends unexpectedly due to an error. -### {Table} all +This event is passed to [row `onDelete` callbacks](#callback-ondelete) resulting from the subscription ending. -Return all the subscribed rows in the table. +#### Variant `UnknownTransaction` -```ts -{Table}.all(): {Table}[] +```typescript +{ tag: 'UnknownTransaction' } ``` -#### Returns +Event when we are notified of a transaction in the remote module which we cannot associate with a known reducer. This may be an ad-hoc SQL query or a reducer for which we do not have bindings. -`{Table}[]` +This event is passed to [row callbacks](#callback-oninsert) resulting from modifications by the transaction. -#### Example +### Type `ReducerEvent` -```ts -var spacetimeDBClient = new SpacetimeDBClient( - 'ws://localhost:3000', - 'database_name' -); +A `ReducerEvent` contains metadata about a reducer run. -spacetimeDBClient.onConnect((token, identity, address) => { - spacetimeDBClient.subscribe(['SELECT * FROM Person']); +```typescript +type ReducerEvent = { + /** + * The time when the reducer started running. + */ + timestamp: Timestamp; + + /** + * Whether the reducer committed, was aborted due to insufficient energy, or failed with an error message. + */ + status: UpdateStatus; + + /** + * The identity of the caller. + * TODO: Revise these to reflect the forthcoming Identity proposal. + */ + callerIdentity: Identity; + + /** + * The connection ID of the caller. + * + * May be `null`, e.g. for scheduled reducers. + */ + callerConnectionId?: ConnectionId; + + /** + * The amount of energy consumed by the reducer run, in eV. + * (Not literal eV, but our SpacetimeDB energy unit eV.) + * May be present or undefined at the implementor's discretion; + * future work may determine an interface for module developers + * to request this value be published or hidden. + */ + energyConsumed?: bigint; + + /** + * The `Reducer` enum defined by the `moduleBindings`, which encodes which reducer ran and its arguments. + */ + reducer: Reducer; +}; +``` + +### Type `UpdateStatus` - setTimeout(() => { - console.log(Person.all()); // Prints all the `Person` rows in the database. - }, 5000); -}); +```typescript +type UpdateStatus = + | { tag: 'Committed'; value: __DatabaseUpdate } + | { tag: 'Failed'; value: string } + | { tag: 'OutOfEnergy' }; ``` ---- - -### {Table} count +| Name | Description | +|-----------------------------------------------|-----------------------------------------------------| +| [`Committed` variant](#variant-committed) | The reducer ran successfully. | +| [`Failed` variant](#variant-failed) | The reducer errored. | +| [`OutOfEnergy` variant](#variant-outofenergy) | The reducer was aborted due to insufficient energy. | -Return the number of subscribed rows in the table, or 0 if there is no active connection. +#### Variant `Committed` -```ts -{Table}.count(): number +```typescript +{ tag: 'Committed' } ``` -#### Returns +The reducer returned successfully and its changes were committed into the database state. An [`Event` with `tag: 'Reducer'`](#variant-reducer) passed to a row callback must have this status in its [`ReducerEvent`](#type-reducerevent). -`number` +#### Variant `Failed` -#### Example +```typescript +{ tag: 'Failed'; value: string } +``` -```ts -var spacetimeDBClient = new SpacetimeDBClient( - 'ws://localhost:3000', - 'database_name' -); +The reducer returned an error, panicked, or threw an exception. The `value` is the stringified error message. Formatting of the error message is unstable and subject to change, so clients should use it only as a human-readable diagnostic, and in particular should not attempt to parse the message. -spacetimeDBClient.onConnect((token, identity, address) => { - spacetimeDBClient.subscribe(['SELECT * FROM Person']); +#### Variant `OutOfEnergy` - setTimeout(() => { - console.log(Person.count()); - }, 5000); -}); +```typescript +{ tag: 'OutOfEnergy' } ``` ---- - -### {Table} filterBy{COLUMN} - -For each column of a table, `spacetime generate` generates a static method on the `Class` to filter subscribed rows where that column matches a requested value. +The reducer was aborted due to insufficient energy balance of the module owner. -These methods are named `filterBy{COLUMN}`, where `{COLUMN}` is the column name converted to `camelCase`. +### Type `Reducer` -```ts -{Table}.filterBy{COLUMN}(value): Iterable<{Table}> +```rust +type Reducer = + | { name: 'ReducerA'; args: ReducerA } + | { name: 'ReducerB'; args: ReducerB } ``` -#### Parameters +The module bindings contains a type `Reducer` with a variant for each reducer defined by the module. Each variant has a field `args` containing the arguments to the reducer. -| Name | Type | -| :------ | :-------------------------- | -| `value` | The type of the `{COLUMN}`. | +## Type `ReducerEventContext` -#### Returns +A `ReducerEventContext` is a [`DbContext`](#interface-dbcontext) augmented with a field [`event: ReducerEvent`](#type-reducerevent). `ReducerEventContext`s are passed as the first argument to [reducer callbacks](#observe-and-invoke-reducers). -`Iterable<{Table}>` +| Name | Description | +|-------------------------------------|-------------------------------------------------------------------| +| [`event` field](#field-event) | [`ReducerEvent`](#type-reducerevent) containing reducer metadata. | +| [`db` field](#field-db) | Provides access to the client cache. | +| [`reducers` field](#field-reducers) | Allows requesting reducers run on the remote database. | -#### Example +### Field `event` -```ts -var spacetimeDBClient = new SpacetimeDBClient( - 'ws://localhost:3000', - 'database_name' -); - -spacetimeDBClient.onConnect((token, identity, address) => { - spacetimeDBClient.subscribe(['SELECT * FROM Person']); - - setTimeout(() => { - console.log(...Person.filterByName('John')); // prints all the `Person` rows named John. - }, 5000); -}); +```typescript +class ReducerEventContext { + public event: ReducerEvent +} ``` ---- - -### {Table} findBy{COLUMN} - -For each unique column of a table, `spacetime generate` generates a static method on the `Class` to find the subscribed row where that column matches a requested value. +The [`ReducerEvent`](#type-reducerevent) contained in the `ReducerEventContext` has metadata about the reducer which ran. -These methods are named `findBy{COLUMN}`, where `{COLUMN}` is the column name converted to `camelCase`. +### Field `db` -```ts -{Table}.findBy{COLUMN}(value): {Table} | undefined +```typescript +class ReducerEventContext { + public db: RemoteTables +} ``` -#### Parameters +The `db` field of the context provides access to the subscribed view of the remote database's tables. See [Access the client cache](#access-the-client-cache). -| Name | Type | -| :------ | :-------------------------- | -| `value` | The type of the `{COLUMN}`. | +### Field `reducers` -#### Returns +```typescript +class ReducerEventContext { + public reducers: RemoteReducers +} +``` -`{Table} | undefined` +The `reducers` field of the context provides access to reducers exposed by the remote module. See [Observe and invoke reducers](#observe-and-invoke-reducers). -#### Example +## Type `SubscriptionEventContext` -```ts -var spacetimeDBClient = new SpacetimeDBClient( - 'ws://localhost:3000', - 'database_name' -); +A `SubscriptionEventContext` is a [`DbContext`](#interface-dbcontext). Unlike the other context types, `SubscriptionEventContext` doesn't have an `event` field. `SubscriptionEventContext`s are passed to subscription [`onApplied`](#callback-onapplied) and [`unsubscribeThen`](#method-unsubscribethen) callbacks. -spacetimeDBClient.onConnect((token, identity, address) => { - spacetimeDBClient.subscribe(['SELECT * FROM Person']); +| Name | Description | +|-------------------------------------|------------------------------------------------------------| +| [`db` field](#field-db) | Provides access to the client cache. | +| [`reducers` field](#field-reducers) | Allows requesting reducers run on the remote database. | - setTimeout(() => { - console.log(Person.findById(0)); // prints a `Person` row with id 0. - }, 5000); -}); -``` +### Field `db` ---- +```typescript +class SubscriptionEventContext { + public db: RemoteTables +} +``` -### {Table} fromValue +The `db` field of the context provides access to the subscribed view of the remote database's tables. See [Access the client cache](#access-the-client-cache). -Deserialize an `AlgebraicType` into this `{Table}`. +### Field `reducers` -```ts - {Table}.fromValue(value: AlgebraicValue): {Table} +```typescript +class SubscriptionEventContext { + public reducers: RemoteReducers +} ``` -#### Parameters - -| Name | Type | -| :------ | :--------------- | -| `value` | `AlgebraicValue` | +The `reducers` field of the context provides access to reducers exposed by the remote module. See [Observe and invoke reducers](#observe-and-invoke-reducers). -#### Returns +## Type `ErrorContext` -`{Table}` +An `ErrorContext` is a [`DbContext`](#interface-dbcontext) augmented with a field `event: Error`. `ErrorContext`s are to connections' [`onDisconnect`](#callback-ondisconnect) and [`onConnectError`](#callback-onconnecterror) callbacks, and to subscriptions' [`onError`](#callback-onerror) callbacks. ---- +| Name | Description | +|-------------------------------------|--------------------------------------------------------| +| [`event` field](#field-event) | The error which caused the current error callback. | +| [`db` field](#field-db) | Provides access to the client cache. | +| [`reducers` field](#field-reducers) | Allows requesting reducers run on the remote database. | -### {Table} getAlgebraicType -Serialize `this` into an `AlgebraicType`. +### Field `event` -#### Example - -```ts -{Table}.getAlgebraicType(): AlgebraicType +```typescript +class ErrorContext { + public event: Error +} ``` -#### Returns - -`AlgebraicType` - ---- - -### {Table} onInsert +### Field `db` -Register an `onInsert` callback for when a subscribed row is newly inserted into the database. - -```ts -{Table}.onInsert(callback: (value: {Table}, reducerEvent: ReducerEvent | undefined) => void): void +```typescript +class ErrorContext { + public db: RemoteTables +} ``` -#### Parameters +The `db` field of the context provides access to the subscribed view of the remote database's tables. See [Access the client cache](#access-the-client-cache). -| Name | Type | Description | -| :--------- | :---------------------------------------------------------------------------- | :----------------------------------------------------- | -| `callback` | (`value`: `{Table}`, `reducerEvent`: `undefined` \| `ReducerEvent`) => `void` | Callback to run whenever a subscribed row is inserted. | +### Field `reducers` -#### Example - -```ts -var spacetimeDBClient = new SpacetimeDBClient( - 'ws://localhost:3000', - 'database_name' -); -spacetimeDBClient.onConnect((token, identity, address) => { - spacetimeDBClient.subscribe(['SELECT * FROM Person']); -}); - -Person.onInsert((person, reducerEvent) => { - if (reducerEvent) { - console.log('New person inserted by reducer', reducerEvent, person); - } else { - console.log('New person received during subscription update', person); - } -}); +```typescript +class ErrorContext { + public reducers: RemoteReducers +} ``` ---- - -### {Table} removeOnInsert +The `reducers` field of the context provides access to reducers exposed by the remote module. See [Observe and invoke reducers](#observe-and-invoke-reducers). -Unregister a previously-registered [`onInsert`](#table-oninsert) callback. +## Access the client cache -```ts -{Table}.removeOnInsert(callback: (value: Person, reducerEvent: ReducerEvent | undefined) => void): void -``` - -#### Parameters +All [`DbContext`](#interface-dbcontext) implementors, including [`DbConnection`](#type-dbconnection) and [`EventContext`](#type-eventcontext), have fields `.db`, which in turn has methods for accessing tables in the client cache. -| Name | Type | -| :--------- | :---------------------------------------------------------------------------- | -| `callback` | (`value`: `{Table}`, `reducerEvent`: `undefined` \| `ReducerEvent`) => `void` | +Each table defined by a module has an accessor method, whose name is the table name converted to `camelCase`, on this `.db` field. The table accessor methods return table handles. Table handles have methods for [accessing rows](#accessing-rows) and [registering `onInsert`](#callback-oninsert) and [`onDelete` callbacks](#callback-ondelete). Handles for tables which have a declared primary key field also expose [`onUpdate` callbacks](#callback-onupdate). Table handles also offer the ability to find subscribed rows by unique index. ---- +| Name | Description | +|--------------------------------------------------------|---------------------------------------------------------------------------------| +| [Accessing rows](#accessing-rows) | Iterate over or count subscribed rows. | +| [`onInsert` callback](#callback-oninsert) | Register a function to run when a row is added to the client cache. | +| [`onDelete` callback](#callback-ondelete) | Register a function to run when a row is removed from the client cache. | +| [`onUpdate` callback](#callback-onupdate) | Register a functioNto run when a subscribed row is replaced with a new version. | +| [Unique index access](#unique-constraint-index-access) | Seek a subscribed row by the value in its unique or primary key column. | +| [BTree index access](#btree-index-access) | Not supported. | -### {Table} onUpdate +### Accessing rows -Register an `onUpdate` callback to run when an existing row is modified by primary key. +#### Method `count` -```ts -{Table}.onUpdate(callback: (oldValue: {Table}, newValue: {Table}, reducerEvent: ReducerEvent | undefined) => void): void +```typescript +class TableHandle { + public count(): number +} ``` -`onUpdate` callbacks are only meaningful for tables with a column declared as a primary key. Tables without primary keys will never fire `onUpdate` callbacks. - -#### Parameters - -| Name | Type | Description | -| :--------- | :------------------------------------------------------------------------------------------------------ | :---------------------------------------------------- | -| `callback` | (`oldValue`: `{Table}`, `newValue`: `{Table}`, `reducerEvent`: `undefined` \| `ReducerEvent`) => `void` | Callback to run whenever a subscribed row is updated. | +Returns the number of rows of this table resident in the client cache, i.e. the total number which match any subscribed query. -#### Example +#### Method `iter` -```ts -var spacetimeDBClient = new SpacetimeDBClient( - 'ws://localhost:3000', - 'database_name' -); -spacetimeDBClient.onConnect((token, identity, address) => { - spacetimeDBClient.subscribe(['SELECT * FROM Person']); -}); - -Person.onUpdate((oldPerson, newPerson, reducerEvent) => { - console.log('Person updated by reducer', reducerEvent, oldPerson, newPerson); -}); +```typescript +class TableHandle { + public iter(): Iterable +} ``` ---- +An iterator over all the subscribed rows in the client cache, i.e. those which match any subscribed query. -### {Table} removeOnUpdate +The `Row` type will be an autogenerated type which matches the row type defined by the module. -Unregister a previously-registered [`onUpdate`](#table-onupdate) callback. +### Callback `onInsert` -```ts -{Table}.removeOnUpdate(callback: (oldValue: {Table}, newValue: {Table}, reducerEvent: ReducerEvent | undefined) => void): void +```typescript +class TableHandle { + public onInsert( + callback: (ctx: EventContext, row: Row) => void + ): void; + + public removeOnInsert( + callback: (ctx: EventContext, row: Row) => void + ): void; +} ``` -#### Parameters - -| Name | Type | -| :--------- | :------------------------------------------------------------------------------------------------------ | -| `callback` | (`oldValue`: `{Table}`, `newValue`: `{Table}`, `reducerEvent`: `undefined` \| `ReducerEvent`) => `void` | +The `onInsert` callback runs whenever a new row is inserted into the client cache, either when applying a subscription or being notified of a transaction. The passed [`EventContext`](#type-eventcontext) contains an [`Event`](#type-event) which can identify the change which caused the insertion, and also allows the callback to interact with the connection, inspect the client cache and invoke reducers. ---- +The `Row` type will be an autogenerated type which matches the row type defined by the module. -### {Table} onDelete +`removeOnInsert` may be used to un-register a previously-registered `onInsert` callback. -Register an `onDelete` callback for when a subscribed row is removed from the database. +### Callback `onDelete` -```ts -{Table}.onDelete(callback: (value: {Table}, reducerEvent: ReducerEvent | undefined) => void): void +```typescript +class TableHandle { + public onDelete( + callback: (ctx: EventContext, row: Row) => void + ): void; + + public removeOnDelete( + callback: (ctx: EventContext, row: Row) => void + ): void; +} ``` -#### Parameters +The `onDelete` callback runs whenever a previously-resident row is deleted from the client cache. -| Name | Type | Description | -| :--------- | :---------------------------------------------------------------------------- | :---------------------------------------------------- | -| `callback` | (`value`: `{Table}`, `reducerEvent`: `undefined` \| `ReducerEvent`) => `void` | Callback to run whenever a subscribed row is removed. | +The `Row` type will be an autogenerated type which matches the row type defined by the module. -#### Example +`removeOnDelete` may be used to un-register a previously-registered `onDelete` callback. -```ts -var spacetimeDBClient = new SpacetimeDBClient( - 'ws://localhost:3000', - 'database_name' -); -spacetimeDBClient.onConnect((token, identity, address) => { - spacetimeDBClient.subscribe(['SELECT * FROM Person']); -}); +### Callback `onUpdate` -Person.onDelete((person, reducerEvent) => { - if (reducerEvent) { - console.log('Person deleted by reducer', reducerEvent, person); - } else { - console.log( - 'Person no longer subscribed during subscription update', - person - ); - } -}); +```typescript +class TableHandle { + public onUpdate( + callback: (ctx: EventContext, old: Row, new: Row) => void + ): void; + + public removeOnUpdate( + callback: (ctx: EventContext, old: Row, new: Row) => void + ): void; +} ``` ---- - -### {Table} removeOnDelete +The `onUpdate` callback runs whenever an already-resident row in the client cache is updated, i.e. replaced with a new row that has the same primary key. -Unregister a previously-registered [`onDelete`](#table-ondelete) callback. +Only tables with a declared primary key expose `onUpdate` callbacks. Handles for tables without a declared primary key will not have `onUpdate` or `removeOnUpdate` methods. -```ts -{Table}.removeOnDelete(callback: (value: {Table}, reducerEvent: ReducerEvent | undefined) => void): void -``` +The `Row` type will be an autogenerated type which matches the row type defined by the module. -#### Parameters +`removeOnUpdate` may be used to un-register a previously-registered `onUpdate` callback. -| Name | Type | -| :--------- | :---------------------------------------------------------------------------- | -| `callback` | (`value`: `{Table}`, `reducerEvent`: `undefined` \| `ReducerEvent`) => `void` | +### Unique constraint index access -### Class `{Reducer}` +For each unique constraint on a table, its table handle has a field whose name is the unique column name. This field is a unique index handle. The unique index handle has a method `.find(desiredValue: Col) -> Row | undefined`, where `Col` is the type of the column, and `Row` the type of rows. If a row with `desiredValue` in the unique column is resident in the client cache, `.find` returns it. -`spacetime generate` defines an `{Reducer}` class in the `module_bindings` folder for each reducer defined by a module. +### BTree index access -The class's name will be the reducer's name converted to `PascalCase`. +The SpacetimeDB TypeScript client SDK does not support non-unique BTree indexes. -| Static methods | Description | -| ------------------------------- | ------------------------------------------------------------ | -| [`Reducer.call`](#reducer-call) | Executes the reducer. | -| Events | | -| [`Reducer.on`](#reducer-on) | Register a callback to run each time the reducer is invoked. | +## Observe and invoke reducers -## Static methods +All [`DbContext`](#interface-dbcontext) implementors, including [`DbConnection`](#type-dbconnection) and [`EventContext`](#type-eventcontext), have fields `.reducers`, which in turn has methods for invoking reducers defined by the module and registering callbacks on it. -### {Reducer} call +Each reducer defined by the module has three methods on the `.reducers`: -Executes the reducer. +- An invoke method, whose name is the reducer's name converted to camel case, like `setName`. This requests that the module run the reducer. +- A callback registation method, whose name is prefixed with `on`, like `onSetName`. This registers a callback to run whenever we are notified that the reducer ran, including successfully committed runs and runs we requested which failed. This method returns a callback id, which can be passed to the callback remove method. +- A callback remove method, whose name is prefixed with `removeOn`, like `removeOnSetName`. This cancels a callback previously registered via the callback registration method. -```ts -{Reducer}.call(): void -``` +## Identify a client -#### Example +### Type `Identity` -```ts -SayHelloReducer.call(); +```rust +Identity ``` -## Events - -### {Reducer} on +A unique public identifier for a client connected to a database. -Register a callback to run each time the reducer is invoked. +### Type `ConnectionId` -```ts -{Reducer}.on(callback: (reducerEvent: ReducerEvent, ...reducerArgs: any[]) => void): void +```rust +ConnectionId ``` -Clients will only be notified of reducer runs if either of two criteria is met: - -- The reducer inserted, deleted or updated at least one row to which the client is subscribed. -- The reducer invocation was requested by this client, and the run failed. - -#### Parameters - -| Name | Type | -| :--------- | :------------------------------------------------------------- | -| `callback` | `(reducerEvent: ReducerEvent, ...reducerArgs: any[]) => void)` | - -#### Example - -```ts -SayHelloReducer.on((reducerEvent, ...reducerArgs) => { - console.log('SayHelloReducer called', reducerEvent, reducerArgs); -}); -``` +An opaque identifier for a client connection to a database, intended to differentiate between connections from the same [`Identity`](#type-identity). diff --git a/docs/sql/index.md b/docs/sql/index.md index 59e90ca7..807af409 100644 --- a/docs/sql/index.md +++ b/docs/sql/index.md @@ -441,7 +441,7 @@ HEXIT ; ``` -Hex literals can represent [Identity], [Address], or binary types. +Hex literals can represent [Identity], [ConnectionId], or binary types. The type is ultimately inferred from the context. #### Examples @@ -645,4 +645,4 @@ column [cli]: /docs/cli-reference.md#spacetime-sql [Identity]: /docs/index.md#identity -[Address]: /docs/index.md#address +[ConnectionId]: /docs/index.md#connectionid From 11f50c271f2e0a4a98dbe9928dc4e1c671b18411 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Mon, 3 Mar 2025 15:27:33 -0500 Subject: [PATCH 115/164] How-to: Incremental Migrations (#127) * How-to: Incremental Migrations This commit adds a how-to guide for defining "incremental migrations," a strategy for updating the schema of a database while maintaining compatibility with outdated clients and without requiring a manual migration. The code is not on GitHub yet, as I'd like review on my choice of example before pushing the repository. As such, the links to the code at the bottom of the new document are broken. * Updates following review --- docs/how-to/incremental-migrations.md | 369 ++++++++++++++++++++++++++ docs/nav.js | 2 + nav.ts | 3 + 3 files changed, 374 insertions(+) create mode 100644 docs/how-to/incremental-migrations.md diff --git a/docs/how-to/incremental-migrations.md b/docs/how-to/incremental-migrations.md new file mode 100644 index 00000000..3f9106b1 --- /dev/null +++ b/docs/how-to/incremental-migrations.md @@ -0,0 +1,369 @@ +# Incremental Migrations + +SpacetimeDB does not provide built-in support for general schema-modifying migrations. It does, however, allow adding new tables, and changing reducers' definitions in arbitrary ways. It's possible to run general migrations using an external tool, but this is tedious, necessitates downtime, and imposes the requirement that you update all your clients at the same time as publishing your new module version. + +Our friends at [Lightfox Games](https://www.lightfoxgames.com/) taught us a pattern they call "incremental migrations," which mitigates all these problems, and works perfectly with SpacetimeDB's capabilities. The short version is that, instead of altering an existing table, you add a new table with the desired new schema. Whenever your module wants to access a row from that table, it first checks the new table. If the row is present in the new table, then you've already migrated, so do whatever you want to do. If the new table doesn't have the row, instead look it up in the old table, compute and insert a row for the new table, and use that. (If the row isn't present in either the old or new table, it's just not present.) If possible, you should also update the row in the old table to match any mutations that happen in the new table, so that outdated clients can still function. + +This has several advantages: +- SpacetimeDB's module hotswapping makes this a zero-downtime update. Write your new module, `spacetime publish` it, and watch the new table populate as it's used. +- It amortizes the cost of transforming rows or computing new columns across many transactions. Rows will only be added to the new table when they're needed. +- In many cases, old clients from before the update can coexist with new clients that use the new table. You can publish the updated module without disconnecting your clients, roll out the client update through normal channels, and allow your users to update at their own pace. + +For example, imagine we have a table `player` which stores information about our players: + + + +```rust +#[spacetimedb::table(name = character, public)] +pub struct Character { + #[primary_key] + player_id: Identity, + #[unique] + nickname: String, + level: u32, + class: Class, +} + +#[derive(SpacetimeType, Debug, Copy, Clone)] +pub enum Class { + Fighter, + Caster, + Medic, +} +``` + +We'll write a few helper functions and some simple reducers: + +```rust +#[spacetimedb::reducer] +fn create_character(ctx: &ReducerContext, class: Class, nickname: String) { + log::info!( + "Creating new level 1 {class:?} named {nickname}", + ); + ctx.db.character().insert(Character { + player_id: ctx.sender, + nickname, + level: 1, + class, + }); +} + +fn find_character_for_player(ctx: &ReducerContext) -> Character { + ctx.db + .character() + .player_id() + .find(ctx.sender) + .expect("Player has not created a character") +} + +fn update_character(ctx: &ReducerContext, character: Character) { + ctx.db.character().player_id().update(character); +} + +#[spacetimedb::reducer] +fn rename_character(ctx: &ReducerContext, new_name: String) { + let character = find_character_for_player(ctx); + log::info!( + "Renaming {} to {}", + character.nickname, + new_name, + ); + update_character( + ctx, + Character { + nickname: new_name, + ..character + }, + ); +} + +#[spacetimedb::reducer] +fn level_up_character(ctx: &ReducerContext) { + let character = find_character_for_player(ctx); + log::info!( + "Leveling up {} from {} to {}", + character.nickname, + character.level, + character.level + 1, + ); + update_character( + ctx, + Character { + level: character.level + 1, + ..character + }, + ); +} +``` + +We'll play around a bit with `spacetime call` to set up a character: + +```sh +$ spacetime logs incr-migration-demo -f & + +$ spacetime call incr-migration-demo create_character '{ "Fighter": {} }' "Phoebe" + +2025-01-07T15:32:57.447286Z INFO: src/lib.rs:21: Creating new level 1 Fighter named Phoebe + +$ spacetime call -s local incr-migration-demo rename_character "Gefjon" + +2025-01-07T15:33:48.966134Z INFO: src/lib.rs:48: Renaming Phoebe to Gefjon + +$ spacetime call -s local incr-migration-demo level_up_character + +2025-01-07T15:34:01.437495Z INFO: src/lib.rs:66: Leveling up Gefjon from 1 to 2 + +$ spacetime sql incr-migration-demo 'SELECT * FROM character' + + player_id | nickname | level | class +-----------+----------+-------+---------------- + | "Gefjon" | 2 | (Fighter = ()) +``` + +See [the SATS JSON reference](/docs/sats-json) for more on the encoding of arguments to `spacetime call`. + +Now we want to add a new feature: each player should be able to align themselves with the forces of good or evil, so we can get some healthy competition going between our players. We'll start each character off with `Alliance::Neutral`, and then offer them a reducer `choose_alliance` to set it to either `Alliance::Good` or `Alliance::Evil`. Our first attempt will be to add a new column to the type `Character`: + +```rust +#[spacetimedb::table(name = character, public)] +struct Character { + #[primary_key] + player_id: Identity, + nickname: String, + level: u32, + class: Class, + alliance: Alliance, +} + +#[derive(SpacetimeType, Debug, Copy, Clone)] +enum Alliance { + Good, + Neutral, + Evil, +} + +#[spacetimedb::reducer] +fn choose_alliance(ctx: &ReducerContext, alliance: Alliance) { + let character = find_character_for_player(ctx); + log::info!( + "Setting {}'s alliance to {:?} for player {}", + character.nickname, + alliance, + ctx.sender, + ); + update_character( + ctx, + Character { + alliance, + ..character + }, + ); +} +``` + +But that will fail, since SpacetimeDB doesn't know how to update our existing `character` rows with the new column: + +``` +Error: Database update rejected: Errors occurred: +Adding a column alliance to table character requires a manual migration +``` + +Instead, we'll add a new table, `character_v2`, which will coexist with our original `character` table: + +```rust +#[spacetimedb::table(name = character_v2, public)] +struct CharacterV2 { + #[primary_key] + player_id: Identity, + nickname: String, + level: u32, + class: Class, + alliance: Alliance, +} +``` + +When a new player creates a character, we'll make rows in both tables for them. This way, any old clients that are still subscribing to the original `character` table will continue to work, though of course they won't know about the character's alliance. + +```rust +#[spacetimedb::reducer] +fn create_character(ctx: &ReducerContext, class: Class, nickname: String) { + log::info!( + "Creating new level 1 {class:?} named {nickname} for player {}", + ctx.sender, + ); + + ctx.db.character().insert(Character { + player_id: ctx.sender, + nickname: nickname.clone(), + level: 1, + class, + }); + + ctx.db.character_v2().insert(CharacterV2 { + player_id: ctx.sender, + nickname, + level: 1, + class, + alliance: Alliance::Neutral, + }); +} +``` + +We'll update our helper functions so that they operate on `character_v2` rows. In `find_character_for_player`, if we don't see the player's row in `character_v2`, we'll migrate it from `character` on the fly. In this case, we'll make the player neutral, since they haven't chosen an alliance yet. + +```rust +fn find_character_for_player(ctx: &ReducerContext) -> CharacterV2 { + if let Some(character) = ctx.db.character_v2().player_id().find(ctx.sender) { + // Already migrated; just return the new player. + return character; + } + + // Not yet migrated; look up an old character and update it. + let old_character = ctx + .db + .character() + .player_id() + .find(ctx.sender) + .expect("Player has not created a character"); + + ctx.db.character_v2().insert(CharacterV2 { + player_id: old_character.player_id, + nickname: old_character.nickname, + level: old_character.level, + class: old_character.class, + alliance: Alliance::Neutral, + }) +} +``` + +Just like when creating a new character, when we update a `character_v2` row, we'll also update the old `character` row, so that outdated clients can continue to function. It's very important that we perform the same translation between `character` and `character_v2` rows here as in `create_character` and `find_character_for_player`. + +```rust +fn update_character(ctx: &ReducerContext, character: CharacterV2) { + ctx.db.character().player_id().update(Character { + player_id: character.player_id, + nickname: character.nickname.clone(), + level: character.level, + class: character.class, + }); + ctx.db.character_v2().player_id().update(character); +} +``` + +Then we can make trivial modifications to the callers of `update_character` so that they pass in `CharacterV2` instances: + +```rust +#[spacetimedb::reducer] +fn rename_character(ctx: &ReducerContext, new_name: String) { + let character = find_character_for_player(ctx); + log::info!( + "Renaming {} to {}", + character.nickname, + new_name, + ); + update_character( + ctx, + CharacterV2 { + nickname: new_name, + ..character + }, + ); +} + +#[spacetimedb::reducer] +fn level_up_character(ctx: &ReducerContext) { + let character = find_character_for_player(ctx); + log::info!( + "Leveling up {} from {} to {}", + character.nickname, + character.level, + character.level + 1, + ); + update_character( + ctx, + CharacterV2 { + level: character.level + 1, + ..character + }, + ); +} +``` + +And finally, we can define our new `choose_alliance` reducer: + +```rust +#[spacetimedb::reducer] +fn choose_alliance(ctx: &ReducerContext, alliance: Alliance) { + let character = find_character_for_player(ctx); + log::info!( + "Setting alliance of {} to {:?}", + character.nickname, + alliance, + ); + update_character( + ctx, + CharacterV2 { + alliance, + ..character + }, + ); +} +``` + +A bit more playing around with the CLI will show us that everything works as intended: + +```sh +# Our row in `character` still exists: +$ spacetime sql incr-migration-demo 'SELECT * FROM character' + + player_id | nickname | level | class +-----------+----------+-------+---------------- + | "Gefjon" | 2 | (Fighter = ()) + +# We haven't triggered the "Gefjon" row to migrate yet, so `character_v2` is empty: +$ spacetime sql -s local incr-migration-demo 'SELECT * FROM character_v2' + + player_id | nickname | level | class | alliance +-----------+----------+-------+-------+---------- + +# Accessing our character, e.g. by leveling up, will cause it to migrate into `character_v2`: +$ spacetime call incr-migration-demo level_up_character + +2025-01-07T16:00:20.500600Z INFO: src/lib.rs:110: Leveling up Gefjon from 2 to 3 + +# Now `character_v2` is populated: +$ spacetime sql incr-migration-demo 'SELECT * FROM character_v2' + + player_id | nickname | level | class | alliance +-----------+----------+-------+----------------+---------------- + | "Gefjon" | 3 | (Fighter = ()) | (Neutral = ()) + +# The original row in `character` still got updated by `level_up_character`, +# so outdated clients can continue to function: +$ spacetime sql incr-migration-demo 'SELECT * FROM character' + + player_id | nickname | level | class +-----------+----------+-------+---------------- + | "Gefjon" | 3 | (Fighter = ()) + +# We can set our alliance: +$ spacetime call incr-migration-demo choose_alliance '{ "Good": {} }' + +2025-01-07T16:13:53.816501Z INFO: src/lib.rs:129: Setting alliance of Gefjon to Good + +# And that change shows up in `character_v2`: +$ spacetime sql incr-migration-demo 'SELECT * FROM character_v2' + + player_id | nickname | level | class | alliance +-----------+----------+-------+----------------+------------- + | "Gefjon" | 3 | (Fighter = ()) | (Good = ()) + +# But `character` is not changed, since it doesn't know about alliances: +$ spacetime sql incr-migration-demo 'SELECT * FROM character' + + player_id | nickname | level | class +-----------+----------+-------+---------------- + | "Gefjon" | 3 | (Fighter = ()) +``` + +Now that we know how to define incremental migrations, we can add new features that would seem to require breaking schema changes without cumbersome external migration tools and while maintaining compatibility of outdated clients! The complete for this tutorial is on GitHub in the `clockworklabs/incr-migration-demo` repository, in branches [`v1`](https://github.com/clockworklabs/incr-migration-demo/tree/v1), [`fails-publish`](https://github.com/clockworklabs/incr-migration-demo/tree/fails-publish) and [`v2`](https://github.com/clockworklabs/incr-migration-demo/tree/v2). diff --git a/docs/nav.js b/docs/nav.js index 85697a68..95b1c67e 100644 --- a/docs/nav.js +++ b/docs/nav.js @@ -32,6 +32,8 @@ const nav = { page('Rust Reference', 'sdks/rust', 'sdks/rust/index.md'), page('C# Quickstart', 'sdks/c-sharp/quickstart', 'sdks/c-sharp/quickstart.md'), page('C# Reference', 'sdks/c-sharp', 'sdks/c-sharp/index.md'), + section('How To'), + page('Incremental Migrations', 'how-to/incremental-migrations', 'how-to/incremental-migrations.md'), section('WebAssembly ABI'), page('Module ABI Reference', 'webassembly-abi', 'webassembly-abi/index.md'), section('HTTP API'), diff --git a/nav.ts b/nav.ts index 2de94ab3..97f9dd9d 100644 --- a/nav.ts +++ b/nav.ts @@ -80,6 +80,9 @@ const nav: Nav = { ), page('C# Reference', 'sdks/c-sharp', 'sdks/c-sharp/index.md'), + section('How To'), + page('Incremental Migrations', 'how-to/incremental-migrations', 'how-to/incremental-migrations.md'), + section('WebAssembly ABI'), page('Module ABI Reference', 'webassembly-abi', 'webassembly-abi/index.md'), From e5e885c88f43a1323b8d7b692c36fe567d096e97 Mon Sep 17 00:00:00 2001 From: rekhoff Date: Mon, 3 Mar 2025 12:46:50 -0800 Subject: [PATCH 116/164] Updates to blackhol.io tutorials (#194) Updates to blackholio tutorials Page 3 - Fix duplicate code in Rust "disconnect reducer" instructions. Page 4 - Update use of `CallerIdentity` to `Sender` in C# instructions. --- docs/unity/part-3.md | 6 ------ docs/unity/part-4.md | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/docs/unity/part-3.md b/docs/unity/part-3.md index ecd1990a..f5f49bd4 100644 --- a/docs/unity/part-3.md +++ b/docs/unity/part-3.md @@ -336,12 +336,6 @@ pub fn disconnect(ctx: &ReducerContext) -> Result<(), String> { ctx.db.logged_out_player().insert(player); ctx.db.player().identity().delete(&ctx.sender); - // Remove any circles from the arena - for circle in ctx.db.circle().player_id().filter(&player_id) { - ctx.db.entity().entity_id().delete(&circle.entity_id); - ctx.db.circle().entity_id().delete(&circle.entity_id); - } - Ok(()) } ``` diff --git a/docs/unity/part-4.md b/docs/unity/part-4.md index 78c9a3cd..c42b3629 100644 --- a/docs/unity/part-4.md +++ b/docs/unity/part-4.md @@ -198,7 +198,7 @@ Next, add the following reducer to the `Module` class of your `Lib.cs` file. [Reducer] public static void UpdatePlayerInput(ReducerContext ctx, DbVector2 direction) { - var player = ctx.Db.player.identity.Find(ctx.CallerIdentity) ?? throw new Exception("Player not found"); + var player = ctx.Db.player.identity.Find(ctx.Sender) ?? throw new Exception("Player not found"); foreach (var c in ctx.Db.circle.player_id.Filter(player.player_id)) { var circle = c; @@ -210,7 +210,7 @@ public static void UpdatePlayerInput(ReducerContext ctx, DbVector2 direction) } ``` -This is a simple reducer that takes the movement input from the client and applies them to all circles that that player controls. Note that it is not possible for a player to move another player's circles using this reducer, because the `ctx.CallerIdentity` value is not set by the client. Instead `ctx.CallerIdentity` is set by SpacetimeDB after it has authenticated that sender. You can rest assured that the caller has been authenticated as that player by the time this reducer is called. +This is a simple reducer that takes the movement input from the client and applies them to all circles that that player controls. Note that it is not possible for a player to move another player's circles using this reducer, because the `ctx.Sender` value is not set by the client. Instead `ctx.Sender` is set by SpacetimeDB after it has authenticated that sender. You can rest assured that the caller has been authenticated as that player by the time this reducer is called. ::: Finally, let's schedule a reducer to run every 50 milliseconds to move the player's circles around based on the most recently set player input. From 15b42410023d0491aa00c09a1ecac75514e687f2 Mon Sep 17 00:00:00 2001 From: rekhoff Date: Mon, 3 Mar 2025 13:45:08 -0800 Subject: [PATCH 117/164] =?UTF-8?q?Updated=20with=20corrected=20table=20na?= =?UTF-8?q?mes=20to=20lower=20case,=20for=20compatibility=20w=E2=80=A6=20(?= =?UTF-8?q?#195)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updated with corrected table names to lower case, for compatibility with other quickstart-chat languages. Updated with additional changes in https://github.com/clockworklabs/com.clockworklabs.spacetimedbsdk/pull/258 --- docs/modules/c-sharp/quickstart.md | 28 +++++++++++++++------------- docs/sdks/c-sharp/quickstart.md | 15 ++++++++++----- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/docs/modules/c-sharp/quickstart.md b/docs/modules/c-sharp/quickstart.md index e0fbf33e..fafcb380 100644 --- a/docs/modules/c-sharp/quickstart.md +++ b/docs/modules/c-sharp/quickstart.md @@ -82,7 +82,7 @@ For each `User`, we'll store their `Identity`, an optional name they can set to In `server/Lib.cs`, add the definition of the table `User` to the `Module` class: ```csharp -[Table(Name = "User", Public = true)] +[Table(Name = "user", Public = true)] public partial class User { [PrimaryKey] @@ -97,11 +97,11 @@ For each `Message`, we'll store the `Identity` of the user who sent it, the `Tim In `server/Lib.cs`, add the definition of the table `Message` to the `Module` class: ```csharp -[Table(Name = "Message", Public = true)] +[Table(Name = "message", Public = true)] public partial class Message { public Identity Sender; - public long Sent; + public Timestamp Sent; public string Text = ""; } ``` @@ -122,11 +122,11 @@ public static void SetName(ReducerContext ctx, string name) { name = ValidateName(name); - var user = ctx.Db.User.Identity.Find(ctx.Sender); + var user = ctx.Db.user.Identity.Find(ctx.Sender); if (user is not null) { user.Name = name; - ctx.Db.User.Identity.Update(user); + ctx.Db.user.Identity.Update(user); } } ``` @@ -165,12 +165,12 @@ public static void SendMessage(ReducerContext ctx, string text) { text = ValidateMessage(text); Log.Info(text); - ctx.Db.Message.Insert( + ctx.Db.message.Insert( new Message { Sender = ctx.Sender, Text = text, - Sent = ctx.Timestamp.MicrosecondsSinceUnixEpoch, + Sent = ctx.Timestamp, } ); } @@ -210,20 +210,20 @@ In `server/Lib.cs`, add the definition of the connect reducer to the `Module` cl public static void ClientConnected(ReducerContext ctx) { Log.Info($"Connect {ctx.Sender}"); - var user = ctx.Db.User.Identity.Find(ctx.Sender); + var user = ctx.Db.user.Identity.Find(ctx.Sender); if (user is not null) { // 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.Online = true; - ctx.Db.User.Identity.Update(user); + ctx.Db.user.Identity.Update(user); } else { // If this is a new user, create a `User` object for the `Identity`, // which is online, but hasn't set a name. - ctx.Db.User.Insert( + ctx.Db.user.Insert( new User { Name = null, @@ -243,13 +243,13 @@ Add the following code after the `OnConnect` handler: [Reducer(ReducerKind.ClientDisconnected)] public static void ClientDisconnected(ReducerContext ctx) { - var user = ctx.Db.User.Identity.Find(ctx.Sender); + var user = ctx.Db.user.Identity.Find(ctx.Sender); if (user is not null) { // This user should exist, so set `Online: false`. user.Online = false; - ctx.Db.User.Identity.Update(user); + ctx.Db.user.Identity.Update(user); } else { @@ -311,6 +311,8 @@ spacetime sql quickstart-chat "SELECT * FROM Message" ## What's next? -You've just set up your first database in SpacetimeDB! The next step would be to create a client module that interacts with this module. You can use any of SpacetimDB's supported client languages to do this. Take a look at the quick start guide for your client language of choice: [Rust](/docs/sdks/rust/quickstart), [C#](/docs/sdks/c-sharp/quickstart), or [TypeScript](/docs/sdks/typescript/quickstart). +You've just set up your first database in SpacetimeDB! You can find the full code for this client [in the C# server module example](https://github.com/clockworklabs/com.clockworklabs.spacetimedbsdk/tree/master/examples~/quickstart-chat/server). + +The next step would be to create a client module that interacts with this module. You can use any of SpacetimDB's supported client languages to do this. Take a look at the quick start guide for your client language of choice: [Rust](/docs/sdks/rust/quickstart), [C#](/docs/sdks/c-sharp/quickstart), or [TypeScript](/docs/sdks/typescript/quickstart). If you are planning to use SpacetimeDB with the Unity game engine, you can skip right to the [Unity Comprehensive Tutorial](/docs/unity/part-1). diff --git a/docs/sdks/c-sharp/quickstart.md b/docs/sdks/c-sharp/quickstart.md index 759accbe..aba4b77c 100644 --- a/docs/sdks/c-sharp/quickstart.md +++ b/docs/sdks/c-sharp/quickstart.md @@ -71,7 +71,7 @@ using SpacetimeDB.Types; using System.Collections.Concurrent; ``` -We will also need to create some global variables that will be explained when we use them later. +We will also need to create some global variables. We'll cover the `Identity` later in the `Save credentials` section. Later we'll also be setting up a second thread for handling user input. In the `Process thread` section we'll use this in the `ConcurrentQueue` to store the commands for that thread. To `Program.cs`, add: @@ -153,7 +153,7 @@ DbConnection ConnectToDB() .WithToken(AuthToken.Token) .OnConnect(OnConnected) .OnConnectError(OnConnectError) - .OnDisconnect(OnDisconnect) + .OnDisconnect(OnDisconnected) .Build(); return conn; } @@ -198,12 +198,14 @@ To `Program.cs`, add: ```csharp /// Our `OnDisconnect` callback: print a note, then exit the process. -void OnDisconnect(DbConnection conn, Exception? e) +void OnDisconnected(DbConnection conn, Exception? e) { if (e != null) { Console.Write($"Disconnected abnormally: {e}"); - } else { + } + else + { Console.Write($"Disconnected normally."); } } @@ -319,6 +321,9 @@ To `Program.cs`, add: /// Our `Message.OnInsert` callback: print new messages. void Message_OnInsert(EventContext ctx, Message insertedValue) { + // We are filtering out messages inserted during the subscription being applied, + // since we will be printing those in the OnSubscriptionApplied callback, + // where we will be able to first sort the messages before printing. if (ctx.Event is not Event.SubscribeApplied) { PrintMessage(ctx.Db, insertedValue); @@ -551,7 +556,7 @@ dotnet run --project client Congratulations! You've built a simple chat app using SpacetimeDB. -You can find the full code for this client [in the C# client SDK's examples](https://github.com/clockworklabs/com.clockworklabs.spacetimedbsdk/tree/master/examples~/quickstart/client). +You can find the full code for this client [in the C# client SDK's examples](https://github.com/clockworklabs/com.clockworklabs.spacetimedbsdk/tree/master/examples~/quickstart-chat/client). Check out the [C# client SDK Reference](/docs/sdks/c-sharp) for a more comprehensive view of the SpacetimeDB C# client SDK. From 6b44934fb4a10b62f5e0d75dffb80ecca1f53dc4 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Mon, 3 Mar 2025 18:27:50 -0500 Subject: [PATCH 118/164] Small doc fixes potpourri (#198) * Remove hedging about table access from module quickstarts We'll announce RLS when we announce it. For now, what we have is what we have. * Remove hedging about supported module languages This kind of thing belongs in a roadmap, not anywhere else in our docs. * Fix :fingers_crossed: formatting of link to scheduled reducers * Fix link * List module langs in alphabetical order Which also happens to be decreasing order of support and battle-tested-ness * Re-order various and pages in sidebar - Internals get their own section, and move down. - Appendix gets its own section, instead of joining "Subscriptions." - SQL and Subscriptions move up. * Remove outdated guidance about tokens We don't have "SpacetimeDB tokens" anymore, we just have regular OIDC JWTs. We don't need to offer any special guidance about JWT hygiene. --- docs/http/index.md | 4 ---- docs/index.md | 9 +++---- docs/modules/c-sharp/quickstart.md | 2 -- docs/modules/index.md | 9 ------- docs/modules/rust/quickstart.md | 2 -- docs/nav.js | 26 ++++++++++---------- nav.ts | 38 +++++++++++++++--------------- 7 files changed, 37 insertions(+), 53 deletions(-) diff --git a/docs/http/index.md b/docs/http/index.md index 64196fb6..4f0973dc 100644 --- a/docs/http/index.md +++ b/docs/http/index.md @@ -1,9 +1,5 @@ # SpacetimeDB HTTP Authorization -Rather than a password, each Spacetime identity is associated with a private token. These tokens are generated by SpacetimeDB when the corresponding identity is created, and cannot be changed. - -> Do not share your SpacetimeDB token with anyone, ever. - ### Generating identities and tokens SpacetimeDB can derive an identity from the `sub` and `iss` claims of any [OpenID Connect](https://openid.net/developers/how-connect-works/) compliant [JSON Web Token](https://jwt.io/). diff --git a/docs/index.md b/docs/index.md index 9375f847..864f7327 100644 --- a/docs/index.md +++ b/docs/index.md @@ -209,14 +209,15 @@ public static void World(ReducerContext ctx) ``` ::: +:::server-rust While SpacetimeDB doesn't support nested transactions, -a reducer can [schedule another reducer] to run at an interval, +a reducer can [schedule another reducer](https://docs.rs/spacetimedb/latest/spacetimedb/attr.reducer.html#scheduled-reducers) to run at an interval, or at a specific time. -:::server-rust -[schedule another reducer]: /docs/modules/rust#defining-scheduler-tables ::: :::server-csharp -[schedule another reducer]: /docs/modules/c-sharp#scheduler-tables +While SpacetimeDB doesn't support nested transactions, +a reducer can [schedule another reducer](/docs/modules/c-sharp#scheduler-tables) to run at an interval, +or at a specific time. ::: ### Client diff --git a/docs/modules/c-sharp/quickstart.md b/docs/modules/c-sharp/quickstart.md index fafcb380..86bcf16f 100644 --- a/docs/modules/c-sharp/quickstart.md +++ b/docs/modules/c-sharp/quickstart.md @@ -10,8 +10,6 @@ Each table is defined as a C# `class` annotated with `[SpacetimeDB.Table]`, wher By default, tables are **private**. This means that they are only readable by the table owner, and by server module code. The `[SpacetimeDB.Table(Public = true))]` annotation makes a table public. **Public** tables are readable by all users, but can still only be modified by your server module code. -_Coming soon: We plan to add much more robust access controls than just public or private tables. Stay tuned!_ - A reducer is a function which 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. In C#, reducers are defined as functions annotated with `[SpacetimeDB.Reducer]`. If an exception is thrown, the reducer call fails, the database is not updated, and a failed message is reported to the client. ## Install SpacetimeDB diff --git a/docs/modules/index.md b/docs/modules/index.md index 93b74cb3..78d60d9c 100644 --- a/docs/modules/index.md +++ b/docs/modules/index.md @@ -19,12 +19,3 @@ We have C# support available in experimental status. C# can be a good choice for - [C# Module Reference](/docs/modules/c-sharp) - [C# Module Quickstart Guide](/docs/modules/c-sharp/quickstart) - -### Coming Soon - -We have plans to support additional languages in the future. - -- Python -- Typescript -- C++ -- Lua diff --git a/docs/modules/rust/quickstart.md b/docs/modules/rust/quickstart.md index 057b3ad8..04b7d206 100644 --- a/docs/modules/rust/quickstart.md +++ b/docs/modules/rust/quickstart.md @@ -11,8 +11,6 @@ Each table is defined as a Rust struct annotated with `#[table(name = table_name By default, tables are **private**. This means that they are only readable by the table owner, and by server module code. 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. -_Coming soon: We plan to add much more robust access controls than just public or private. Stay tuned!_ - 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. In Rust, reducers are defined as functions annotated with `#[reducer]`, and may return a `Result<()>`, with an `Err` return aborting the transaction. ## Install SpacetimeDB diff --git a/docs/nav.js b/docs/nav.js index 95b1c67e..7c02811e 100644 --- a/docs/nav.js +++ b/docs/nav.js @@ -26,27 +26,27 @@ const nav = { page('C# Reference', 'modules/c-sharp', 'modules/c-sharp/index.md'), section('Client SDK Languages'), page('Overview', 'sdks', 'sdks/index.md'), - page('Typescript Quickstart', 'sdks/typescript/quickstart', 'sdks/typescript/quickstart.md'), - page('Typescript Reference', 'sdks/typescript', 'sdks/typescript/index.md'), - page('Rust Quickstart', 'sdks/rust/quickstart', 'sdks/rust/quickstart.md'), - page('Rust Reference', 'sdks/rust', 'sdks/rust/index.md'), page('C# Quickstart', 'sdks/c-sharp/quickstart', 'sdks/c-sharp/quickstart.md'), page('C# Reference', 'sdks/c-sharp', 'sdks/c-sharp/index.md'), + page('Rust Quickstart', 'sdks/rust/quickstart', 'sdks/rust/quickstart.md'), + page('Rust Reference', 'sdks/rust', 'sdks/rust/index.md'), + page('TypeScript Quickstart', 'sdks/typescript/quickstart', 'sdks/typescript/quickstart.md'), + page('TypeScript Reference', 'sdks/typescript', 'sdks/typescript/index.md'), + section('SQL'), + page('SQL Reference', 'sql', 'sql/index.md'), + section('Subscriptions'), + page('Subscription Reference', 'subscriptions', 'subscriptions/index.md'), section('How To'), page('Incremental Migrations', 'how-to/incremental-migrations', 'how-to/incremental-migrations.md'), - section('WebAssembly ABI'), - page('Module ABI Reference', 'webassembly-abi', 'webassembly-abi/index.md'), section('HTTP API'), page('HTTP', 'http', 'http/index.md'), page('`/identity`', 'http/identity', 'http/identity.md'), page('`/database`', 'http/database', 'http/database.md'), - section('Data Format'), - page('SATS-JSON', 'sats-json', 'sats-json.md'), - page('BSATN', 'bsatn', 'bsatn.md'), - section('SQL'), - page('SQL Reference', 'sql', 'sql/index.md'), - section('Subscriptions'), - page('Subscription Reference', 'subscriptions', 'subscriptions/index.md'), + section('Internals'), + page('Module ABI Reference', 'webassembly-abi', 'webassembly-abi/index.md'), + page('SATS-JSON Data Format', 'sats-json', 'sats-json.md'), + page('BSATN Data Format', 'bsatn', 'bsatn.md'), + section('Appendix'), page('Appendix', 'appendix', 'appendix.md'), ], }; diff --git a/nav.ts b/nav.ts index 97f9dd9d..b8e38b16 100644 --- a/nav.ts +++ b/nav.ts @@ -65,41 +65,41 @@ const nav: Nav = { section('Client SDK Languages'), page('Overview', 'sdks', 'sdks/index.md'), - page( - 'Typescript Quickstart', - 'sdks/typescript/quickstart', - 'sdks/typescript/quickstart.md' - ), - page('Typescript Reference', 'sdks/typescript', 'sdks/typescript/index.md'), - page('Rust Quickstart', 'sdks/rust/quickstart', 'sdks/rust/quickstart.md'), - page('Rust Reference', 'sdks/rust', 'sdks/rust/index.md'), page( 'C# Quickstart', 'sdks/c-sharp/quickstart', 'sdks/c-sharp/quickstart.md' ), page('C# Reference', 'sdks/c-sharp', 'sdks/c-sharp/index.md'), + page('Rust Quickstart', 'sdks/rust/quickstart', 'sdks/rust/quickstart.md'), + page('Rust Reference', 'sdks/rust', 'sdks/rust/index.md'), + page( + 'TypeScript Quickstart', + 'sdks/typescript/quickstart', + 'sdks/typescript/quickstart.md' + ), + page('TypeScript Reference', 'sdks/typescript', 'sdks/typescript/index.md'), + + section('SQL'), + page('SQL Reference', 'sql', 'sql/index.md'), + + section('Subscriptions'), + page('Subscription Reference', 'subscriptions', 'subscriptions/index.md'), section('How To'), page('Incremental Migrations', 'how-to/incremental-migrations', 'how-to/incremental-migrations.md'), - section('WebAssembly ABI'), - page('Module ABI Reference', 'webassembly-abi', 'webassembly-abi/index.md'), - section('HTTP API'), page('HTTP', 'http', 'http/index.md'), page('`/identity`', 'http/identity', 'http/identity.md'), page('`/database`', 'http/database', 'http/database.md'), - section('Data Format'), - page('SATS-JSON', 'sats-json', 'sats-json.md'), - page('BSATN', 'bsatn', 'bsatn.md'), - - section('SQL'), - page('SQL Reference', 'sql', 'sql/index.md'), + section('Internals'), + page('Module ABI Reference', 'webassembly-abi', 'webassembly-abi/index.md'), + page('SATS-JSON Data Format', 'sats-json', 'sats-json.md'), + page('BSATN Data Format', 'bsatn', 'bsatn.md'), - section('Subscriptions'), - page('Subscription Reference', 'subscriptions', 'subscriptions/index.md'), + section('Appendix'), page('Appendix', 'appendix', 'appendix.md'), ], }; From 9e7bce3da28b0ae87df2551dfaf48c0b688e21a7 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Mon, 3 Mar 2025 18:43:54 -0500 Subject: [PATCH 119/164] Fixes the typescript quickstart for the new subscription API (#161) * Updated quickstart url * DBConnection -> DbConnection for TypeScript SDK * Updated for the subscription update --- docs/sdks/typescript/quickstart.md | 48 +++++++++++++++++++----------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/docs/sdks/typescript/quickstart.md b/docs/sdks/typescript/quickstart.md index 6d905317..d6f73f33 100644 --- a/docs/sdks/typescript/quickstart.md +++ b/docs/sdks/typescript/quickstart.md @@ -384,27 +384,42 @@ module_bindings └── user_type.ts ``` -With `spacetime generate` we have generated TypeScript types derived from the types you specified in your module, which we can conveniently use in our client. We've placed these in the `module_bindings` folder. The main entry to the SpacetimeDB API is the `DBConnection`, a type which manages a connection to a remote database. Let's import it and a few other types into our `client/src/App.tsx`. +With `spacetime generate` we have generated TypeScript types derived from the types you specified in your module, which we can conveniently use in our client. We've placed these in the `module_bindings` folder. The main entry to the SpacetimeDB API is the `DbConnection`, a type which manages a connection to a remote database. Let's import it and a few other types into our `client/src/App.tsx`. ```tsx -import { DBConnection, EventContext, Message, User } from './module_bindings'; +import { DbConnection, EventContext, Message, User } from './module_bindings'; import { Identity } from '@clockworklabs/spacetimedb-sdk'; ``` ## Create your SpacetimeDB client -Now that we've imported the `DBConnection` type, we can use it to connect our app to our module. +Now that we've imported the `DbConnection` type, we can use it to connect our app to our module. Add the following to your `App` function, just below `const [newMessage, setNewMessage] = useState('');`: ```tsx const [connected, setConnected] = useState(false); const [identity, setIdentity] = useState(null); - const [conn, setConn] = useState(null); + const [conn, setConn] = useState(null); useEffect(() => { + const subscribeToQueries = (conn: DbConnection, queries: string[]) => { + let count = 0; + for (const query of queries) { + conn + ?.subscriptionBuilder() + .onApplied(() => { + count++; + if (count === queries.length) { + console.log('SDK client cache initialized.'); + } + }) + .subscribe(query); + } + }; + const onConnect = ( - conn: DBConnection, + conn: DbConnection, identity: Identity, token: string ) => { @@ -415,12 +430,11 @@ Add the following to your `App` function, just below `const [newMessage, setNewM 'Connected to SpacetimeDB with identity:', identity.toHexString() ); - conn - .subscriptionBuilder() - .onApplied(() => { - console.log('SDK client cache initialized.'); - }) - .subscribe(['SELECT * FROM message', 'SELECT * FROM user']); + conn.reducers.onSendMessage(() => { + console.log('Message sent.'); + }); + + subscribeToQueries(conn, ['SELECT * FROM message', 'SELECT * FROM user']); }; const onDisconnect = () => { @@ -428,12 +442,12 @@ Add the following to your `App` function, just below `const [newMessage, setNewM setConnected(false); }; - const onConnectError = (_conn: DBConnection, err: Error) => { + const onConnectError = (_conn: DbConnection, err: Error) => { console.log('Error connecting to SpacetimeDB:', err); }; setConn( - DBConnection.builder() + DbConnection.builder() .withUri('ws://localhost:3000') .withModuleName('quickstart-chat') .withToken(localStorage.getItem('auth_token') || '') @@ -455,12 +469,12 @@ In the `onConnect` function we are also subscribing to the `message` and `user` ### Accessing the Data -Once SpacetimeDB is connected, we can easily access the data in the client cache using our `DBConnection`. The `conn.db` field allows you to access all of the tables of your database. Those tables will contain all data requested by your subscription configuration. +Once SpacetimeDB is connected, we can easily access the data in the client cache using our `DbConnection`. The `conn.db` field allows you to access all of the tables of your database. Those tables will contain all data requested by your subscription configuration. Let's create custom React hooks for the `message` and `user` tables. Add the following code above your `App` component: ```tsx -function useMessages(conn: DBConnection | null): Message[] { +function useMessages(conn: DbConnection | null): Message[] { const [messages, setMessages] = useState([]); useEffect(() => { @@ -491,7 +505,7 @@ function useMessages(conn: DBConnection | null): Message[] { return messages; } -function useUsers(conn: DBConnection | null): Map { +function useUsers(conn: DbConnection | null): Map { const [users, setUsers] = useState>(new Map()); useEffect(() => { @@ -648,7 +662,7 @@ Our `user` table includes all users not just online users, so we want to take ca Here we post a message saying a new user has connected if the user is being added to the `user` table and they're online, or if an existing user's online status is being set to "online". -Note that `onInsert` and `onDelete` callbacks takes two arguments: an `EventContext` and the row. The `EventContext` can be used just like the `DBConnection` and has all the same access functions, in addition to containing information about the event that triggered this callback. For now, we can ignore this argument though, since we have all the info we need in the user rows. +Note that `onInsert` and `onDelete` callbacks takes two arguments: an `EventContext` and the row. The `EventContext` can be used just like the `DbConnection` and has all the same access functions, in addition to containing information about the event that triggered this callback. For now, we can ignore this argument though, since we have all the info we need in the user rows. ## Conclusion From 48355517bfe5d2f9a6adfaacf1fc8712b422085c Mon Sep 17 00:00:00 2001 From: John Detter <4099508+jdetter@users.noreply.github.com> Date: Mon, 3 Mar 2025 17:48:59 -0600 Subject: [PATCH 120/164] Multiplayer bug fix in tutorial (#169) * Multiplayer bug fix in tutorial * Update part-4.md Small fix * removed reference to test input This test input is not used during the tutorial and causes unused variable warnings * Update part-4.md --- docs/unity/part-4.md | 75 +++++++++++++++++++++++++------------------- 1 file changed, 43 insertions(+), 32 deletions(-) diff --git a/docs/unity/part-4.md b/docs/unity/part-4.md index c42b3629..26676126 100644 --- a/docs/unity/part-4.md +++ b/docs/unity/part-4.md @@ -342,43 +342,43 @@ spacetime generate --lang csharp --out-dir ../client-unity/Assets/autogen All that's left is to modify our `PlayerController` on the client to call the `update_player_input` reducer. Open `PlayerController.cs` and add an `Update` function: ```cs - public void Update() +public void Update() +{ + if (!IsLocalPlayer || NumberOfOwnedCircles == 0) + { + return; + } + + if (Input.GetKeyDown(KeyCode.Q)) { - if (!IsLocalPlayer || NumberOfOwnedCircles == 0) + if (LockInputPosition.HasValue) { - return; + LockInputPosition = null; } - - if (Input.GetKeyDown(KeyCode.Q)) + else { - if (LockInputPosition.HasValue) - { - LockInputPosition = null; - } - else - { - LockInputPosition = (Vector2)Input.mousePosition; - } + LockInputPosition = (Vector2)Input.mousePosition; } + } - // Throttled input requests - if (Time.time - LastMovementSendTimestamp >= SEND_UPDATES_FREQUENCY) - { - LastMovementSendTimestamp = Time.time; + // Throttled input requests + if (Time.time - LastMovementSendTimestamp >= SEND_UPDATES_FREQUENCY) + { + LastMovementSendTimestamp = Time.time; - var mousePosition = LockInputPosition ?? (Vector2)Input.mousePosition; - var screenSize = new Vector2 - { - x = Screen.width, - y = Screen.height, - }; - var centerOfScreen = screenSize / 2; - - var direction = (mousePosition - centerOfScreen) / (screenSize.y / 3); - if (testInputEnabled) { direction = testInput; } - GameManager.Conn.Reducers.UpdatePlayerInput(direction); - } - } + var mousePosition = LockInputPosition ?? (Vector2)Input.mousePosition; + var screenSize = new Vector2 + { + x = Screen.width, + y = Screen.height, + }; + var centerOfScreen = screenSize / 2; + + var direction = (mousePosition - centerOfScreen) / (screenSize.y / 3); + if (testInputEnabled) { direction = testInput; } + GameManager.Conn.Reducers.UpdatePlayerInput(direction); + } +} ``` Let's try it out! Press play and roam freely around the arena! Now we're cooking with gas. @@ -423,7 +423,12 @@ pub fn move_all_players(ctx: &ReducerContext, _timer: MoveAllPlayersTimer) -> Re // Handle player input for circle in ctx.db.circle().iter() { - let mut circle_entity = ctx.db.entity().entity_id().find(&circle.entity_id).unwrap(); + let circle_entity = ctx.db.entity().entity_id().find(&circle.entity_id); + if !circle_entity.is_some() { + // This can happen if a circle is eaten by another circle + continue; + } + let mut circle_entity = circle_entity.unwrap(); let circle_radius = mass_to_radius(circle_entity.mass); let direction = circle.direction * circle.speed; let new_pos = @@ -500,7 +505,13 @@ public static void MoveAllPlayers(ReducerContext ctx, MoveAllPlayersTimer timer) // Handle player input foreach (var circle in ctx.Db.circle.Iter()) { - var circle_entity = ctx.Db.entity.entity_id.Find(circle.entity_id) ?? throw new Exception("Circle has no entity"); + var check_entity = ctx.Db.entity.entity_id.Find(circle.entity_id); + if (check_entity == null) + { + // This can happen if the circle has been eaten by another circle. + continue; + } + var circle_entity = check_entity.Value; var circle_radius = MassToRadius(circle_entity.mass); var direction = circle.direction * circle.speed; var new_pos = circle_entity.position + direction * MassToMaxMoveSpeed(circle_entity.mass); From a4bc4c49c2425b2570408f32be4d5f9b3bd8ab66 Mon Sep 17 00:00:00 2001 From: Zeke Foppa <196249+bfops@users.noreply.github.com> Date: Mon, 3 Mar 2025 16:06:20 -0800 Subject: [PATCH 121/164] Add instructions for deploying to maincloud (#167) * [bfops/deploying]: add instructions for deploying mainnet * [bfops/deploying]: nav.ts * [bfops/deploying]: nav.ts * [bfops/deploying]: fix link? * Update docs/deploying/maincloud.md Co-authored-by: John Detter <4099508+jdetter@users.noreply.github.com> * Update docs/deploying/maincloud.md Co-authored-by: John Detter <4099508+jdetter@users.noreply.github.com> * [bfops/deploying]: review * Update docs/deploying/maincloud.md Co-authored-by: Tyler Cloutier * Apply suggestions from code review Co-authored-by: Tyler Cloutier * Add `/profile` as a known link --------- Co-authored-by: Zeke Foppa Co-authored-by: joshua-spacetime Co-authored-by: John Detter <4099508+jdetter@users.noreply.github.com> Co-authored-by: Tyler Cloutier Co-authored-by: Phoebe Goldman --- docs/deploying/maincloud.md | 30 ++++++++++++++++++++++++++++++ docs/nav.js | 2 ++ nav.ts | 3 +++ scripts/checkLinks.ts | 2 +- 4 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 docs/deploying/maincloud.md diff --git a/docs/deploying/maincloud.md b/docs/deploying/maincloud.md new file mode 100644 index 00000000..ea14ebbd --- /dev/null +++ b/docs/deploying/maincloud.md @@ -0,0 +1,30 @@ +# Deploy to Maincloud + +Maincloud is a managed cloud service that provides developers an easy way to deploy their SpacetimeDB apps to the cloud. + +## Deploy via CLI + +1. Install the SpacetimeDB CLI for your platform: [Install SpacetimeDB](/install) +1. Create your module (see [Getting Started](/docs/getting-started)) +1. Publish to Maincloud: + +```bash +spacetime publish -s maincloud my-cool-module +``` + +## Connecting your Identity to the Web Dashboard + +By logging in your CLI via spacetimedb.com, you can view your published modules on the web dashboard. + +If you did not log in with spacetimedb.com when publishing your module, you can log in by running: +```bash +spacetime logout +spacetime login +``` + +1. Open the SpacetimeDB website and log in using your GitHub login. +1. You should now be able to see your published modules [by navigating to your profile on the website](/profile). + +--- + +With SpacetimeDB Maincloud, you benefit from automatic scaling, robust security, and the convenience of not having to manage the hosting environment. diff --git a/docs/nav.js b/docs/nav.js index 7c02811e..aed58053 100644 --- a/docs/nav.js +++ b/docs/nav.js @@ -9,6 +9,8 @@ const nav = { section('Intro'), page('Overview', 'index', 'index.md'), // TODO(BREAKING): For consistency & clarity, 'index' slug should be renamed 'intro'? page('Getting Started', 'getting-started', 'getting-started.md'), + section('Deploying'), + page('Maincloud', 'deploying/maincloud', 'deploying/maincloud.md'), section('Unity Tutorial - Basic Multiplayer'), page('Overview', 'unity', 'unity/index.md'), page('1 - Setup', 'unity/part-1', 'unity/part-1.md'), diff --git a/nav.ts b/nav.ts index b8e38b16..4de5dae3 100644 --- a/nav.ts +++ b/nav.ts @@ -33,6 +33,9 @@ const nav: Nav = { page('Overview', 'index', 'index.md'), // TODO(BREAKING): For consistency & clarity, 'index' slug should be renamed 'intro'? page('Getting Started', 'getting-started', 'getting-started.md'), + section('Deploying'), + page('Maincloud', 'deploying/maincloud', 'deploying/maincloud.md'), + section('Unity Tutorial - Basic Multiplayer'), page('Overview', 'unity', 'unity/index.md'), page('1 - Setup', 'unity/part-1', 'unity/part-1.md'), diff --git a/scripts/checkLinks.ts b/scripts/checkLinks.ts index 58c94f47..944f67d2 100644 --- a/scripts/checkLinks.ts +++ b/scripts/checkLinks.ts @@ -124,7 +124,7 @@ function checkLinks(): void { return; // Skip external links } - const siteLinks = ['/install', '/images']; + const siteLinks = ['/install', '/images', '/profile']; for (const siteLink of siteLinks) { if (link.startsWith(siteLink)) { return; // Skip site links From 8917a26d30cedfe13a793f236aac43205564f55b Mon Sep 17 00:00:00 2001 From: james gilles Date: Mon, 3 Mar 2025 19:22:40 -0500 Subject: [PATCH 122/164] C# Module Library docs (#193) * Most of the way to C# Module SDK docs * Copy in more docs * Mostly done * Remove dead docs * Apply suggestions from code review Thanks Mazdak, also going to apply some of these to the Rust modules. Co-authored-by: Mazdak Farrokhzad Co-authored-by: joshua-spacetime * Address review comments --------- Co-authored-by: Mazdak Farrokhzad Co-authored-by: joshua-spacetime --- docs/modules/c-sharp/index.md | 1467 +++++++++++++++++++++++++++------ 1 file changed, 1221 insertions(+), 246 deletions(-) diff --git a/docs/modules/c-sharp/index.md b/docs/modules/c-sharp/index.md index 40a25e09..fc2acc95 100644 --- a/docs/modules/c-sharp/index.md +++ b/docs/modules/c-sharp/index.md @@ -1,419 +1,1394 @@ -# SpacetimeDB C# Modules +# SpacetimeDB C# Module Library + +[SpacetimeDB](https://spacetimedb.com/) allows using the C# language to write server-side applications called **modules**. Modules, which run inside a relational database, have direct access to database tables, and expose public functions called **reducers** that can be invoked over the network. Clients connect directly to the database to read data. + +```text + Client Application SpacetimeDB +┌───────────────────────┐ ┌───────────────────────┐ +│ │ │ │ +│ ┌─────────────────┐ │ SQL Query │ ┌─────────────────┐ │ +│ │ Subscribed Data │<─────────────────────│ Database │ │ +│ └─────────────────┘ │ │ └─────────────────┘ │ +│ │ │ │ ^ │ +│ │ │ │ │ │ +│ v │ │ v │ +│ +─────────────────┐ │ call_reducer() │ ┌─────────────────┐ │ +│ │ Client Code │─────────────────────>│ Module Code │ │ +│ └─────────────────┘ │ │ └─────────────────┘ │ +│ │ │ │ +└───────────────────────┘ └───────────────────────┘ +``` -You can use the [C# SpacetimeDB library](https://github.com/clockworklabs/SpacetimeDBLibCSharp) to write modules in C# which interact with the SpacetimeDB database. +C# modules are written with the the C# Module Library (this package). They are built using the [dotnet CLI tool](https://learn.microsoft.com/en-us/dotnet/core/tools/) and deployed using the [`spacetime` CLI tool](https://spacetimedb.com/install). C# modules can import any [NuGet package](https://www.nuget.org/packages) that supports being compiled to WebAssembly. -It uses [Roslyn incremental generators](https://github.com/dotnet/roslyn/blob/main/docs/features/incremental-generators.md) to add extra static methods to types, tables and reducers marked with special attributes and registers them with the database runtime. +(Note: C# can also be used to write **clients** of SpacetimeDB databases, but this requires using a different library, the SpacetimeDB C# Client SDK. See the documentation on [clients] for more information.) -## Example +This reference assumes you are familiar with the basics of C#. If you aren't, check out the [C# language documentation](https://learn.microsoft.com/en-us/dotnet/csharp/). For a guided introduction to C# Modules, see the [C# Module Quickstart](https://spacetimedb.com/docs/modules/c-sharp/quickstart). -Let's start with a heavily commented version of the default example from the landing page: +# Overview -```csharp -// These imports bring into the scope common APIs you'll need to expose items from your module and to interact with the database runtime. -using SpacetimeDB.Module; -using static SpacetimeDB.Runtime; +SpacetimeDB modules have two ways to interact with the outside world: tables and reducers. + +- [Tables](#tables) store data and optionally make it readable by [clients]. + +- [Reducers](#reducers) are functions that modify data and can be invoked by [clients] over the network. They can read and write data in tables, and write to a private debug log. -// Roslyn generators are statically generating extra code as-if they were part of the source tree, so, -// in order to inject new methods, types they operate on as well as their parents have to be marked as `partial`. -// -// We start with the top-level `Module` class for the module itself. +These are the only ways for a SpacetimeDB module to interact with the outside world. Calling functions from `System.IO` or `System.Net` inside a reducer will result in runtime errors. + +Declaring tables and reducers is straightforward: + +```csharp static partial class Module { - // `[SpacetimeDB.Table]` registers a struct or a class as a SpacetimeDB table. - // - // It generates methods to insert, filter, update, and delete rows of the given type in the table. - [SpacetimeDB.Table(Public = true)] + [SpacetimeDB.Table(Name = "player")] + public partial struct Player + { + public int Id; + public string Name; + } + + [SpacetimeDB.Reducer] + public static void AddPerson(ReducerContext ctx, int Id, string Name) { + ctx.Db.player.Insert(new Player { Id = Id, Name = Name }); + } +} +``` + + +Note that reducers don't return data directly; they can only modify the database. Clients connect directly to the database and use SQL to query [public](#public-and-private-tables) tables. Clients can also subscribe to a set of rows using SQL queries and receive streaming updates whenever any of those rows change. + +Tables and reducers in C# modules can use any type annotated with [`[SpacetimeDB.Type]`](#attribute-spacetimedbtype). + + + +# Setup + +To create a C# module, install the [`spacetime` CLI tool](https://spacetimedb.com/install) in your preferred shell. Navigate to your work directory and run the following command: + +```bash +spacetime init --lang csharp my-project-directory +``` + +This creates a `dotnet` project in `my-project-directory` with the following `StdbModule.csproj`: + +```xml + + + + net8.0 + wasi-wasm + enable + enable + + + + + + + +``` + +This is a standard `csproj`, with the exception of the line `wasi-wasm`. +This line is important: it allows the project to be compiled to a WebAssembly module. + +The project's `Lib.cs` will contain the following skeleton: + +```csharp +public static partial class Module +{ + [SpacetimeDB.Table] public partial struct Person { - // `[SpacetimeDB.Column]` allows to specify column attributes / constraints such as - // "this field should be unique" or "this field should get automatically assigned auto-incremented value". - [SpacetimeDB.Column(ColumnAttrs.Unique | ColumnAttrs.AutoInc)] + [SpacetimeDB.AutoInc] + [SpacetimeDB.PrimaryKey] public int Id; public string Name; public int Age; } - // `[SpacetimeDB.Reducer]` marks a static method as a SpacetimeDB reducer. - // - // Reducers are functions that can be invoked from the database runtime. - // They can't return values, but can throw errors that will be caught and reported back to the runtime. [SpacetimeDB.Reducer] - public static void Add(string name, int age) + public static void Add(ReducerContext ctx, string name, int age) { - // We can skip (or explicitly set to zero) auto-incremented fields when creating new rows. - var person = new Person { Name = name, Age = age }; - - // `Insert()` method is auto-generated and will insert the given row into the table. - person.Insert(); - // After insertion, the auto-incremented fields will be populated with their actual values. - // - // `Log()` function is provided by the runtime and will print the message to the database log. - // It should be used instead of `Console.WriteLine()` or similar functions. - Log($"Inserted {person.Name} under #{person.Id}"); + var person = ctx.Db.Person.Insert(new Person { Name = name, Age = age }); + Log.Info($"Inserted {person.Name} under #{person.Id}"); } [SpacetimeDB.Reducer] - public static void SayHello() + public static void SayHello(ReducerContext ctx) { - // Each table type gets a static Iter() method that can be used to iterate over the entire table. - foreach (var person in Person.Iter()) + foreach (var person in ctx.Db.Person.Iter()) { - Log($"Hello, {person.Name}!"); + Log.Info($"Hello, {person.Name}!"); } - Log("Hello, World!"); + Log.Info("Hello, World!"); + } +} +``` + +This skeleton declares a [table](#tables) and some [reducers](#reducers). + +You can also add some [lifecycle reducers](#lifecycle-reducers) to the `Module` class using the following code: + +```csharp +[Reducer(ReducerKind.Init)] +public static void Init(ReducerContext ctx) +{ + // Run when the module is first loaded. +} + +[Reducer(ReducerKind.ClientConnected)] +public static void ClientConnected(ReducerContext ctx) +{ + // Called when a client connects. +} + +[Reducer(ReducerKind.ClientDisconnected)] +public static void ClientDisconnected(ReducerContext ctx) +{ + // Called when a client connects. +} +``` + + +To compile the project, run the following command: + +```bash +spacetime build +``` + +SpacetimeDB requires a WebAssembly-compatible `dotnet` toolchain. If the `spacetime` cli finds a compatible version of [`dotnet`](https://rustup.rs/) that it can run, it will automatically install the `wasi-experimental` workload and use it to build your application. This can also be done manually using the command: + +```bash +dotnet workload install wasi-experimental +``` + +If you are managing your dotnet installation in some other way, you will need to install the `wasi-experimental` workload yourself. + +To build your application and upload it to the public SpacetimeDB network, run: + +```bash +spacetime login +``` + +And then: + +```bash +spacetime publish [MY_DATABASE_NAME] +``` + +For example: + +```bash +spacetime publish silly_demo_app +``` + +When you publish your module, a database named `silly_demo_app` will be created with the requested tables, and the module will be installed inside it. + +The output of `spacetime publish` will end with a line: +```text +Created new database with name: , identity: +``` + +This name is the human-readable name of the created database, and the hex string is its [`Identity`](#struct-identity). These distinguish the created database from the other databases running on the SpacetimeDB network. They are used when administering the application, for example using the [`spacetime logs `](#class-log) command. You should probably write the database name down in a text file so that you can remember it. + +After modifying your project, you can run: + +`spacetime publish ` + +to update the module attached to your database. Note that SpacetimeDB tries to [automatically migrate](#automatic-migrations) your database schema whenever you run `spacetime publish`. + +You can also generate code for clients of your module using the `spacetime generate` command. See the [client SDK documentation] for more information. + +# How it works + +Under the hood, SpacetimeDB modules are WebAssembly modules that import a [specific WebAssembly ABI](https://spacetimedb.com/docs/webassembly-abi) and export a small number of special functions. This is automatically configured when you add the `SpacetimeDB.Runtime` package as a dependency of your application. + +The SpacetimeDB host is an application that hosts SpacetimeDB databases. [Its source code is available](https://github.com/clockworklabs/SpacetimeDB) under [the Business Source License with an Additional Use Grant](https://github.com/clockworklabs/SpacetimeDB/blob/master/LICENSE.txt). You can run your own host, or you can upload your module to the public SpacetimeDB network. The network will create a database for you and install your module in it to serve client requests. + +## In More Detail: Publishing a Module + +The `spacetime publish [DATABASE_IDENTITY]` command compiles a module and uploads it to a SpacetimeDB host. After this: +- The host finds the database with the requested `DATABASE_IDENTITY`. + - (Or creates a fresh database and identity, if no identity was provided). +- The host loads the new module and inspects its requested database schema. If there are changes to the schema, the host tries perform an [automatic migration](#automatic-migrations). If the migration fails, publishing fails. +- The host terminates the old module attached to the database. +- The host installs the new module into the database. It begins running the module's [lifecycle reducers](#lifecycle-reducers) and [scheduled reducers](#scheduled-reducers), starting with the `Init` reducer. +- The host begins allowing clients to call the module's reducers. + +From the perspective of clients, this process is seamless. Open connections are maintained and subscriptions continue functioning. [Automatic migrations](#automatic-migrations) forbid most table changes except for adding new tables, so client code does not need to be recompiled. +However: +- Clients may witness a brief interruption in the execution of scheduled reducers (for example, game loops.) +- New versions of a module may remove or change reducers that were previously present. Client code calling those reducers will receive runtime errors. + + +# Tables + +Tables are declared using the [`[SpacetimeDB.Table]` attribute](#table-attribute). + +This macro is applied to a C# `partial class` or `partial struct` with named fields. (The `partial` modifier is required to allow code generation to add methods.) All of the fields of the table must be marked with [`[SpacetimeDB.Type]`](#type-attribute). + +The resulting type is used to store rows of the table. It's a normal class (or struct). Row values are not special -- operations on row types do not, by themselves, modify the table. Instead, a [`ReducerContext`](#class-reducercontext) is needed to get a handle to the table. + +```csharp +public static partial class Module { + + /// + /// A Person is a row of the table person. + /// + [SpacetimeDB.Table(Name = "person", Public)] + public partial struct Person { + [SpacetimeDB.PrimaryKey] + [SpacetimeDB.AutoInc] + ulong Id; + [SpacetimeDB.Index.BTree] + string Name; + } + + // `Person` is a normal C# struct type. + // Operations on a `Person` do not, by themselves, do anything. + // The following function does not interact with the database at all. + public static void DoNothing() { + // Creating a `Person` DOES NOT modify the database. + var person = new Person { Id = 0, Name = "Joe Average" }; + // Updating a `Person` DOES NOT modify the database. + person.Name = "Joanna Average"; + // Deallocating a `Person` DOES NOT modify the database. + person = null; + } + + // To interact with the database, you need a `ReducerContext`, + // which is provided as the first parameter of any reducer. + [SpacetimeDB.Reducer] + public static void DoSomething(ReducerContext ctx) { + // The following inserts a row into the table: + var examplePerson = ctx.Db.person.Insert(new Person { id = 0, name = "Joe Average" }); + + // `examplePerson` is a COPY of the row stored in the database. + // If we update it: + examplePerson.name = "Joanna Average".to_string(); + // Our copy is now updated, but the database's copy is UNCHANGED. + // To push our change through, we can call `UniqueIndex.Update()`: + examplePerson = ctx.Db.person.Id.Update(examplePerson); + // Now the database and our copy are in sync again. + + // We can also delete the row in the database using `UniqueIndex.Delete()`. + ctx.Db.person.Id.Delete(examplePerson.Id); } } ``` -## API reference +(See [reducers](#reducers) for more information on declaring reducers.) -Now we'll get into details on all the APIs SpacetimeDB provides for writing modules in C#. +This library generates a custom API for each table, depending on the table's name and structure. -### Logging +All tables support getting a handle implementing the [`ITableView`](#interface-itableview) interface from a [`ReducerContext`](#class-reducercontext), using: -First of all, logging as we're likely going to use it a lot for debugging and reporting errors. +```text +ctx.Db.{table_name} +``` -`SpacetimeDB.Runtime` provides a `Log` function that will print the given message to the database log, along with the source location and a log level it was provided. +For example, -Supported log levels are provided by the `LogLevel` enum: +```csharp +ctx.Db.person +``` + +[Unique and primary key columns](#unique-and-primary-key-columns) and [indexes](#indexes) generate additional accessors, such as `ctx.Db.person.Id` and `ctx.Db.person.Name`. + +## Interface `ITableView` ```csharp -public enum LogLevel +namespace SpacetimeDB.Internal; + +public interface ITableView + where Row : IStructuralReadWrite, new() { - Error, - Warn, - Info, - Debug, - Trace, - Panic + /* ... */ } ``` + + +Implemented for every table handle generated by the [`Table`](#tables) attribute. +For a table named `{name}`, a handle can be extracted from a [`ReducerContext`](#class-reducercontext) using `ctx.Db.{name}`. For example, `ctx.Db.person`. + +Contains methods that are present for every table handle, regardless of what unique constraints +and indexes are present. + +The type `Row` is the type of rows in the table. + +| Name | Description | +| --------------------------------------------- | ----------------------------- | +| [Method `Insert`](#method-itableviewinsert) | Insert a row into the table | +| [Method `Delete`](#method-itableviewdelete) | Delete a row from the table | +| [Method `Iter`](#method-itableviewiter) | Iterate all rows of the table | +| [Property `Count`](#property-itableviewcount) | Count all rows of the table | + +### Method `ITableView.Insert` + +```csharp +Row Insert(Row row); +``` + +Inserts `row` into the table. + +The return value is the inserted row, with any auto-incrementing columns replaced with computed values. +The `insert` method always returns the inserted row, even when the table contains no auto-incrementing columns. + +(The returned row is a copy of the row in the database. +Modifying this copy does not directly modify the database. +See [`UniqueIndex.Update()`](#method-uniqueindexupdate) if you want to update the row.) + +Throws an exception if inserting the row violates any constraints. -If omitted, the log level will default to `Info`, so these two forms are equivalent: +Inserting a duplicate row in a table is a no-op, +as SpacetimeDB is a set-semantic database. + +### Method `ITableView.Delete` ```csharp -Log("Hello, World!"); -Log("Hello, World!", LogLevel.Info); +bool Delete(Row row); ``` -### Supported types +Deletes a row equal to `row` from the table. + +Returns `true` if the row was present and has been deleted, +or `false` if the row was not present and therefore the tables have not changed. -#### Built-in types +Unlike [`Insert`](#method-itableviewinsert), there is no need to return the deleted row, +as it must necessarily have been exactly equal to the `row` argument. +No analogue to auto-increment placeholders exists for deletions. -The following types are supported out of the box and can be stored in the database tables directly or as part of more complex types: +Throws an exception if deleting the row would violate any constraints. -- `bool` -- `byte`, `sbyte` -- `short`, `ushort` -- `int`, `uint` -- `long`, `ulong` -- `float`, `double` -- `string` -- [`Int128`](https://learn.microsoft.com/en-us/dotnet/api/system.int128), [`UInt128`](https://learn.microsoft.com/en-us/dotnet/api/system.uint128) -- `T[]` - arrays of supported values. -- [`List`](https://learn.microsoft.com/en-us/dotnet/api/system.collections.generic.list-1) -- [`Dictionary`](https://learn.microsoft.com/en-us/dotnet/api/system.collections.generic.dictionary-2) +### Method `ITableView.Iter` -And a couple of special custom types: +```csharp +IEnumerable Iter(); +``` -- `SpacetimeDB.SATS.Unit` - semantically equivalent to an empty struct, sometimes useful in generic contexts where C# doesn't permit `void`. -- `Identity` (`SpacetimeDB.Runtime.Identity`) - a unique identifier for each user; internally a byte blob but can be printed, hashed and compared for equality. -- `Address` (`SpacetimeDB.Runtime.Address`) - an identifier which disamgibuates connections by the same `Identity`; internally a byte blob but can be printed, hashed and compared for equality. +Iterate over all rows of the table. -#### Custom types +(This keeps track of changes made to the table since the start of this reducer invocation. For example, if rows have been [deleted](#method-itableviewdelete) since the start of this reducer invocation, those rows will not be returned by `Iter`. Similarly, [inserted](#method-itableviewinsert) rows WILL be returned.) -`[SpacetimeDB.Type]` attribute can be used on any `struct`, `class` or an `enum` to mark it as a SpacetimeDB type. It will implement serialization and deserialization for values of this type so that they can be stored in the database. +For large tables, this can be a slow operation! Prefer [filtering](#method-indexfilter) by an [`Index`](#class-index) or [finding](#method-uniqueindexfind) a [`UniqueIndex`](#class-uniqueindex) if possible. -Any `struct` or `class` marked with this attribute, as well as their respective parents, must be `partial`, as the code generator will add methods to them. +### Property `ITableView.Count` ```csharp -[SpacetimeDB.Type] -public partial struct Point -{ - public int x; - public int y; +ulong Count { get; } +``` + +Returns the number of rows of this table. + +This takes into account modifications by the current transaction, +even though those modifications have not yet been committed or broadcast to clients. +This applies generally to insertions, deletions, updates, and iteration as well. + +## Public and Private Tables + +By default, tables are considered **private**. This means that they are only readable by the database owner and by reducers. Reducers run inside the database, so clients cannot see private tables at all or even know of their existence. + +Using the `[SpacetimeDB.Table(Name = "table_name", Public)]` flag makes a table public. **Public** tables are readable by all clients. They can still only be modified by reducers. + +(Note that, when run by the module owner, the `spacetime sql ` command can also read private tables. This is for debugging convenience. Only the module owner can see these tables. This is determined by the `Identity` stored by the `spacetime login` command. Run `spacetime login show` to print your current logged-in `Identity`.) + +To learn how to subscribe to a public table, see the [client SDK documentation](https://spacetimedb.com/docs/sdks). + +## Unique and Primary Key Columns + +Columns of a table (that is, fields of a [`[Table]`](#tables) struct) can be annotated with `[Unique]` or `[PrimaryKey]`. Multiple columns can be `[Unique]`, but only one can be `[PrimaryKey]`. For example: + +```csharp +[SpacetimeDB.Table(Name = "citizen")] +public partial struct Citizen { + [SpacetimeDB.PrimaryKey] + ulong Id; + + [SpacetimeDB.Unique] + string Ssn; + + [SpacetimeDB.Unique] + string Email; + + string name; } ``` -`enum`s marked with this attribute must not use custom discriminants, as the runtime expects them to be always consecutive starting from zero. Unlike structs and classes, they don't use `partial` as C# doesn't allow to add methods to `enum`s. +Every row in the table `Person` must have unique entries in the `id`, `ssn`, and `email` columns. Attempting to insert multiple `Person`s with the same `id`, `ssn`, or `email` will throw an exception. + +Any `[Unique]` or `[PrimaryKey]` column supports getting a [`UniqueIndex`](#class-uniqueindex) from a [`ReducerContext`](#class-reducercontext) using: + +```text +ctx.Db.{table}.{unique_column} +``` + +For example, ```csharp -[SpacetimeDB.Type] -public enum Color +ctx.Db.citizen.Ssn +``` + +Notice that updating a row is only possible if a row has a unique column -- there is no `update` method in the base [`ITableView`](#interface-itableview) interface. SpacetimeDB has no notion of rows having an "identity" aside from their unique / primary keys. + +The `[PrimaryKey]` annotation implies a `[Unique]` annotation, but avails additional methods in the [client]-side SDKs. + +It is not currently possible to mark a group of fields as collectively unique. + +Filtering on unique columns is only supported for a limited number of types. + +## Class `UniqueIndex` + +```csharp +namespace SpacetimeDB.Internal; + +public abstract class UniqueIndex : IndexBase + where Handle : ITableView + where Row : IStructuralReadWrite, new() + where Column : IEquatable { - Red, - Green, - Blue, + /* ... */ +} +``` + + +A unique index on a column. Available for `[Unique]` and `[PrimaryKey]` columns. +(A custom class derived from `UniqueIndex` is generated for every such column.) + +`Row` is the type decorated with `[SpacetimeDB.Table]`, `Column` is the type of the column, +and `Handle` is the type of the generated table handle. + +For a table *table* with a column *column*, use `ctx.Db.{table}.{column}` +to get a `UniqueColumn` from a [`ReducerContext`](#class-reducercontext). + +Example: + +```csharp +using SpacetimeDB; + +public static partial class Module { + [Table(Name = "user")] + public partial struct User { + [PrimaryKey] + uint Id; + [Unique] + string Username; + ulong DogCount; + } + + [Reducer] + void Demo(ReducerContext ctx) { + var idIndex = ctx.Db.user.Id; + var exampleUser = idIndex.find(357).unwrap(); + exampleUser.dog_count += 5; + idIndex.update(exampleUser); + + var usernameIndex = ctx.Db.user.Username; + usernameIndex.delete("Evil Bob"); + } } ``` -#### Tagged enums +| Name | Description | +| -------------------------------------------- | -------------------------------------------- | +| [Method `Find`](#method-uniqueindexfind) | Find a row by the value of a unique column | +| [Method `Update`](#method-uniqueindexupdate) | Update a row with a unique column | +| [Method `Delete`](#method-uniqueindexdelete) | Delete a row by the value of a unique column | -SpacetimeDB has support for tagged enums which can be found in languages like Rust, but not C#. + -We provide a tagged enum support for C# modules via a special `record SpacetimeDB.TaggedEnum<(...types and names of the variants as a tuple...)>`. +### Method `UniqueIndex.Find` -When you inherit from the `SpacetimeDB.TaggedEnum` marker, it will generate variants as subclasses of the annotated type, so you can use regular C# pattern matching operators like `is` or `switch` to determine which variant a given tagged enum holds at any time. +```csharp +Row? Find(Column key); +``` -For unit variants (those without any data payload) you can use a built-in `SpacetimeDB.Unit` as the variant type. +Finds and returns the row where the value in the unique column matches the supplied `key`, +or `null` if no such row is present in the database state. -Example: +### Method `UniqueIndex.Update` ```csharp -// Define a tagged enum named `MyEnum` with three variants, -// `MyEnum.String`, `MyEnum.Int` and `MyEnum.None`. -[SpacetimeDB.Type] -public partial record MyEnum : SpacetimeDB.TaggedEnum<( - string String, - int Int, - SpacetimeDB.Unit None -)>; +Row Update(Row row); +``` + +Deletes the row where the value in the unique column matches that in the corresponding field of `row` and then inserts `row`. + +Returns the new row as actually inserted, with any auto-inc placeholders substituted for computed values. + +Throws if no row was previously present with the matching value in the unique column, +or if either the delete or the insertion would violate a constraint. + +### Method `UniqueIndex.Delete` + +```csharp +bool Delete(Column key); +``` + +Deletes the row where the value in the unique column matches the supplied `key`, if any such row is present in the database state. + +Returns `true` if a row with the specified `key` was previously present and has been deleted, +or `false` if no such row was present. + +## Auto-inc columns + +Columns can be marked `[SpacetimeDB.AutoInc]`. This can only be used on integer types (`int`, `ulong`, etc.) + +When inserting into or updating a row in a table with an `[AutoInc]` column, if the annotated column is set to zero (`0`), the database will automatically overwrite that zero with an atomically increasing value. -// Print an instance of `MyEnum`, using `switch`/`case` to determine the active variant. -void PrintEnum(MyEnum e) +[`ITableView.Insert`] and [`UniqueIndex.Update()`](#method-uniqueindexupdate) returns rows with `[AutoInc]` columns set to the values that were actually written into the database. + +```csharp +public static partial class Module { - switch (e) + [SpacetimeDB.Table(Name = "example")] + public partial struct Example { - case MyEnum.String(var s): - Console.WriteLine(s); - break; - - case MyEnum.Int(var i): - Console.WriteLine(i); - break; + [SpacetimeDB.AutoInc] + public int Field; + } - case MyEnum.None: - Console.WriteLine("(none)"); - break; + [SpacetimeDB.Reducer] + public static void InsertAutoIncExample(ReducerContext ctx, int Id, string Name) { + for (var i = 0; i < 10; i++) { + // These will have distinct, unique values + // at rest in the database, since they + // are inserted with the sentinel value 0. + var actual = ctx.Db.example.Insert(new Example { Field = 0 }); + Debug.Assert(actual.Field != 0); + } } } +``` + +`[AutoInc]` is often combined with `[Unique]` or `[PrimaryKey]` to automatically assign unique integer identifiers to rows. -// Test whether an instance of `MyEnum` holds some value (either a string or an int one). -bool IsSome(MyEnum e) => e is not MyEnum.None; +## Indexes -// Construct an instance of `MyEnum` with the `String` variant active. -var myEnum = new MyEnum.String("Hello, world!"); -Console.WriteLine($"IsSome: {IsSome(myEnum)}"); -PrintEnum(myEnum); +SpacetimeDB supports both single- and multi-column [B-Tree](https://en.wikipedia.org/wiki/B-tree) indexes. + +Indexes are declared using the syntax: + +```csharp +[SpacetimeDB.Index.BTree(Name = "IndexName", Columns = [nameof(Column1), nameof(Column2), nameof(Column3)])] +``` + +For example: + +```csharp +[SpacetimeDB.Table(Name = "paper")] +[SpacetimeDB.Index.BTree(Name = "TitleAndDate", Columns = [nameof(Title), nameof(Date)])] +[SpacetimeDB.Index.BTree(Name = "UrlAndCountry", Columns = [nameof(Url), nameof(Country)])] +public partial struct AcademicPaper { + public string Title; + public string Url; + public string Date; + public string Venue; + public string Country; +} ``` -### Tables +Multiple indexes can be declared. + +Single-column indexes can also be declared using an annotation on a column: + +```csharp +[SpacetimeDB.Table(Name = "academic_paper")] +public partial struct AcademicPaper { + public string Title; + public string Url; + [SpacetimeDB.Index.BTree] // The index will be named "Date". + public string Date; + [SpacetimeDB.Index.BTree] // The index will be named "Venue". + public string Venue; + [SpacetimeDB.Index.BTree(Name = "ByCountry")] // The index will be named "ByCountry". + public string Country; +} +``` -`[SpacetimeDB.Table]` attribute can be used on any `struct` or `class` to mark it as a SpacetimeDB table. It will register a table in the database with the given name and fields as well as will generate C# methods to insert, filter, update, and delete rows of the given type. -By default, tables are **private**. This means that they are only readable by the table owner, and by server module code. -Adding `[SpacetimeDB.Table(Public = true))]` annotation makes a table public. **Public** tables are readable by all users, but can still only be modified by your server module code. -_Coming soon: We plan to add much more robust access controls than just public or private. Stay tuned!_ +Any table supports getting an [`Index`](#class-index) using `ctx.Db.{table}.{index}`. For example, `ctx.Db.academic_paper.TitleAndDate` or `ctx.Db.academic_paper.Venue`. -It implies `[SpacetimeDB.Type]`, so you must not specify both attributes on the same type. +## Class `Index` ```csharp -[SpacetimeDB.Table(Public = true)] -public partial struct Person +public abstract class IndexBase + where Row : IStructuralReadWrite, new() { - [SpacetimeDB.Column(ColumnAttrs.Unique | ColumnAttrs.AutoInc)] - public int Id; - public string Name; - public int Age; + // ... } ``` -The example above will generate the following extra methods: +Each index generates a subclass of `IndexBase`, which is accessible via `ctx.Db.{table}.{index}`. For example, `ctx.Db.academic_paper.TitleAndDate`. + +Indexes can be applied to a variable number of columns, referred to as `Column1`, `Column2`, `Column3`... in the following examples. + +| Name | Description | +| -------------------------------------- | ----------------------- | +| Method [`Filter`](#method-indexfilter) | Filter rows in an index | +| Method [`Delete`](#method-indexdelete) | Delete rows in an index | + +### Method `Index.Filter` ```csharp -public partial struct Person +public IEnumerable Filter(Column1 bound); +public IEnumerable Filter(Bound bound); +public IEnumerable Filter((Column1, Column2) bound); +public IEnumerable Filter((Column1, Bound) bound); +public IEnumerable Filter((Column1, Column2, Column3) bound); +public IEnumerable Filter((Column1, Column2, Bound) bound); +// ... +``` + +Returns an iterator over all rows in the database state where the indexed column(s) match the passed `bound`. Bound is a tuple of column values, possibly terminated by a `Bound`. A `Bound` is simply a tuple `(LastColumn Min, LastColumn Max)`. Any prefix of the indexed columns can be passed, for example: + +```csharp +using SpacetimeDB; + +public static partial class Module { - // Inserts current instance as a new row into the table. - public void Insert(); + [SpacetimeDB.Table(Name = "zoo_animal")] + [SpacetimeDB.Index.BTree(Name = "SpeciesAgeName", Columns = [nameof(Species), nameof(Age), nameof(Name)])] + public partial struct ZooAnimal + { + public string Species; + public uint Age; + public string Name; + [SpacetimeDB.PrimaryKey] + public uint Id; + } - // Returns an iterator over all rows in the table, e.g.: - // `for (var person in Person.Iter()) { ... }` - public static IEnumerable Iter(); + [SpacetimeDB.Reducer] + public static void Example(ReducerContext ctx) + { + foreach (var baboon in ctx.Db.zoo_animal.SpeciesAgeName.Filter("baboon")) + { + // Work with the baboon. + } + foreach (var animal in ctx.Db.zoo_animal.SpeciesAgeName.Filter(("b", "e"))) + { + // Work with the animal. + // The name of the species starts with a character between "b" and "e". + } + foreach (var babyBaboon in ctx.Db.zoo_animal.SpeciesAgeName.Filter(("baboon", 1))) + { + // Work with the baby baboon. + } + foreach (var youngBaboon in ctx.Db.zoo_animal.SpeciesAgeName.Filter(("baboon", (1, 5)))) + { + // Work with the young baboon. + } + foreach (var babyBaboonNamedBob in ctx.Db.zoo_animal.SpeciesAgeName.Filter(("baboon", 1, "Bob"))) + { + // Work with the baby baboon named "Bob". + } + foreach (var babyBaboon in ctx.Db.zoo_animal.SpeciesAgeName.Filter(("baboon", 1, ("a", "f")))) + { + // Work with the baby baboon, whose name starts with a letter between "a" and "f". + } + } +} +``` - // Returns an iterator over all rows in the table that match the given filter, e.g.: - // `for (var person in Person.Query(p => p.Age >= 18)) { ... }` - public static IEnumerable Query(Expression> filter); +### Method `Index.Delete` - // Generated for each column: +```csharp +public ulong Delete(Column1 bound); +public ulong Delete(Bound bound); +public ulong Delete((Column1, Column2) bound); +public ulong Delete((Column1, Bound) bound); +public ulong Delete((Column1, Column2, Column3) bound); +public ulong Delete((Column1, Column2, Bound) bound); +// ... +``` - // Returns an iterator over all rows in the table that have the given value in the `Name` column. - public static IEnumerable FilterByName(string name); - public static IEnumerable FilterByAge(int age); +Delete all rows in the database state where the indexed column(s) match the passed `bound`. Returns the count of rows deleted. Note that there may be multiple rows deleted even if only a single column value is passed, since the index is not guaranteed to be unique. - // Generated for each unique column: +# Reducers - // Finds a row in the table with the given value in the `Id` column and returns it, or `null` if no such row exists. - public static Person? FindById(int id); +Reducers are declared using the `[SpacetimeDB.Reducer]` attribute. - // Deletes a row in the table with the given value in the `Id` column and returns `true` if the row was found and deleted, or `false` if no such row exists. - public static bool DeleteById(int id); +`[SpacetimeDB.Reducer]` is always applied to static C# functions. The first parameter of a reducer must be a [`ReducerContext`]. The remaining parameters must be types marked with [`SpacetimeDB.Type`]. Reducers should return `void`. - // Updates a row in the table with the given value in the `Id` column and returns `true` if the row was found and updated, or `false` if no such row exists. - public static bool UpdateById(int oldId, Person newValue); +```csharp +public static partial class Module { + [SpacetimeDB.Reducer] + public static void GivePlayerItem( + ReducerContext context, + ulong PlayerId, + ulong ItemId + ) + { + // ... + } } ``` -You can create multiple tables backed by items of the same type by applying it with different names. For example, to store active and archived posts separately and with different privacy rules, you can declare two tables like this: +Every reducer runs inside a [database transaction](https://en.wikipedia.org/wiki/Database_transaction). This means that reducers will not observe the effects of other reducers modifying the database while they run. If a reducer fails, all of its changes to the database will automatically be rolled back. Reducers can fail by throwing an exception. + +## Class `ReducerContext` ```csharp -[SpacetimeDB.Table(Name = "Post", Public = true)] -[SpacetimeDB.Table(Name = "ArchivedPost", Public = false)] -public partial struct Post { - public string Title; - public string Body; +public sealed record ReducerContext : DbContext, Internal.IReducerContext +{ + // ... } ``` -#### Column attributes +Reducers have access to a special [`ReducerContext`] parameter. This parameter allows reading and writing the database attached to a module. It also provides some additional functionality, like generating random numbers and scheduling future operations. -Attribute `[SpacetimeDB.Column]` can be used on any field of a `SpacetimeDB.Table`-marked `struct` or `class` to customize column attributes as seen above. +[`ReducerContext`] provides access to the database tables via [the `.Db` property](#property-reducercontextdb). The [`[Table]`](#tables) attribute generated code that adds table accessors to this property. -The supported column attributes are: +| Name | Description | +| --------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| Property [`Db`](#property-reducercontextdb) | The current state of the database | +| Property [`Sender`](#property-reducercontextsender) | The [`Identity`](#struct-identity) of the caller of the reducer | +| Property [`ConnectionId`](#property-reducercontextconnectionid) | The [`ConnectionId`](#struct-connectionid) of the caller of the reducer, if any | +| Property [`Rng`](#property-reducercontextrng) | A [`System.Random`] instance. | +| Property [`Timestamp`](#property-reducercontexttimestamp) | The [`Timestamp`](#struct-timestamp) of the reducer invocation | +| Property [`Identity`](#property-reducercontextidentity) | The [`Identity`](#struct-identity) of the module | -- `ColumnAttrs.AutoInc` - this column should be auto-incremented. +### Property `ReducerContext.Db` -**Note**: The `AutoInc` number generator is not transactional. See the [SEQUENCE] section for more details. +```csharp +DbView Db; +``` -- `ColumnAttrs.Unique` - this column should be unique. -- `ColumnAttrs.PrimaryKey` - this column should be a primary key, it implies `ColumnAttrs.Unique` but also allows clients to subscribe to updates via `OnUpdate` which will use this field to match the old and the new version of the row with each other. +Allows accessing the local database attached to a module. -These attributes are bitflags and can be combined together, but you can also use some predefined shortcut aliases: +The `[Table]` attribute generates a field of this property. -- `ColumnAttrs.Identity` - same as `ColumnAttrs.Unique | ColumnAttrs.AutoInc`. -- `ColumnAttrs.PrimaryKeyAuto` - same as `ColumnAttrs.PrimaryKey | ColumnAttrs.AutoInc`. +For a table named *table*, use `ctx.Db.{table}` to get a [table view](#interface-itableview). +For example, `ctx.Db.users`. -### Reducers +You can also use `ctx.Db.{table}.{index}` to get an [index](#class-index) or [unique index](#class-uniqueindex). -Attribute `[SpacetimeDB.Reducer]` can be used on any `static void` method to register it as a SpacetimeDB reducer. The method must accept only supported types as arguments. If it throws an exception, those will be caught and reported back to the database runtime. +### Property `ReducerContext.Sender` ```csharp -[SpacetimeDB.Reducer] -public static void Add(string name, int age) -{ - var person = new Person { Name = name, Age = age }; - person.Insert(); - Log($"Inserted {person.Name} under #{person.Id}"); -} +Identity Sender; ``` -If a reducer has an argument with a type `ReducerContext` (`SpacetimeDB.Runtime.ReducerContext`), it will be provided with event details such as the sender identity (`SpacetimeDB.Runtime.Identity`), sender address (`SpacetimeDB.Runtime.Address?`) and the time (`DateTimeOffset`) of the invocation: +The [`Identity`](#struct-identity) of the client that invoked the reducer. + +### Property `ReducerContext.ConnectionId` ```csharp -[SpacetimeDB.Reducer] -public static void PrintInfo(ReducerContext e) -{ - Log($"Sender identity: {e.Sender}"); - Log($"Sender address: {e.Address}"); - Log($"Time: {e.Time}"); -} +ConnectionId? ConnectionId; +``` + +The [`ConnectionId`](#struct-connectionid) of the client that invoked the reducer. + +`null` if no `ConnectionId` was supplied to the `/database/call` HTTP endpoint, +or via the CLI's `spacetime call` subcommand. + +### Property `ReducerContext.Rng` + +```csharp +Random Rng; +``` + +A [`System.Random`] that can be used to generate random numbers. + +### Property `ReducerContext.Timestamp` + +```csharp +Timestamp Timestamp; +``` + +The time at which the reducer was invoked. + +### Property `ReducerContext.Identity` + +```csharp +Identity Identity; ``` -### Scheduler Tables +The [`Identity`](#struct-identity) of the module. + +This can be used to [check whether a scheduled reducer is being called by a user](#restricting-scheduled-reducers). + +Note: this is not the identity of the caller, that's [`ReducerContext.Sender`](#property-reducercontextsender). + + +## Lifecycle Reducers + +A small group of reducers are called at set points in the module lifecycle. These are used to initialize +the database and respond to client connections. You can have one of each per module. + +These reducers cannot be called manually and may not have any parameters except for `ReducerContext`. + +### The `Init` reducer -Tables can be used to schedule a reducer calls either at a specific timestamp or at regular intervals. +This reducer is marked with `[SpacetimeDB.Reducer(ReducerKind.Init)]`. It is run the first time a module is published and any time the database is cleared. + +If an error occurs when initializing, the module will not be published. + +This reducer can be used to configure any static data tables used by your module. It can also be used to start running [scheduled reducers](#scheduled-reducers). + +### The `ClientConnected` reducer + +This reducer is marked with `[SpacetimeDB.Reducer(ReducerKind.ClientConnected)]`. It is run when a client connects to the SpacetimeDB module. Their identity can be found in the sender value of the `ReducerContext`. + +If an error occurs in the reducer, the client will be disconnected. + +### The `ClientDisconnected` reducer + +This reducer is marked with `[SpacetimeDB.Reducer(ReducerKind.ClientDisconnected)]`. It is run when a client disconnects from the SpacetimeDB module. Their identity can be found in the sender value of the `ReducerContext`. + +If an error occurs in the disconnect reducer, the client is still recorded as disconnected. + + +## Scheduled Reducers + +Reducers can schedule other reducers to run asynchronously. This allows calling the reducers at a particular time, or at repeating intervals. This can be used to implement timers, game loops, and maintenance tasks. + +The scheduling information for a reducer is stored in a table. +This table has two mandatory fields: +- An `[AutoInc] [PrimaryKey] ulong` field that identifies scheduled reducer calls. +- A [`ScheduleAt`](#record-scheduleat) field that says when to call the reducer. + +Managing timers with a scheduled table is as simple as inserting or deleting rows from the table. +This makes scheduling transactional in SpacetimeDB. If a reducer A first schedules B but then errors for some other reason, B will not be scheduled to run. + +A [`ScheduleAt`](#record-scheduleat) can be created from a [`Timestamp`](#struct-timestamp), in which case the reducer will be scheduled once, or from a [`TimeDuration`](#struct-timeduration), in which case the reducer will be scheduled in a loop. + +Example: ```csharp -public static partial class Timers +using SpacetimeDB; + +public static partial class Module { - // The `Scheduled` attribute links this table to a reducer. - [SpacetimeDB.Table(Scheduled = nameof(SendScheduledMessage))] - public partial struct SendMessageTimer + // First, we declare the table with scheduling information. + + [Table(Name = "send_message_schedule", Scheduled = nameof(SendMessage), ScheduledAt = nameof(ScheduledAt))] + public partial struct SendMessageSchedule { - public string Text; + + // Mandatory fields: + + [PrimaryKey] + [AutoInc] + public ulong Id; + + public ScheduleAt ScheduledAt; + + // Custom fields: + + public string Message; } + // Then, we declare the scheduled reducer. + // The first argument of the reducer should be, as always, a `ReducerContext`. + // The second argument should be a row of the scheduling information table. - // Define the reducer that will be invoked by the scheduler table. - // The first parameter is always `ReducerContext`, and the second parameter is an instance of the linked table struct. - [SpacetimeDB.Reducer] - public static void SendScheduledMessage(ReducerContext ctx, SendMessageTimer arg) + [Reducer] + public static void SendMessage(ReducerContext ctx, SendMessageSchedule schedule) { + Log.Info($"Sending message {schedule.Message}"); // ... } + // Finally, we want to actually start scheduling reducers. + // It's convenient to do this inside the `init` reducer. - // Scheduling reducers inside `init` reducer. - [SpacetimeDB.Reducer(ReducerKind.Init)] + [Reducer(ReducerKind.Init)] public static void Init(ReducerContext ctx) { + var currentTime = ctx.Timestamp; + var tenSeconds = new TimeDuration { Microseconds = +10_000_000 }; + var futureTimestamp = currentTime + tenSeconds; - // Schedule a one-time reducer call by inserting a row. - new SendMessageTimer + ctx.Db.send_message_schedule.Insert(new() { - Text = "bot sending a message", - ScheduledAt = ctx.Time.AddSeconds(10), - ScheduledId = 1, - }.Insert(); - + Id = 0, // Have [AutoInc] assign an Id. + ScheduledAt = new ScheduleAt.Time(futureTimestamp), + Message = "I'm a bot sending a message one time!" + }); - // Schedule a recurring reducer. - new SendMessageTimer + ctx.Db.send_message_schedule.Insert(new() { - Text = "bot sending a message", - ScheduledAt = new TimeStamp(10), - ScheduledId = 2, - }.Insert(); + Id = 0, // Have [AutoInc] assign an Id. + ScheduledAt = new ScheduleAt.Interval(tenSeconds), + Message = "I'm a bot sending a message every ten seconds!" + }); } } ``` -Annotating a struct with `Scheduled` automatically adds fields to support scheduling, It can be expanded as: +Scheduled reducers are called on a best-effort basis and may be slightly delayed in their execution +when a database is under heavy load. + +### Restricting scheduled reducers + +Scheduled reducers are normal reducers, and may still be called by clients. +If a scheduled reducer should only be called by the scheduler, consider beginning it with a check that the caller `Identity` is the module: ```csharp -public static partial class Timers +[Reducer] +public static void SendMessage(ReducerContext ctx, SendMessageSchedule schedule) { - [SpacetimeDB.Table] - public partial struct SendMessageTimer + if (ctx.Sender != ctx.Identity) { - public string Text; // fields of original struct + throw new Exception("Reducer SendMessage may not be invoked by clients, only via scheduling."); + } + // ... +} +``` + +# Automatic migrations + +When you `spacetime publish` a module that has already been published using `spacetime publish `, +SpacetimeDB attempts to automatically migrate your existing database to the new schema. (The "schema" is just the collection +of tables and reducers you've declared in your code, together with the types they depend on.) This form of migration is limited and only supports a few kinds of changes. +On the plus side, automatic migrations usually don't break clients. The situations that may break clients are documented below. + +The following changes are always allowed and never breaking: + +- ✅ **Adding tables**. Non-updated clients will not be able to see the new tables. +- ✅ **Adding indexes**. +- ✅ **Adding or removing `[AutoInc]` annotations.** +- ✅ **Changing tables from private to public**. +- ✅ **Adding reducers**. +- ✅ **Removing `[Unique]` annotations.** + +The following changes are allowed, but may break clients: + +- ⚠️ **Changing or removing reducers**. Clients that attempt to call the old version of a changed reducer will receive runtime errors. +- ⚠️ **Changing tables from public to private**. Clients that are subscribed to a newly-private table will receive runtime errors. +- ⚠️ **Removing `[PrimaryKey]` annotations**. Non-updated clients will still use the old `[PrimaryKey]` as a unique key in their local cache, which can result in non-deterministic behavior when updates are received. +- ⚠️ **Removing indexes**. This is only breaking in some situtations. + The specific problem is subscription queries involving semijoins, such as: + ```sql + SELECT Employee.* + FROM Employee JOIN Dept + ON Employee.DeptName = Dept.DeptName + ) + ``` + For performance reasons, SpacetimeDB will only allow this kind of subscription query if there are indexes on `Employee.DeptName` and `Dept.DeptName`. Removing either of these indexes will invalidate this subscription query, resulting in client-side runtime errors. + +The following changes are forbidden without a manual migration: - [SpacetimeDB.Column(ColumnAttrs.PrimaryKeyAuto)] - public ulong ScheduledId; // unique identifier to be used internally +- ❌ **Removing tables**. +- ❌ **Changing the columns of a table**. This includes changing the order of columns of a table. +- ❌ **Changing whether a table is used for [scheduling](#scheduled-reducers).** +- ❌ **Adding `[Unique]` or `[PrimaryKey]` constraints.** This could result in existing tables being in an invalid state. - public SpacetimeDB.ScheduleAt ScheduleAt; // Scheduling details (Time or Inteval) +Currently, manual migration support is limited. The `spacetime publish --clear-database ` command can be used to **COMPLETELY DELETE** and reinitialize your database, but naturally it should be used with EXTREME CAUTION. + +# Other infrastructure + +## Class `Log` + +```csharp +namespace SpacetimeDB +{ + public static class Log + { + public static void Debug(string message); + public static void Error(string message); + public static void Exception(string message); + public static void Exception(Exception exception); + public static void Info(string message); + public static void Trace(string message); + public static void Warn(string message); } } - -// `ScheduledAt` definition -public abstract partial record ScheduleAt: SpacetimeDB.TaggedEnum<(DateTimeOffset Time, TimeSpan Interval)> ``` -#### Special reducers +Methods for writing to a private debug log. Log messages will include file and line numbers. -These are four special kinds of reducers that can be used to respond to module lifecycle events. They're stored in the `SpacetimeDB.Module.ReducerKind` class and can be used as an argument to the `[SpacetimeDB.Reducer]` attribute: +Log outputs of a running module can be inspected using the `spacetime logs` command: -- `ReducerKind.Init` - this reducer will be invoked when the module is first published. -- `ReducerKind.Update` - this reducer will be invoked when the module is updated. -- `ReducerKind.Connect` - this reducer will be invoked when a client connects to the database. -- `ReducerKind.Disconnect` - this reducer will be invoked when a client disconnects from the database. +```text +spacetime logs +``` + +These are only visible to the database owner, not to clients or other developers. + +Note that `Log.Error` and `Log.Exception` only write to the log, they do not throw exceptions themselves. Example: -````csharp -[SpacetimeDB.Reducer(ReducerKind.Init)] -public static void Init() +```csharp +using SpacetimeDB; + +public static partial class Module { + [Table(Name = "user")] + public partial struct User { + [PrimaryKey] + uint Id; + [Unique] + string Username; + ulong DogCount; + } + + [Reducer] + public static void LogDogs(ReducerContext ctx) { + Log.Info("Examining users."); + + var totalDogCount = 0; + + foreach (var user in ctx.Db.user.Iter()) { + Log.Info($" User: Id = {user.Id}, Username = {user.Username}, DogCount = {user.DogCount}"); + + totalDogCount += user.DogCount; + } + + if (totalDogCount < 300) { + Log.Warn("Insufficient dogs."); + } + + if (totalDogCount < 100) { + Log.Error("Dog population is critically low!"); + } + } +} +``` + +## Attribute `[SpacetimeDB.Type]` + +This attribute makes types self-describing, allowing them to automatically register their structure +with SpacetimeDB. Any C# type annotated with `[SpacetimeDB.Type]` can be used as a table column or reducer argument. + +Types marked `[SpacetimeDB.Table]` are automatically marked `[SpacetimeDB.Type]`. + +`[SpacetimeDB.Type]` can be combined with [`SpacetimeDB.TaggedEnum`] to use tagged enums in tables or reducers. + +```csharp +using SpacetimeDB; + +public static partial class Module { + + [Type] + public partial struct Coord { + public int X; + public int Y; + } + + [Type] + public partial struct TankData { + public int Ammo; + public int LeftTreadHealth; + public int RightTreadHealth; + } + + [Type] + public partial struct TransportData { + public int TroopCount; + } + + // A type that could be either the data for a Tank or the data for a Transport. + // See SpacetimeDB.TaggedEnum docs. + [Type] + public partial record VehicleData : TaggedEnum<(TankData Tank, TransportData Transport)> {} + + [Table(Name = "vehicle")] + public partial struct Vehicle { + [PrimaryKey] + [AutoInc] + public uint Id; + public Coord Coord; + public VehicleData Data; + } + + [SpacetimeDB.Reducer] + public static void InsertVehicle(ReducerContext ctx, Coord Coord, VehicleData Data) { + ctx.Db.vehicle.Insert(new Vehicle { Id = 0, Coord = Coord, Data = Data }); + } +} +``` + +The fields of the struct/enum must also be marked with `[SpacetimeDB.Type]`. + +Some types from the standard library are also considered to be marked with `[SpacetimeDB.Type]`, including: +- `byte` +- `sbyte` +- `ushort` +- `short` +- `uint` +- `int` +- `ulong` +- `long` +- `SpacetimeDB.U128` +- `SpacetimeDB.I128` +- `SpacetimeDB.U256` +- `SpacetimeDB.I256` +- `List` where `T` is a `[SpacetimeDB.Type]` + +## Struct `Identity` + +```csharp +namespace SpacetimeDB; + +public readonly record struct Identity { - Log("...and we're live!"); + public static Identity FromHexString(string hex); + public string ToString(); } +``` + +An `Identity` for something interacting with the database. + +This is a record struct, so it can be printed, compared with `==`, and used as a `Dictionary` key. + +`ToString()` returns a hex encoding of the Identity, suitable for printing. -[SpacetimeDB.Reducer(ReducerKind.Update)] -public static void Update() + + +## Struct `ConnectionId` + +```csharp +namespace SpacetimeDB; + +public readonly record struct ConnectionId { - Log("Update get!"); + public static ConnectionId? FromHexString(string hex); + public string ToString(); } +``` + +A unique identifier for a client connection to a SpacetimeDB database. + +This is a record struct, so it can be printed, compared with `==`, and used as a `Dictionary` key. + +`ToString()` returns a hex encoding of the `ConnectionId`, suitable for printing. -[SpacetimeDB.Reducer(ReducerKind.Connect)] -public static void OnConnect(DbEventArgs ctx) +## Struct `Timestamp` + +```csharp +namespace SpacetimeDB; + +public record struct Timestamp(long MicrosecondsSinceUnixEpoch) + : IStructuralReadWrite, + IComparable { - Log($"{ctx.Sender} has connected from {ctx.Address}!"); + // ... +} +``` + +A point in time, measured in microseconds since the Unix epoch. +This can be converted to/from a standard library [`DateTimeOffset`]. It is provided for consistency of behavior between SpacetimeDB's supported module and SDK languages. + +| Name | Description | +| ------------------------------------- | ----------------------------------------------------- | +| Property `MicrosecondsSinceUnixEpoch` | Microseconds since the [unix epoch]. | +| Conversion to/from `DateTimeOffset` | Convert to/from a standard library [`DateTimeOffset`] | +| Static property `UNIX_EPOCH` | The [unix epoch] as a `Timestamp` | +| Method `TimeDurationSince` | Measure the time elapsed since another `Timestamp` | +| Operator `+` | Add a [`TimeDuration`] to a `Timestamp` | +| Method `CompareTo` | Compare to another `Timestamp` | + +### Property `Timestamp.MicrosecondsSinceUnixEpoch` + +```csharp +long MicrosecondsSinceUnixEpoch; +``` + +The number of microseconds since the [unix epoch]. + +A positive value means a time after the Unix epoch, and a negative value means a time before. + +### Conversion to/from `DateTimeOffset` + +```csharp +public static implicit operator DateTimeOffset(Timestamp t); +public static implicit operator Timestamp(DateTimeOffset offset); +``` +`Timestamp` may be converted to/from a [`DateTimeOffset`], but the conversion can lose precision. +This type has less precision than DateTimeOffset (units of microseconds rather than units of 100ns). + +### Static property `Timestamp.UNIX_EPOCH` +```csharp +public static readonly Timestamp UNIX_EPOCH = new Timestamp { MicrosecondsSinceUnixEpoch = 0 }; +``` + +The [unix epoch] as a `Timestamp`. + +### Method `Timestamp.TimeDurationSince` +```csharp +public readonly TimeDuration TimeDurationSince(Timestamp earlier) => +``` + +Create a new [`TimeDuration`] that is the difference between two `Timestamps`. + +### Operator `Timestamp.+` +```csharp +public static Timestamp operator +(Timestamp point, TimeDuration interval); +``` + +Create a new `Timestamp` that occurs `interval` after `point`. + +### Method `Timestamp.CompareTo` +```csharp +public int CompareTo(Timestamp that) +``` + +Compare two `Timestamp`s. + +## Struct `TimeDuration` +```csharp +namespace SpacetimeDB; + +public record struct TimeDuration(long Microseconds) : IStructuralReadWrite { + // ... } +``` + +A duration that represents an interval between two [`Timestamp`]s. + +This type may be converted to/from a [`TimeSpan`]. It is provided for consistency of behavior between SpacetimeDB's supported module and SDK languages. + +| Name | Description | +| ------------------------------------------------------------- | ------------------------------------------------- | +| Property [`Microseconds`](#property-timedurationmicroseconds) | Microseconds between the [`Timestamp`]s. | +| [Conversion to/from `TimeSpan`](#conversion-tofrom-timespan) | Convert to/from a standard library [`TimeSpan`] | +| Static property [`ZERO`](#static-property-timedurationzero) | The duration between any [`Timestamp`] and itself | + +### Property `TimeDuration.Microseconds` +```csharp +long Microseconds; +``` + +The number of microseconds between two [`Timestamp`]s. + +### Conversion to/from `TimeSpan` +```csharp +public static implicit operator TimeSpan(TimeDuration d) => + new(d.Microseconds * Util.TicksPerMicrosecond); + +public static implicit operator TimeDuration(TimeSpan timeSpan) => + new(timeSpan.Ticks / Util.TicksPerMicrosecond); +``` + +`TimeDuration` may be converted to/from a [`TimeSpan`], but the conversion can lose precision. +This type has less precision than [`TimeSpan`] (units of microseconds rather than units of 100ns). + +### Static property `TimeDuration.ZERO` +```csharp +public static readonly TimeDuration ZERO = new TimeDuration { Microseconds = 0 }; +``` -[SpacetimeDB.Reducer(ReducerKind.Disconnect)] -public static void OnDisconnect(DbEventArgs ctx) +The duration between any `Timestamp` and itself. + +## Record `TaggedEnum` +```csharp +namespace SpacetimeDB; + +public abstract record TaggedEnum : IEquatable> where Variants : struct, ITuple +``` + +A [tagged enum](https://en.wikipedia.org/wiki/Tagged_union) is a type that can hold a value from any one of several types. `TaggedEnum` uses code generation to accomplish this. + +For example, to declare a type that can be either a `string` or an `int`, write: + +```csharp +[SpacetimeDB.Type] +public partial record ProductId : SpacetimeDB.TaggedEnum<(string Text, uint Number)> { } +``` + +Here there are two **variants**: one is named `Text` and holds a `string`, the other is named `Number` and holds a `uint`. + +To create a value of this type, use `new {Type}.{Variant}({data})`. For example: + +```csharp +ProductId a = new ProductId.Text("apple"); +ProductId b = new ProductId.Number(57); +ProductId c = new ProductId.Number(59); +``` + +To use a value of this type, you need to check which variant it stores. +This is done with [C# pattern matching syntax](https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/functional/pattern-matching). For example: + +```csharp +public static void Print(ProductId id) { - Log($"{ctx.Sender} has disconnected."); -}``` -```` + if (id is ProductId.Text(var s)) + { + Log.Info($"Textual product ID: '{s}'"); + } + else if (id is ProductId.Number(var i)) + { + Log.Info($"Numeric Product ID: {i}"); + } +} +``` + +A `TaggedEnum` can have up to 255 variants, and the variants can be any type marked with [`[SpacetimeDB.Type]`]. + +```csharp +[SpacetimeDB.Type] +public partial record ManyChoices : SpacetimeDB.TaggedEnum<( + string String, + int Int, + List IntList, + Banana Banana, + List> BananaMatrix +)> { } + +[SpacetimeDB.Type] +public partial struct Banana { + public int Sweetness; + public int Rot; +} +``` + +`TaggedEnums` are an excellent alternative to nullable fields when groups of fields are always set together. Consider a data type like: + +```csharp +[SpacetimeDB.Type] +public partial struct ShapeData { + public int? CircleRadius; + public int? RectWidth; + public int? RectHeight; +} +``` + +Often this is supposed to be a circle XOR a rectangle -- that is, not both at the same time. If this is the case, then we don't want to set `circleRadius` at the same time as `rectWidth` or `rectHeight`. Also, if `rectWidth` is set, we expect `rectHeight` to be set. +However, C# doesn't know about this, so code using this type will be littered with extra null checks. + +If we instead write: + +```csharp +[SpacetimeDB.Type] +public partial struct CircleData { + public int Radius; +} + +[SpacetimeDB.Type] +public partial struct RectData { + public int Width; + public int Height; +} + +[SpacetimeDB.Type] +public partial record ShapeData : SpacetimeDB.TaggedEnum<(CircleData Circle, RectData Rect)> { } +``` + +Then code using a `ShapeData` will only have to do one check -- do I have a circle or a rectangle? +And in each case, the data will be guaranteed to have exactly the fields needed. + +## Record `ScheduleAt` +```csharp +namespace SpacetimeDB; + +public partial record ScheduleAt : TaggedEnum<(TimeDuration Interval, Timestamp Time)> +``` + +When a [scheduled reducer](#scheduled-reducers) should execute, either at a specific point in time, or at regular intervals for repeating schedules. + +Stored in reducer-scheduling tables as a column. -[SEQUENCE]: /docs/appendix#sequence \ No newline at end of file +[demo]: /#demo +[client]: https://spacetimedb.com/docs/#client +[clients]: https://spacetimedb.com/docs/#client +[client SDK documentation]: https://spacetimedb.com/docs/#client +[host]: https://spacetimedb.com/docs/#host +[`DateTimeOffset`]: https://learn.microsoft.com/en-us/dotnet/api/system.datetimeoffset?view=net-9.0 +[`TimeSpan`]: https://learn.microsoft.com/en-us/dotnet/api/system.timespan?view=net-9.0 +[unix epoch]: https://en.wikipedia.org/wiki/Unix_time +[`System.Random`]: https://learn.microsoft.com/en-us/dotnet/api/system.random?view=net-9.0 \ No newline at end of file From 0d97f844b8fe2fee76473bcc6a96dbf0329f7839 Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Mon, 3 Mar 2025 16:36:56 -0800 Subject: [PATCH 123/164] C# sdk reference (#191) Closes #192. --- docs/index.md | 2 +- docs/sdks/c-sharp/index.md | 1191 ++++++++++++++++++------------------ 2 files changed, 592 insertions(+), 601 deletions(-) diff --git a/docs/index.md b/docs/index.md index 864f7327..9180ff68 100644 --- a/docs/index.md +++ b/docs/index.md @@ -216,7 +216,7 @@ or at a specific time. ::: :::server-csharp While SpacetimeDB doesn't support nested transactions, -a reducer can [schedule another reducer](/docs/modules/c-sharp#scheduler-tables) to run at an interval, +a reducer can [schedule another reducer](/docs/modules/c-sharp#scheduled-reducers) to run at an interval, or at a specific time. ::: diff --git a/docs/sdks/c-sharp/index.md b/docs/sdks/c-sharp/index.md index e9c5f23a..16fd2068 100644 --- a/docs/sdks/c-sharp/index.md +++ b/docs/sdks/c-sharp/index.md @@ -1,56 +1,22 @@ # The SpacetimeDB C# client SDK -The SpacetimeDB client C# for Rust contains all the tools you need to build native clients for SpacetimeDB modules using C#. - -## Table of Contents - -- [The SpacetimeDB C# client SDK](#the-spacetimedb-c-client-sdk) - - [Table of Contents](#table-of-contents) - - [Install the SDK](#install-the-sdk) - - [Using the `dotnet` CLI tool](#using-the-dotnet-cli-tool) - - [Using Unity](#using-unity) - - [Generate module bindings](#generate-module-bindings) - - [Initialization](#initialization) - - [Property `SpacetimeDBClient.instance`](#property-spacetimedbclientinstance) - - [Class `NetworkManager`](#class-networkmanager) - - [Method `SpacetimeDBClient.Connect`](#method-spacetimedbclientconnect) - - [Event `SpacetimeDBClient.onIdentityReceived`](#event-spacetimedbclientonidentityreceived) - - [Event `SpacetimeDBClient.onConnect`](#event-spacetimedbclientonconnect) - - [Subscribe to queries](#subscribe-to-queries) - - [Method `SpacetimeDBClient.Subscribe`](#method-spacetimedbclientsubscribe) - - [Event `SpacetimeDBClient.onSubscriptionApplied`](#event-spacetimedbclientonsubscriptionapplied) - - [Method \[`SpacetimeDBClient.OneOffQuery`\]](#method-spacetimedbclientoneoffquery) - - [View rows of subscribed tables](#view-rows-of-subscribed-tables) - - [Class `{TABLE}`](#class-table) - - [Static Method `{TABLE}.Iter`](#static-method-tableiter) - - [Static Method `{TABLE}.FilterBy{COLUMN}`](#static-method-tablefilterbycolumn) - - [Static Method `{TABLE}.FindBy{COLUMN}`](#static-method-tablefindbycolumn) - - [Static Method `{TABLE}.Count`](#static-method-tablecount) - - [Static Event `{TABLE}.OnInsert`](#static-event-tableoninsert) - - [Static Event `{TABLE}.OnBeforeDelete`](#static-event-tableonbeforedelete) - - [Static Event `{TABLE}.OnDelete`](#static-event-tableondelete) - - [Static Event `{TABLE}.OnUpdate`](#static-event-tableonupdate) - - [Observe and invoke reducers](#observe-and-invoke-reducers) - - [Class `Reducer`](#class-reducer) - - [Static Method `Reducer.{REDUCER}`](#static-method-reducerreducer) - - [Static Event `Reducer.On{REDUCER}`](#static-event-reduceronreducer) - - [Class `ReducerEvent`](#class-reducerevent) - - [Enum `Status`](#enum-status) - - [Variant `Status.Committed`](#variant-statuscommitted) - - [Variant `Status.Failed`](#variant-statusfailed) - - [Variant `Status.OutOfEnergy`](#variant-statusoutofenergy) - - [Identity management](#identity-management) - - [Class `AuthToken`](#class-authtoken) - - [Static Method `AuthToken.Init`](#static-method-authtokeninit) - - [Static Property `AuthToken.Token`](#static-property-authtokentoken) - - [Static Method `AuthToken.SaveToken`](#static-method-authtokensavetoken) - - [Class `Identity`](#class-identity) - - [Customizing logging](#customizing-logging) - - [Interface `ISpacetimeDBLogger`](#interface-ispacetimedblogger) - - [Class `ConsoleLogger`](#class-consolelogger) - - [Class `UnityDebugLogger`](#class-unitydebuglogger) - -## Install the SDK +The SpacetimeDB client for C# contains all the tools you need to build native clients for SpacetimeDB modules using C#. + +| Name | Description | +|---------------------------------------------------------|---------------------------------------------------------------------------| +| [Project setup](#project-setup) | Configure a C# project to use the SpacetimeDB C# client SDK. | +| [Generate module bindings](#generate-module-bindings) | Use the SpacetimeDB CLI to generate module-specific types and interfaces. | +| [`DbConnection` type](#type-dbconnection) | A connection to a remote database. | +| [`IDbContext` interface](#interface-idbcontext) | Methods for interacting with the remote database. | +| [`EventContext` type](#type-eventcontext) | Implements [`IDbContext`](##interface-idbcontext) for [row callbacks](#callback-oninsert). | +| [`ReducerEventContext` type](#type-reducereventcontext) | Implements [`IDbContext`](##interface-idbcontext) for [reducer callbacks](#observe-and-invoke-reducers). | +| [`SubscriptionEventContext` type](#type-subscriptioneventcontext) | Implements [`IDbContext`](##interface-idbcontext) for [subscription callbacks](#subscribe-to-queries). | +| [`ErrorContext` type](#type-errorcontext) | Implements [`IDbContext`](##interface-idbcontext) for error-related callbacks. | +| [Access the client cache](#access-the-client-cache) | Access to your local view of the database. | +| [Observe and invoke reducers](#observe-and-invoke-reducers) | Send requests to the database to run reducers, and register callbacks to run when notified of reducers. | +| [Identify a client](#identify-a-client) | Types for identifying users and client connections. | + +## Project setup ### Using the `dotnet` CLI tool @@ -81,853 +47,878 @@ spacetime generate --lang cs --out-dir module_bindings --project-path PATH-TO-MO Replace `PATH-TO-MODULE-DIRECTORY` with the path to your SpacetimeDB module. -## Initialization +## Type `DbConnection` -### Property `SpacetimeDBClient.instance` +A connection to a remote database is represented by the `DbConnection` class. This class is generated per module and contains information about the types, tables, and reducers defined by your module. -```cs -namespace SpacetimeDB { +| Name | Description | +|------------------------------------------------------------------------|-------------------------------------------------------------------------------| +| [Connect to a module](#connect-to-a-module) | Construct a `DbConnection` instance. | +| [Advance the connection](#advance-the-connection-and-process-messages) | Poll the `DbConnection` or run it in the background. | +| [Access tables and reducers](#access-tables-and-reducers) | Access the client cache, request reducer invocations, and register callbacks. | -public class SpacetimeDBClient { - public static SpacetimeDBClient instance; -} +## Connect to a module +```csharp +class DbConnection +{ + public static DbConnectionBuilder Builder(); } ``` -This is the global instance of a SpacetimeDB client in a particular .NET/Unity process. Much of the SDK is accessible through this instance. +Construct a `DbConnection` by calling `DbConnection.Builder()`, chaining configuration methods, and finally calling `.Build()`. At a minimum, you must specify `WithUri` to provide the URI of the SpacetimeDB instance, and `WithModuleName` to specify the module's name or identity. -### Class `NetworkManager` +| Name | Description | +|---------------------------------------------------------|--------------------------------------------------------------------------------------------| +| [WithUri method](#method-withuri) | Set the URI of the SpacetimeDB instance hosting the remote database. | +| [WithModuleName method](#method-withmodulename) | Set the name or identity of the remote module. | +| [OnConnect callback](#callback-onconnect) | Register a callback to run when the connection is successfully established. | +| [OnConnectError callback](#callback-onconnecterror) | Register a callback to run if the connection is rejected or the host is unreachable. | +| [OnDisconnect callback](#callback-ondisconnect) | Register a callback to run when the connection ends. | +| [WithToken method](#method-withtoken) | Supply a token to authenticate with the remote database. | +| [Build method](#method-build) | Finalize configuration and open the connection. | -The Unity SpacetimeDB SDK relies on there being a `NetworkManager` somewhere in the scene. Click on the GameManager object in the scene, and in the inspector, add the `NetworkManager` component. +### Method `WithUri` -This component will handle updating and closing the [`SpacetimeDBClient.instance`](#property-spacetimedbclientinstance) for you, but will not call [`SpacetimeDBClient.Connect`](#method-spacetimedbclientconnect), you still need to handle that yourself. See the [Unity Tutorial](/docs/unity) for more information. +```csharp +class DbConnectionBuilder +{ + public DbConnectionBuilder WithUri(Uri uri); +} +``` -### Method `SpacetimeDBClient.Connect` +Configure the URI of the SpacetimeDB instance or cluster which hosts the remote module. -```cs -namespace SpacetimeDB { +### Method `WithModuleName` -class SpacetimeDBClient { - public void Connect( - string? token, - string host, - string addressOrName, - bool sslEnabled = true - ); +```csharp +class DbConnectionBuilder +{ + public DbConnectionBuilder WithModuleName(string nameOrIdentity); } +``` + +Configure the SpacetimeDB domain name or `Identity` of the remote module which identifies it within the SpacetimeDB instance or cluster. +### Callback `OnConnect` + +```csharp +class DbConnectionBuilder +{ + public DbConnectionBuilder OnConnect(Action callback); } ``` - +Chain a call to `.OnConnect(callback)` to your builder to register a callback to run when your new `DbConnection` successfully initiates its connection to the remote module. The callback accepts three arguments: a reference to the `DbConnection`, the `Identity` by which SpacetimeDB identifies this connection, and a private access token which can be saved and later passed to [`WithToken`](#method-withtoken) to authenticate the same user in future connections. -Connect to a database named `addressOrName` accessible over the internet at the URI `host`. +### Callback `OnConnectError` -| Argument | Type | Meaning | -| --------------- | --------- | -------------------------------------------------------------------------- | -| `token` | `string?` | Identity token to use, if one is available. | -| `host` | `string` | URI of the SpacetimeDB instance running the module. | -| `addressOrName` | `string` | Address or name of the module. | -| `sslEnabled` | `bool` | Whether or not to use SSL when connecting to SpacetimeDB. Default: `true`. | +```csharp +class DbConnectionBuilder +{ + public DbConnectionBuilder OnConnectError(Action callback); +} +``` -If a `token` is supplied, it will be passed to the new connection to identify and authenticate the user. Otherwise, a new token and [`Identity`](#class-identity) will be generated by the server and returned in [`onConnect`](#event-spacetimedbclientonconnect). +Chain a call to `.OnConnectError(callback)` to your builder to register a callback to run when your connection fails. -```cs -using SpacetimeDB; -using SpacetimeDB.Types; +A known bug in the SpacetimeDB Rust client SDK currently causes this callback never to be invoked. [`OnDisconnect`](#callback-ondisconnect) callbacks are invoked instead. -const string DBNAME = "chat"; +### Callback `OnDisconnect` + +```csharp +class DbConnectionBuilder +{ + public DbConnectionBuilder OnDisconnect(Action callback); +} +``` -// Connect to a local DB with a fresh identity -SpacetimeDBClient.instance.Connect(null, "localhost:3000", DBNAME, false); +Chain a call to `.OnDisconnect(callback)` to your builder to register a callback to run when your `DbConnection` disconnects from the remote module, either as a result of a call to [`Disconnect`](#method-disconnect) or due to an error. -// Connect to cloud with a fresh identity -SpacetimeDBClient.instance.Connect(null, "dev.spacetimedb.net", DBNAME, true); +### Method `WithToken` -// Connect to cloud using a saved identity from the filesystem, or get a new one and save it -AuthToken.Init(); -Identity localIdentity; -SpacetimeDBClient.instance.Connect(AuthToken.Token, "dev.spacetimedb.net", DBNAME, true); -SpacetimeDBClient.instance.onIdentityReceived += (string authToken, Identity identity, Address address) { - AuthToken.SaveToken(authToken); - localIdentity = identity; +```csharp +class DbConnectionBuilder +{ + public DbConnectionBuilder WithToken(string token = null); } ``` -(You should probably also store the returned `Identity` somewhere; see the [`onIdentityReceived`](#event-spacetimedbclientonidentityreceived) event.) +Chain a call to `.WithToken(token)` to your builder to provide an OpenID Connect compliant JSON Web Token to authenticate with, or to explicitly select an anonymous connection. If this method is not called or `None` is passed, SpacetimeDB will generate a new `Identity` and sign a new private access token for the connection. -### Event `SpacetimeDBClient.onIdentityReceived` +### Method `Build` -```cs -namespace SpacetimeDB { - -class SpacetimeDBClient { - public event Action onIdentityReceived; +```csharp +class DbConnectionBuilder +{ + public DbConnection Build(); } +``` + +After configuring the connection and registering callbacks, attempt to open the connection. + +## Advance the connection and process messages + +In the interest of supporting a wide variety of client applications with different execution strategies, the SpacetimeDB SDK allows you to choose when the `DbConnection` spends compute time and processes messages. If you do not arrange for the connection to advance by calling one of these methods, the `DbConnection` will never advance, and no callbacks will ever be invoked. +| Name | Description | +|---------------------------------------------|-------------------------------------------------------| +| [`FrameTick` method](#method-frametick) | Process messages on the main thread without blocking. | + +#### Method `FrameTick` + +```csharp +class DbConnection { + public void FrameTick(); } ``` -Called when we receive an auth token, [`Identity`](#class-identity) and `Address` from the server. The [`Identity`](#class-identity) serves as a unique public identifier for a user of the database. It can be for several purposes, such as filtering rows in a database for the rows created by a particular user. The auth token is a private access token that allows us to assume an identity. The `Address` is opaque identifier for a client connection to a database, intended to differentiate between connections from the same [`Identity`](#class-identity). +`FrameTick` will advance the connection until no work remains or until it is disconnected, then return rather than blocking. Games might arrange for this message to be called every frame. -To store the auth token to the filesystem, use the static method [`AuthToken.SaveToken`](#static-method-authtokensavetoken). You may also want to store the returned [`Identity`](#class-identity) in a local variable. +It is not advised to run `FrameTick` on a background thread, since it modifies [`dbConnection.Db`](#property-db). If main thread code is also accessing the `Db`, it may observe data races when `FrameTick` runs on another thread. -If an existing auth token is used to connect to the database, the same auth token and the identity it came with will be returned verbatim in `onIdentityReceived`. +(Note that the SDK already does most of the work for parsing messages on a background thread. `FrameTick()` does the minimal amount of work needed to apply updates to the `Db`.) -```cs -// Connect to cloud using a saved identity from the filesystem, or get a new one and save it -AuthToken.Init(); -Identity localIdentity; -SpacetimeDBClient.instance.Connect(AuthToken.Token, "dev.spacetimedb.net", DBNAME, true); -SpacetimeDBClient.instance.onIdentityReceived += (string authToken, Identity identity, Address address) { - AuthToken.SaveToken(authToken); - localIdentity = identity; +## Access tables and reducers + +### Property `Db` + +```csharp +class DbConnection +{ + public RemoteTables Db; + /* other members */ } ``` -### Event `SpacetimeDBClient.onConnect` +The `Db` property of the `DbConnection` provides access to the subscribed view of the remote database's tables. See [Access the client cache](#access-the-client-cache). -```cs -namespace SpacetimeDB { +### Property `Reducers` -class SpacetimeDBClient { - public event Action onConnect; +```csharp +class DbConnection +{ + public RemoteReducers Reducers; + /* other members */ } +``` +The `Reducers` field of the `DbConnection` provides access to reducers exposed by the remote module. See [Observe and invoke reducers](#observe-and-invoke-reducers). + +## Interface `IDbContext` + +```csharp +interface IDbContext +{ + /* methods */ } ``` -Allows registering delegates to be invoked upon authentication with the database. +[`DbConnection`](#type-dbconnection), [`EventContext`](#type-eventcontext), [`ReducerEventContext`](#type-reducereventcontext), [`SubscriptionEventContext`](#type-subscriptioneventcontext) and [`ErrorContext`](#type-errorcontext) all implement `IDbContext`. `IDbContext` has methods for inspecting and configuring your connection to the remote database. -Once this occurs, the SDK is prepared for calls to [`SpacetimeDBClient.Subscribe`](#method-spacetimedbclientsubscribe). +The `IDbContext` interface is implemented by connections and contexts to *every* module - hence why it takes [`DbView`](#method-db) and [`RemoteReducers`](#method-reducers) as type parameters. -## Subscribe to queries +| Name | Description | +|---------------------------------------------------------------|--------------------------------------------------------------------------| +| [`IRemoteDbContext` interface](#interface-iremotedbcontext) | Module-specific `IDbContext`. | +| [`Db` method](#method-db) | Provides access to the subscribed view of the remote database's tables. | +| [`Reducers` method](#method-reducers) | Provides access to reducers exposed by the remote module. | +| [`Disconnect` method](#method-disconnect) | End the connection. | +| [Subscribe to queries](#subscribe-to-queries) | Register SQL queries to receive updates about matching rows. | +| [Read connection metadata](#read-connection-metadata) | Access the connection's `Identity` and `ConnectionId` | -### Method `SpacetimeDBClient.Subscribe` +### Interface `IRemoteDbContext` -```cs -namespace SpacetimeDB { +Each module's `module_bindings` exports an interface `IRemoteDbContext` which inherits from `IDbContext`, with the type parameters `DbView` and `RemoteReducers` bound to the types defined for that module. This can be more convenient when creating functions that can be called from any callback for a specific module, but which access the database or invoke reducers, and so must know the type of the `DbView` or `Reducers`. -class SpacetimeDBClient { - public void Subscribe(List queries); -} +### Method `Db` +```csharp +interface IRemoteDbContext +{ + public DbView Db { get; } } ``` -| Argument | Type | Meaning | -| --------- | -------------- | ---------------------------- | -| `queries` | `List` | SQL queries to subscribe to. | +`Db` will have methods to access each table defined by the module. -Subscribe to a set of queries, to be notified when rows which match those queries are altered. +#### Example -`Subscribe` will return an error if called before establishing a connection with the [`SpacetimeDBClient.Connect`](#method-spacetimedbclientconnect) function. In that case, the queries are not registered. - -The `Subscribe` method does not return data directly. `spacetime generate` will generate classes [`SpacetimeDB.Types.{TABLE}`](#class-table) for each table in your module. These classes are used to reecive information from the database. See the section [View Rows of Subscribed Tables](#view-rows-of-subscribed-tables) for more information. +```csharp +var conn = ConnectToDB(); -A new call to `Subscribe` will remove all previous subscriptions and replace them with the new `queries`. If any rows matched the previous subscribed queries but do not match the new queries, those rows will be removed from the client cache, and [`{TABLE}.OnDelete`](#static-event-tableoninsert) callbacks will be invoked for them. +// Get a handle to the User table +var tableHandle = conn.Db.User; +``` -```cs -using SpacetimeDB; -using SpacetimeDB.Types; +### Method `Reducers` -void Main() +```csharp +interface IRemoteDbContext { - AuthToken.Init(); + public RemoteReducers Reducers { get; } +} +``` - SpacetimeDBClient.instance.onConnect += OnConnect; +`Reducers` will have methods to invoke each reducer defined by the module, +plus methods for adding and removing callbacks on each of those reducers. - // Our module contains a table named "Loot" - Loot.OnInsert += Loot_OnInsert; +#### Example - SpacetimeDBClient.instance.Connect(/* ... */); -} +```csharp +var conn = ConnectToDB(); -void OnConnect() -{ - SpacetimeDBClient.instance.Subscribe(new List { - "SELECT * FROM Loot" - }); -} +// Register a callback to be run every time the SendMessage reducer is invoked +conn.Reducers.OnSendMessage += Reducer_OnSendMessageEvent; +``` + +### Method `Disconnect` -void Loot_OnInsert( - Loot loot, - ReducerEvent? event -) { - Console.Log($"Loaded loot {loot.itemType} at coordinates {loot.position}"); +```csharp +interface IRemoteDbContext +{ + public void Disconnect(); } ``` -### Event `SpacetimeDBClient.onSubscriptionApplied` +Gracefully close the `DbConnection`. Throws an error if the connection is already closed. -```cs -namespace SpacetimeDB { +### Subscribe to queries -class SpacetimeDBClient { - public event Action onSubscriptionApplied; -} +| Name | Description | +|---------------------------------------------------------|-------------------------------------------------------------| +| [`SubscriptionBuilder` type](#type-subscriptionbuilder) | Builder-pattern constructor to register subscribed queries. | +| [`SubscriptionHandle` type](#type-subscriptionhandle) | Manage an active subscripion. | -} -``` +#### Type `SubscriptionBuilder` -Register a delegate to be invoked when a subscription is registered with the database. +| Name | Description | +|----------------------------------------------------------------------------------|-----------------------------------------------------------------| +| [`ctx.SubscriptionBuilder()` constructor](#constructor-ctxsubscriptionbuilder) | Begin configuring a new subscription. | +| [`OnApplied` callback](#callback-onapplied) | Register a callback to run when matching rows become available. | +| [`OnError` callback](#callback-onerror) | Register a callback to run if the subscription fails. | +| [`Subscribe` method](#method-subscribe) | Finish configuration and subscribe to one or more SQL queries. | +| [`SubscribeToAllTables` method](#method-subscribetoalltables) | Convenience method to subscribe to the entire database. | -```cs -using SpacetimeDB; +##### Constructor `ctx.SubscriptionBuilder()` -void OnSubscriptionApplied() +```csharp +interface IRemoteDbContext { - Console.WriteLine("Now listening on queries."); + public SubscriptionBuilder SubscriptionBuilder(); } +``` + +Subscribe to queries by calling `ctx.SubscriptionBuilder()` and chaining configuration methods, then calling `.Subscribe(queries)`. -void Main() +##### Callback `OnApplied` + +```csharp +class SubscriptionBuilder { - // ...initialize... - SpacetimeDBClient.instance.onSubscriptionApplied += OnSubscriptionApplied; + public SubscriptionBuilder OnApplied(Action callback); } ``` -### Method [`SpacetimeDBClient.OneOffQuery`] +Register a callback to run when the subscription is applied and the matching rows are inserted into the client cache. -You may not want to subscribe to a query, but instead want to run a query once and receive the results immediately via a `Task` result: +##### Callback `OnError` ```csharp -// Query all Messages from the sender "bob" -SpacetimeDBClient.instance.OneOffQuery("WHERE sender = \"bob\""); +class SubscriptionBuilder +{ + public SubscriptionBuilder OnError(Action callback); +} ``` -## View rows of subscribed tables +Register a callback to run if the subscription is rejected or unexpectedly terminated by the server. This is most frequently caused by passing an invalid query to [`Subscribe`](#method-subscribe). -The SDK maintains a local view of the database called the "client cache". This cache contains whatever rows are selected via a call to [`SpacetimeDBClient.Subscribe`](#method-spacetimedbclientsubscribe). These rows are represented in the SpacetimeDB .Net SDK as instances of [`SpacetimeDB.Types.{TABLE}`](#class-table). -ONLY the rows selected in a [`SpacetimeDBClient.Subscribe`](#method-spacetimedbclientsubscribe) call will be available in the client cache. All operations in the client sdk operate on these rows exclusively, and have no information about the state of the rest of the database. +##### Method `Subscribe` -In particular, SpacetimeDB does not support foreign key constraints. This means that if you are using a column as a foreign key, SpacetimeDB will not automatically bring in all of the rows that key might reference. You will need to manually subscribe to all tables you need information from. - -To optimize network performance, prefer selecting as few rows as possible in your [`Subscribe`](#method-spacetimedbclientsubscribe) query. Processes that need to view the entire state of the database are better run inside the database -- that is, inside modules. +```csharp +class SubscriptionBuilder +{ + public SubscriptionHandle Subscribe(string[] querySqls); +} +``` -### Class `{TABLE}` +Subscribe to a set of queries. `queries` should be an array of SQL query strings. -For each table defined by a module, `spacetime generate` will generate a class [`SpacetimeDB.Types.{TABLE}`](#class-table) whose name is that table's name converted to `PascalCase`. The generated class contains a property for each of the table's columns, whose names are the column names converted to `camelCase`. It also contains various static events and methods. +See [the SpacetimeDB SQL Reference](/docs/sql#subscriptions) for information on the queries SpacetimeDB supports as subscriptions. -Static Methods: +##### Method `SubscribeToAllTables` -- [`{TABLE}.Iter()`](#static-method-tableiter) iterates all subscribed rows in the client cache. -- [`{TABLE}.FilterBy{COLUMN}(value)`](#static-method-tablefilterbycolumn) filters subscribed rows in the client cache by a column value. -- [`{TABLE}.FindBy{COLUMN}(value)`](#static-method-tablefindbycolumn) finds a subscribed row in the client cache by a unique column value. -- [`{TABLE}.Count()`](#static-method-tablecount) counts the number of subscribed rows in the client cache. +```csharp +class SubscriptionBuilder +{ + public void SubscribeToAllTables(); +} +``` -Static Events: +Subscribe to all rows from all public tables. This method is provided as a convenience for simple clients. The subscription initiated by `SubscribeToAllTables` cannot be canceled after it is initiated. You should [`subscribe` to specific queries](#method-subscribe) if you need fine-grained control over the lifecycle of your subscriptions. -- [`{TABLE}.OnInsert`](#static-event-tableoninsert) is called when a row is inserted into the client cache. -- [`{TABLE}.OnBeforeDelete`](#static-event-tableonbeforedelete) is called when a row is about to be removed from the client cache. -- If the table has a primary key attribute, [`{TABLE}.OnUpdate`](#static-event-tableonupdate) is called when a row is updated. -- [`{TABLE}.OnDelete`](#static-event-tableondelete) is called while a row is being removed from the client cache. You should almost always use [`{TABLE}.OnBeforeDelete`](#static-event-tableonbeforedelete) instead. +#### Type `SubscriptionHandle` -Note that it is not possible to directly insert into the database from the client SDK! All insertion validation should be performed inside serverside modules for security reasons. You can instead [invoke reducers](#observe-and-invoke-reducers), which run code inside the database that can insert rows for you. +A `SubscriptionHandle` represents a subscribed query or a group of subscribed queries. -#### Static Method `{TABLE}.Iter` +The `SubscriptionHandle` does not contain or provide access to the subscribed rows. Subscribed rows of all subscriptions by a connection are contained within that connection's [`ctx.Db`](#property-db). See [Access the client cache](#access-the-client-cache). -```cs -namespace SpacetimeDB.Types { +| Name | Description | +|-------------------------------------------------------|------------------------------------------------------------------------------------------------------------------| +| [`IsEnded` property](#property-isended) | Determine whether the subscription has ended. | +| [`IsActive` property](#property-isactive) | Determine whether the subscription is active and its matching rows are present in the client cache. | +| [`Unsubscribe` method](#method-unsubscribe) | Discard a subscription. | +| [`UnsubscribeThen` method](#method-unsubscribethen) | Discard a subscription, and register a callback to run when its matching rows are removed from the client cache. | -class TABLE { - public static IEnumerable
Iter(); -} +##### Property `IsEnded` +```csharp +class SubscriptionHandle +{ + public bool IsEnded; } ``` -Iterate over all the subscribed rows in the table. This method is only available after [`SpacetimeDBClient.onSubscriptionApplied`](#event-spacetimedbclientonsubscriptionapplied) has occurred. +True if this subscription has been terminated due to an unsubscribe call or an error. -When iterating over rows and filtering for those containing a particular column, [`{TABLE}.FilterBy{COLUMN}`](#static-method-tablefilterbycolumn) and [`{TABLE}.FindBy{COLUMN}`](#static-method-tablefindbycolumn) will be more efficient, so prefer those when possible. +##### Property `IsActive` -```cs -using SpacetimeDB; -using SpacetimeDB.Types; - -SpacetimeDBClient.instance.onConnect += (string authToken, Identity identity) => { - SpacetimeDBClient.instance.Subscribe(new List { "SELECT * FROM User" }); -}; -SpacetimeDBClient.instance.onSubscriptionApplied += () => { - // Will print a line for each `User` row in the database. - foreach (var user in User.Iter()) { - Console.WriteLine($"User: {user.Name}"); - } -}; -SpacetimeDBClient.instance.connect(/* ... */); +```csharp +class SubscriptionHandle +{ + public bool IsActive; +} ``` -#### Static Method `{TABLE}.FilterBy{COLUMN}` - -```cs -namespace SpacetimeDB.Types { +True if this subscription has been applied and has not yet been unsubscribed. -class TABLE { - public static IEnumerable
FilterBySender(COLUMNTYPE value); -} +##### Method `Unsubscribe` +```csharp +class SubscriptionHandle +{ + public void Unsubscribe(); } ``` -For each column of a table, `spacetime generate` generates a static method on the [table class](#class-table) to filter subscribed rows where that column matches a requested value. +Terminate this subscription, causing matching rows to be removed from the client cache. Any rows removed from the client cache this way will have [`OnDelete` callbacks](#callback-ondelete) run for them. -These methods are named `filterBy{COLUMN}`, where `{COLUMN}` is the column name converted to `PascalCase`. The method's return type is an `IEnumerable` over the [table class](#class-table). +Unsubscribing is an asynchronous operation. Matching rows are not removed from the client cache immediately. Use [`UnsubscribeThen`](#method-unsubscribethen) to run a callback once the unsubscribe operation is completed. -#### Static Method `{TABLE}.FindBy{COLUMN}` +Returns an error if the subscription has already ended, either due to a previous call to `Unsubscribe` or [`UnsubscribeThen`](#method-unsubscribethen), or due to an error. -```cs -namespace SpacetimeDB.Types { - -class TABLE { - // If the column has a #[unique] or #[primarykey] constraint - public static TABLE? FindBySender(COLUMNTYPE value); -} +##### Method `UnsubscribeThen` +```csharp +class SubscriptionHandle +{ + public void UnsubscribeThen(Action? onEnded); } ``` -For each unique column of a table (those annotated `#[unique]` or `#[primarykey]`), `spacetime generate` generates a static method on the [table class](#class-table) to seek a subscribed row where that column matches a requested value. +Terminate this subscription, and run the `onEnded` callback when the subscription is ended and its matching rows are removed from the client cache. Any rows removed from the client cache this way will have [`OnDelete` callbacks](#callback-ondelete) run for them. -These methods are named `findBy{COLUMN}`, where `{COLUMN}` is the column name converted to `PascalCase`. Those methods return a single instance of the [table class](#class-table) if a row is found, or `null` if no row matches the query. +Returns an error if the subscription has already ended, either due to a previous call to [`Unsubscribe`](#method-unsubscribe) or `UnsubscribeThen`, or due to an error. -#### Static Method `{TABLE}.Count` +### Read connection metadata -```cs -namespace SpacetimeDB.Types { - -class TABLE { - public static int Count(); -} +#### Property `Identity` +```csharp +interface IDbContext +{ + public Identity? Identity { get; } } ``` -Return the number of subscribed rows in the table, or 0 if there is no active connection. +Get the `Identity` with which SpacetimeDB identifies the connection. This method returns null if the connection was initiated anonymously and the newly-generated `Identity` has not yet been received, i.e. if called before the [`OnConnect` callback](#callback-onconnect) is invoked. -```cs -using SpacetimeDB; -using SpacetimeDB.Types; +#### Property `ConnectionId` -SpacetimeDBClient.instance.onConnect += (string authToken, Identity identity) => { - SpacetimeDBClient.instance.Subscribe(new List { "SELECT * FROM User" }); -}; -SpacetimeDBClient.instance.onSubscriptionApplied += () => { - Console.WriteLine($"There are {User.Count()} users in the database."); -}; -SpacetimeDBClient.instance.connect(/* ... */); +```csharp +interface IDbContext +{ + public ConnectionId ConnectionId { get; } +} ``` -#### Static Event `{TABLE}.OnInsert` +Get the [`ConnectionId`](#type-connectionid) with which SpacetimeDB identifies the connection. -```cs -namespace SpacetimeDB.Types { - -class TABLE { - public delegate void InsertEventHandler( - TABLE insertedValue, - ReducerEvent? dbEvent - ); - public static event InsertEventHandler OnInsert; -} +#### Property `IsActive` +```csharp +interface IDbContext +{ + public bool IsActive { get; } } ``` -Register a delegate for when a subscribed row is newly inserted into the database. +`true` if the connection has not yet disconnected. Note that a connection `IsActive` when it is constructed, before its [`OnConnect` callback](#callback-onconnect) is invoked. -The delegate takes two arguments: +## Type `EventContext` -- A [`{TABLE}`](#class-table) instance with the data of the inserted row -- A [`ReducerEvent?`], which contains the data of the reducer that inserted the row, or `null` if the row is being inserted while initializing a subscription. +An `EventContext` is an [`IDbContext`](#interface-idbcontext) augmented with an [`Event`](#record-event) property. `EventContext`s are passed as the first argument to row callbacks [`OnInsert`](#callback-oninsert), [`OnDelete`](#callback-ondelete) and [`OnUpdate`](#callback-onupdate). -```cs -using SpacetimeDB; -using SpacetimeDB.Types; +| Name | Description | +|-------------------------------------------|---------------------------------------------------------------| +| [`Event` property](#property-event) | Enum describing the cause of the current row callback. | +| [`Db` property](#property-db) | Provides access to the client cache. | +| [`Reducers` property](#property-reducers) | Allows requesting reducers run on the remote database. | +| [`Event` record](#record-event) | Possible events which can cause a row callback to be invoked. | -/* initialize, subscribe to table User... */ +### Property `Event` -User.OnInsert += (User user, ReducerEvent? reducerEvent) => { - if (reducerEvent == null) { - Console.WriteLine($"New user '{user.Name}' received during subscription update."); - } else { - Console.WriteLine($"New user '{user.Name}' inserted by reducer {reducerEvent.Reducer}."); - } -}; +```csharp +class EventContext { + public readonly Event Event; + /* other fields */ +} ``` -#### Static Event `{TABLE}.OnBeforeDelete` +The [`Event`](#record-event) contained in the `EventContext` describes what happened to cause the current row callback to be invoked. -```cs -namespace SpacetimeDB.Types { - -class TABLE { - public delegate void DeleteEventHandler( - TABLE deletedValue, - ReducerEvent dbEvent - ); - public static event DeleteEventHandler OnBeforeDelete; -} +### Property `Db` +```csharp +class EventContext { + public RemoteTables Db; + /* other fields */ } ``` -Register a delegate for when a subscribed row is about to be deleted from the database. If a reducer deletes many rows at once, this delegate will be invoked for each of those rows before any of them is deleted. +The `Db` property of the context provides access to the subscribed view of the remote database's tables. See [Access the client cache](#access-the-client-cache). + +### Field `Reducers` -The delegate takes two arguments: +```csharp +class EventContext { + public RemoteReducers Reducers; + /* other fields */ +} +``` -- A [`{TABLE}`](#class-table) instance with the data of the deleted row -- A [`ReducerEvent`](#class-reducerevent), which contains the data of the reducer that deleted the row. +The `Reducers` property of the context provides access to reducers exposed by the remote module. See [Observe and invoke reducers](#observe-and-invoke-reducers). -This event should almost always be used instead of [`OnDelete`](#static-event-tableondelete). This is because often, many rows will be deleted at once, and `OnDelete` can be invoked in an arbitrary order on these rows. This means that data related to a row may already be missing when `OnDelete` is called. `OnBeforeDelete` does not have this problem. +### Record `Event` -```cs -using SpacetimeDB; -using SpacetimeDB.Types; +| Name | Description | +|-------------------------------------------------------------|--------------------------------------------------------------------------| +| [`Reducer` variant](#variant-reducer) | A reducer ran in the remote database. | +| [`SubscribeApplied` variant](#variant-subscribeapplied) | A new subscription was applied to the client cache. | +| [`UnsubscribeApplied` variant](#variant-unsubscribeapplied) | A previous subscription was removed from the client cache after a call to [`Unsubscribe`](#method-unsubscribe). | +| [`SubscribeError` variant](#variant-subscribeerror) | A previous subscription was removed from the client cache due to an error. | +| [`UnknownTransaction` variant](#variant-unknowntransaction) | A transaction ran in the remote database, but was not attributed to a known reducer. | +| [`ReducerEvent` record](#record-reducerevent) | Metadata about a reducer run. Contained in a [`Reducer` event](#variant-reducer) and [`ReducerEventContext`](#type-reducereventcontext). | +| [`Status` record](#record-status) | Completion status of a reducer run. | +| [`Reducer` record](#record-reducer) | Module-specific generated record with a variant for each reducer defined by the module. | -/* initialize, subscribe to table User... */ +#### Variant `Reducer` -User.OnBeforeDelete += (User user, ReducerEvent reducerEvent) => { - Console.WriteLine($"User '{user.Name}' deleted by reducer {reducerEvent.Reducer}."); -}; +```csharp +record Event +{ + public record Reducer(ReducerEvent ReducerEvent) : Event; +} ``` -#### Static Event `{TABLE}.OnDelete` +Event when we are notified that a reducer ran in the remote module. The [`ReducerEvent`](#record-reducerevent) contains metadata about the reducer run, including its arguments and termination [`Status`](#record-status). -```cs -namespace SpacetimeDB.Types { +This event is passed to row callbacks resulting from modifications by the reducer. -class TABLE { - public delegate void DeleteEventHandler( - TABLE deletedValue, - SpacetimeDB.ReducerEvent dbEvent - ); - public static event DeleteEventHandler OnDelete; -} +#### Variant `SubscribeApplied` +```csharp +record Event +{ + public record SubscribeApplied : Event; } ``` -Register a delegate for when a subscribed row is being deleted from the database. If a reducer deletes many rows at once, this delegate will be invoked on those rows in arbitrary order, and data for some rows may already be missing when it is invoked. For this reason, prefer the event [`{TABLE}.OnBeforeDelete`](#static-event-tableonbeforedelete). - -The delegate takes two arguments: +Event when our subscription is applied and its rows are inserted into the client cache. -- A [`{TABLE}`](#class-table) instance with the data of the deleted row -- A [`ReducerEvent`](#class-reducerevent), which contains the data of the reducer that deleted the row. +This event is passed to [row `OnInsert` callbacks](#callback-oninsert) resulting from the new subscription. -```cs -using SpacetimeDB; -using SpacetimeDB.Types; +#### Variant `UnsubscribeApplied` -/* initialize, subscribe to table User... */ - -User.OnBeforeDelete += (User user, ReducerEvent reducerEvent) => { - Console.WriteLine($"User '{user.Name}' deleted by reducer {reducerEvent.Reducer}."); -}; +```csharp +record Event +{ + public record UnsubscribeApplied : Event; +} ``` -#### Static Event `{TABLE}.OnUpdate` +Event when our subscription is removed after a call to [`SubscriptionHandle.Unsubscribe`](#method-unsubscribe) or [`SubscriptionHandle.UnsubscribeTthen`](#method-unsubscribethen) and its matching rows are deleted from the client cache. -```cs -namespace SpacetimeDB.Types { +This event is passed to [row `OnDelete` callbacks](#callback-ondelete) resulting from the subscription ending. -class TABLE { - public delegate void UpdateEventHandler( - TABLE oldValue, - TABLE newValue, - ReducerEvent dbEvent - ); - public static event UpdateEventHandler OnUpdate; -} +#### Variant `SubscribeError` +```csharp +record Event +{ + public record SubscribeError(Exception Exception) : Event; } ``` -Register a delegate for when a subscribed row is being updated. This event is only available if the row has a column with the `#[primary_key]` attribute. +Event when a subscription ends unexpectedly due to an error. -The delegate takes three arguments: +This event is passed to [row `OnDelete` callbacks](#callback-ondelete) resulting from the subscription ending. -- A [`{TABLE}`](#class-table) instance with the old data of the updated row -- A [`{TABLE}`](#class-table) instance with the new data of the updated row -- A [`ReducerEvent`](#class-reducerevent), which contains the data of the reducer that updated the row. +#### Variant `UnknownTransaction` -```cs -using SpacetimeDB; -using SpacetimeDB.Types; - -/* initialize, subscribe to table User... */ - -User.OnUpdate += (User oldUser, User newUser, ReducerEvent reducerEvent) => { - Debug.Assert(oldUser.UserId == newUser.UserId, "Primary key never changes in an update"); - - Console.WriteLine($"User with ID {oldUser.UserId} had name changed "+ - $"from '{oldUser.Name}' to '{newUser.Name}' by reducer {reducerEvent.Reducer}."); -}; +```csharp +record Event +{ + public record UnknownTransaction : Event; +} ``` -## Observe and invoke reducers +Event when we are notified of a transaction in the remote module which we cannot associate with a known reducer. This may be an ad-hoc SQL query or a reducer for which we do not have bindings. -"Reducer" is SpacetimeDB's name for the stored procedures that run in modules inside the database. You can invoke reducers from a connected client SDK, and also receive information about which reducers are running. +This event is passed to [row callbacks](#callback-oninsert) resulting from modifications by the transaction. -`spacetime generate` generates a class [`SpacetimeDB.Types.Reducer`](#class-reducer) that contains methods and events for each reducer defined in a module. To invoke a reducer, use the method [`Reducer.{REDUCER}`](#static-method-reducerreducer) generated for it. To receive a callback each time a reducer is invoked, use the static event [`Reducer.On{REDUCER}`](#static-event-reduceronreducer). +### Record `ReducerEvent` -### Class `Reducer` +```csharp +record ReducerEvent( + Timestamp Timestamp, + Status Status, + Identity CallerIdentity, + ConnectionId? CallerConnectionId, + U128? EnergyConsumed, + R Reducer +) +``` -```cs -namespace SpacetimeDB.Types { +A `ReducerEvent` contains metadata about a reducer run. -class Reducer {} +### Record `Status` -} +```csharp +record Status : TaggedEnum<( + Unit Committed, + string Failed, + Unit OutOfEnergy +)>; ``` -This class contains a static method and event for each reducer defined in a module. + -#### Static Method `Reducer.{REDUCER}` +| Name | Description | +|-----------------------------------------------|-----------------------------------------------------| +| [`Committed` variant](#variant-committed) | The reducer ran successfully. | +| [`Failed` variant](#variant-failed) | The reducer errored. | +| [`OutOfEnergy` variant](#variant-outofenergy) | The reducer was aborted due to insufficient energy. | -```cs -namespace SpacetimeDB.Types { -class Reducer { +#### Variant `Committed` -/* void {REDUCER_NAME}(...ARGS...) */ +The reducer returned successfully and its changes were committed into the database state. An [`Event.Reducer`](#variant-reducer) passed to a row callback must have this status in its [`ReducerEvent`](#record-reducerevent). -} -} -``` +#### Variant `Failed` -For each reducer defined by a module, `spacetime generate` generates a static method which sends a request to the database to invoke that reducer. The generated function's name is the reducer's name converted to `PascalCase`. +The reducer returned an error, panicked, or threw an exception. The record payload is the stringified error message. Formatting of the error message is unstable and subject to change, so clients should use it only as a human-readable diagnostic, and in particular should not attempt to parse the message. -Reducers don't run immediately! They run as soon as the request reaches the database. Don't assume data inserted by a reducer will be available immediately after you call this method. +#### Variant `OutOfEnergy` -For reducers which accept a `ReducerContext` as their first argument, the `ReducerContext` is not included in the generated function's argument list. +The reducer was aborted due to insufficient energy balance of the module owner. -For example, if we define a reducer in Rust as follows: +### Record `Reducer` -```rust -#[spacetimedb(reducer)] -pub fn set_name( - ctx: ReducerContext, - user_id: u64, - name: String -) -> Result<(), Error>; -``` +The module bindings contains an record `Reducer` with a variant for each reducer defined by the module. Each variant has a payload containing the arguments to the reducer. -The following C# static method will be generated: +## Type `ReducerEventContext` -```cs -namespace SpacetimeDB.Types { -class Reducer { +A `ReducerEventContext` is an [`IDbContext`](#interface-idbcontext) augmented with an [`Event`](#record-reducerevent) property. `ReducerEventContext`s are passed as the first argument to [reducer callbacks](#observe-and-invoke-reducers). -public static void SendMessage(UInt64 userId, string name); +| Name | Description | +|-------------------------------------------|---------------------------------------------------------------------| +| [`Event` property](#property-event) | [`ReducerEvent`](#record-reducerevent) containing reducer metadata. | +| [`Db` property](#property-db) | Provides access to the client cache. | +| [`Reducers` property](#property-reducers) | Allows requesting reducers run on the remote database. | -} +### Property `Event` + +```csharp +class ReducerEventContext { + public readonly ReducerEvent Event; + /* other fields */ } ``` -#### Static Event `Reducer.On{REDUCER}` +The [`ReducerEvent`](#record-reducerevent) contained in the `ReducerEventContext` has metadata about the reducer which ran. -```cs -namespace SpacetimeDB.Types { -class Reducer { +### Property `Db` -public delegate void /*{REDUCER}*/Handler(ReducerEvent reducerEvent, /* {ARGS...} */); - -public static event /*{REDUCER}*/Handler On/*{REDUCER}*/Event; - -} +```csharp +class ReducerEventContext { + public RemoteTables Db; + /* other fields */ } ``` -For each reducer defined by a module, `spacetime generate` generates an event to run each time the reducer is invoked. The generated functions are named `on{REDUCER}Event`, where `{REDUCER}` is the reducer's name converted to `PascalCase`. - -The first argument to the event handler is an instance of [`SpacetimeDB.Types.ReducerEvent`](#class-reducerevent) describing the invocation -- its timestamp, arguments, and whether it succeeded or failed. The remaining arguments are the arguments passed to the reducer. Reducers cannot have return values, so no return value information is included. +The `Db` property of the context provides access to the subscribed view of the remote database's tables. See [Access the client cache](#access-the-client-cache). -For example, if we define a reducer in Rust as follows: +### Property `Reducers` -```rust -#[spacetimedb(reducer)] -pub fn set_name( - ctx: ReducerContext, - user_id: u64, - name: String -) -> Result<(), Error>; +```csharp +class ReducerEventContext { + public RemoteReducers Reducers; + /* other fields */ +} ``` -The following C# static method will be generated: +The `Reducers` property of the context provides access to reducers exposed by the remote module. See [Observe and invoke reducers](#observe-and-invoke-reducers). -```cs -namespace SpacetimeDB.Types { -class Reducer { +## Type `SubscriptionEventContext` -public delegate void SetNameHandler( - ReducerEvent reducerEvent, - UInt64 userId, - string name -); -public static event SetNameHandler OnSetNameEvent; +A `SubscriptionEventContext` is an [`IDbContext`](#interface-idbcontext). Unlike the other context types, `SubscriptionEventContext` doesn't have an `Event` property. `SubscriptionEventContext`s are passed to subscription [`OnApplied`](#callback-onapplied) and [`UnsubscribeThen`](#method-unsubscribethen) callbacks. -} +| Name | Description | +|-------------------------------------------|------------------------------------------------------------| +| [`Db` property](#property-db) | Provides access to the client cache. | +| [`Reducers` property](#property-reducers) | Allows requesting reducers run on the remote database. | + +### Property `Db` + +```csharp +class SubscriptionEventContext { + public RemoteTables Db; + /* other fields */ } ``` -Which can be used as follows: +The `Db` property of the context provides access to the subscribed view of the remote database's tables. See [Access the client cache](#access-the-client-cache). -```cs -/* initialize, wait for onSubscriptionApplied... */ +### Property `Reducers` -Reducer.SetNameHandler += ( - ReducerEvent reducerEvent, - UInt64 userId, - string name -) => { - if (reducerEvent.Status == ClientApi.Event.Types.Status.Committed) { - Console.WriteLine($"User with id {userId} set name to {name}"); - } else if (reducerEvent.Status == ClientApi.Event.Types.Status.Failed) { - Console.WriteLine( - $"User with id {userId} failed to set name to {name}:" - + reducerEvent.ErrMessage - ); - } else if (reducerEvent.Status == ClientApi.Event.Types.Status.OutOfEnergy) { - Console.WriteLine( - $"User with id {userId} failed to set name to {name}:" - + "Invoker ran out of energy" - ); - } -}; -Reducer.SetName(USER_ID, NAME); +```csharp +class SubscriptionEventContext { + public RemoteReducers Reducers; + /* other fields */ +} ``` -### Class `ReducerEvent` +The `Reducers` property of the context provides access to reducers exposed by the remote module. See [Observe and invoke reducers](#observe-and-invoke-reducers). -`spacetime generate` defines an class `ReducerEvent` containing an enum `ReducerType` with a variant for each reducer defined by a module. The variant's name will be the reducer's name converted to `PascalCase`. +## Type `ErrorContext` -For example, the example project shown in the Rust Module quickstart will generate the following (abridged) code. +An `ErrorContext` is an [`IDbContext`](#interface-idbcontext) augmented with an `Event` property. `ErrorContext`s are to connections' [`OnDisconnect`](#callback-ondisconnect) and [`OnConnectError`](#callback-onconnecterror) callbacks, and to subscriptions' [`OnError`](#callback-onerror) callbacks. -```cs -namespace SpacetimeDB.Types { +| Name | Description | +|-------------------------------------------|--------------------------------------------------------| +| [`Event` property](#property-event) | The error which caused the current error callback. | +| [`Db` property](#property-db) | Provides access to the client cache. | +| [`Reducers` property](#property-reducers) | Allows requesting reducers run on the remote database. | -public enum ReducerType -{ - /* A member for each reducer in the module, with names converted to PascalCase */ - None, - SendMessage, - SetName, -} -public partial class SendMessageArgsStruct -{ - /* A member for each argument of the reducer SendMessage, with names converted to PascalCase. */ - public string Text; -} -public partial class SetNameArgsStruct -{ - /* A member for each argument of the reducer SetName, with names converted to PascalCase. */ - public string Name; -} -public partial class ReducerEvent : ReducerEventBase { - // Which reducer was invoked - public ReducerType Reducer { get; } - // If event.Reducer == ReducerType.SendMessage, the arguments - // sent to the SendMessage reducer. Otherwise, accesses will - // throw a runtime error. - public SendMessageArgsStruct SendMessageArgs { get; } - // If event.Reducer == ReducerType.SetName, the arguments - // passed to the SetName reducer. Otherwise, accesses will - // throw a runtime error. - public SetNameArgsStruct SetNameArgs { get; } - /* Additional information, present on any ReducerEvent */ - // The name of the reducer. - public string ReducerName { get; } - // The timestamp of the reducer invocation inside the database. - public ulong Timestamp { get; } - // The identity of the client that invoked the reducer. - public SpacetimeDB.Identity Identity { get; } - // Whether the reducer succeeded, failed, or ran out of energy. - public ClientApi.Event.Types.Status Status { get; } - // If event.Status == Status.Failed, the error message returned from inside the module. - public string ErrMessage { get; } -} +### Property `Event` +```csharp +class SubscriptionEventContext { + public readonly Exception Event; + /* other fields */ } ``` -#### Enum `Status` - -```cs -namespace ClientApi { -public sealed partial class Event { -public static partial class Types { - -public enum Status { - Committed = 0, - Failed = 1, - OutOfEnergy = 2, -} +### Property `Db` -} -} +```csharp +class ErrorContext { + public RemoteTables Db; + /* other fields */ } ``` -An enum whose variants represent possible reducer completion statuses of a reducer invocation. +The `Db` property of the context provides access to the subscribed view of the remote database's tables. See [Access the client cache](#access-the-client-cache). -##### Variant `Status.Committed` +### Property `Reducers` -The reducer finished successfully, and its row changes were committed to the database. - -##### Variant `Status.Failed` +```csharp +class ErrorContext { + public RemoteReducers Reducers; + /* other fields */ +} +``` -The reducer failed, either by panicking or returning a `Err`. +The `Reducers` property of the context provides access to reducers exposed by the remote module. See [Observe and invoke reducers](#observe-and-invoke-reducers). -##### Variant `Status.OutOfEnergy` +## Access the client cache -The reducer was canceled because the module owner had insufficient energy to allow it to run to completion. +All [`IDbContext`](#interface-idbcontext) implementors, including [`DbConnection`](#type-dbconnection) and [`EventContext`](#type-eventcontext), have `.Db` properties, which in turn have methods for accessing tables in the client cache. -## Identity management +Each table defined by a module has an accessor method, whose name is the table name converted to `snake_case`, on this `.Db` property. The table accessor methods return table handles which inherit from [`RemoteTableHandle`](#type-remotetablehandle) and have methods for searching by index. -### Class `AuthToken` +| Name | Description | +|-------------------------------------------------------------------|---------------------------------------------------------------------------------| +| [`RemoteTableHandle`](#type-remotetablehandle) | Provides access to subscribed rows of a specific table within the client cache. | +| [Unique constraint index access](#unique-constraint-index-access) | Seek a subscribed row by the value in its unique or primary key column. | +| [BTree index access](#btree-index-access) | Seek subscribed rows by the value in its indexed column. | -The AuthToken helper class handles creating and saving SpacetimeDB identity tokens in the filesystem. +### Type `RemoteTableHandle` -#### Static Method `AuthToken.Init` +Implemented by all table handles. -```cs -namespace SpacetimeDB { +| Name | Description | +|-----------------------------------------------|------------------------------------------------------------------------------| +| [`Row` type parameter](#type-row) | The type of rows in the table. | +| [`Count` property](#property-count) | The number of subscribed rows in the table. | +| [`Iter` method](#method-iter) | Iterate over all subscribed rows in the table. | +| [`OnInsert` callback](#callback-oninsert) | Register a callback to run whenever a row is inserted into the client cache. | +| [`OnDelete` callback](#callback-ondelete) | Register a callback to run whenever a row is deleted from the client cache. | +| [`OnUpdate` callback](#callback-onupdate) | Register a callback to run whenever a subscribed row is replaced with a new version. | -class AuthToken { - public static void Init( - string configFolder = ".spacetime_csharp_sdk", - string configFile = "settings.ini", - string? configRoot = null - ); -} +#### Type `Row` +```csharp +class RemoteTableHandle +{ + /* members */ } ``` -Creates a file `$"{configRoot}/{configFolder}/{configFile}"` to store tokens. -If no arguments are passed, the default is `"%HOME%/.spacetime_csharp_sdk/settings.ini"`. +The type of rows in the table. -| Argument | Type | Meaning | -| -------------- | -------- | ---------------------------------------------------------------------------------- | -| `configFolder` | `string` | The folder to store the config file in. Default is `"spacetime_csharp_sdk"`. | -| `configFile` | `string` | The name of the config file. Default is `"settings.ini"`. | -| `configRoot` | `string` | The root folder to store the config file in. Default is the user's home directory. | +#### Property `Count` -#### Static Property `AuthToken.Token` +```csharp +class RemoteTableHandle +{ + public int Count; +} +``` -```cs -namespace SpacetimeDB { +The number of rows of this table resident in the client cache, i.e. the total number which match any subscribed query. -class AuthToken { - public static string? Token { get; } -} +#### Method `Iter` +```csharp +class RemoteTableHandle +{ + public IEnumerable Iter(); } ``` -The auth token stored on the filesystem, if one exists. - -#### Static Method `AuthToken.SaveToken` - -```cs -namespace SpacetimeDB { +An iterator over all the subscribed rows in the client cache, i.e. those which match any subscribed query. -class AuthToken { - public static void SaveToken(string token); -} +#### Callback `OnInsert` +```csharp +class RemoteTableHandle +{ + public delegate void RowEventHandler(EventContext context, Row row); + public event RowEventHandler? OnInsert; } ``` -Save a token to the filesystem. +The `OnInsert` callback runs whenever a new row is inserted into the client cache, either when applying a subscription or being notified of a transaction. The passed [`EventContext`](#type-eventcontext) contains an [`Event`](#record-event) which can identify the change which caused the insertion, and also allows the callback to interact with the connection, inspect the client cache and invoke reducers. Newly registered or canceled callbacks do not take effect until the following event. + +See [the quickstart](/docs/sdks/c-sharp/quickstart#register-callbacks) for examples of regstering and unregistering row callbacks. -### Class `Identity` +#### Callback `OnDelete` -```cs -namespace SpacetimeDB +```csharp +class RemoteTableHandle { - public struct Identity : IEquatable - { - public byte[] Bytes { get; } - public static Identity From(byte[] bytes); - public bool Equals(Identity other); - public static bool operator ==(Identity a, Identity b); - public static bool operator !=(Identity a, Identity b); - } + public delegate void RowEventHandler(EventContext context, Row row); + public event RowEventHandler? OnDelete; } ``` -A unique public identifier for a user of a database. +The `OnDelete` callback runs whenever a previously-resident row is deleted from the client cache. Newly registered or canceled callbacks do not take effect until the following event. - +See [the quickstart](/docs/sdks/c-sharp/quickstart#register-callbacks) for examples of regstering and unregistering row callbacks. -Columns of type `Identity` inside a module will be represented in the C# SDK as properties of type `byte[]`. `Identity` is essentially just a wrapper around `byte[]`, and you can use the `Bytes` property to get a `byte[]` that can be used to filter tables and so on. +#### Callback `OnUpdate` -```cs -namespace SpacetimeDB +```csharp +class RemoteTableHandle { - public struct Address : IEquatable
- { - public byte[] Bytes { get; } - public static Address? From(byte[] bytes); - public bool Equals(Address other); - public static bool operator ==(Address a, Address b); - public static bool operator !=(Address a, Address b); - } + public delegate void RowEventHandler(EventContext context, Row row); + public event RowEventHandler? OnUpdate; } ``` -An opaque identifier for a client connection to a database, intended to differentiate between connections from the same [`Identity`](#class-identity). - -## Customizing logging +The `OnUpdate` callback runs whenever an already-resident row in the client cache is updated, i.e. replaced with a new row that has the same primary key. The table must have a primary key for callbacks to be triggered. Newly registered or canceled callbacks do not take effect until the following event. -The SpacetimeDB C# SDK performs internal logging. +See [the quickstart](/docs/sdks/c-sharp/quickstart#register-callbacks) for examples of regstering and unregistering row callbacks. -A default logger is set up automatically for you - a [`ConsoleLogger`](#class-consolelogger) for C# projects and [`UnityDebugLogger`](#class-unitydebuglogger) for Unity projects. +### Unique constraint index access -If you want to redirect SDK logs elsewhere, you can inherit from the [`ISpacetimeDBLogger`](#interface-ispacetimedblogger) and assign an instance of your class to the `SpacetimeDB.Logger.Current` static property. +For each unique constraint on a table, its table handle has a property which is a unique index handle and whose name is the unique column name. This unique index handle has a method `.Find(Column value)`. If a `Row` with `value` in the unique column is resident in the client cache, `.Find` returns it. Otherwise it returns null. -### Interface `ISpacetimeDBLogger` -```cs -namespace SpacetimeDB -{ +#### Example -public interface ISpacetimeDBLogger +Given the following module-side `User` definition: +```csharp +[Table(Name = "User", Public = true)] +public partial class User { - void Log(string message); - void LogError(string message); - void LogWarning(string message); - void LogException(Exception e); + [Unique] // Or [PrimaryKey] + public Identity Identity; + .. } +``` -} +a client would lookup a user as follows: +```csharp +User? FindUser(RemoteTables tables, Identity id) => tables.User.Identity.Find(id); ``` -This interface provides methods that are invoked when the SpacetimeDB C# SDK needs to log at various log levels. You can create custom implementations if needed to integrate with existing logging solutions. +### BTree index access -### Class `ConsoleLogger` +For each btree index defined on a remote table, its corresponding table handle has a property which is a btree index handle and whose name is the name of the index. This index handle has a method `IEnumerable Filter(Column value)` which will return `Row`s with `value` in the indexed `Column`, if there are any in the cache. -```cs -namespace SpacetimeDB { +#### Example -public class ConsoleLogger : ISpacetimeDBLogger {} +Given the following module-side `Player` definition: +```csharp +[Table(Name = "Player", Public = true)] +public partial class Player +{ + [PrimaryKey] + public Identity id; + [Index.BTree(Name = "Level")] + public uint level; + .. } ``` -An `ISpacetimeDBLogger` implementation for regular .NET applications, using `Console.Write` when logs are received. +a client would count the number of `Player`s at a certain level as follows: +```csharp +int CountPlayersAtLevel(RemoteTables tables, uint level) => tables.Player.Level.Filter(level).Count(); +``` -### Class `UnityDebugLogger` +## Observe and invoke reducers -```cs -namespace SpacetimeDB { +All [`IDbContext`](#interface-idbcontext) implementors, including [`DbConnection`](#type-dbconnection) and [`EventContext`](#type-eventcontext), have a `.Reducers` property, which in turn has methods for invoking reducers defined by the module and registering callbacks on it. -public class UnityDebugLogger : ISpacetimeDBLogger {} +Each reducer defined by the module has three methods on the `.Reducers`: -} -``` +- An invoke method, whose name is the reducer's name converted to snake case, like `set_name`. This requests that the module run the reducer. +- A callback registation method, whose name is prefixed with `on_`, like `on_set_name`. This registers a callback to run whenever we are notified that the reducer ran, including successfully committed runs and runs we requested which failed. This method returns a callback id, which can be passed to the callback remove method. +- A callback remove method, whose name is prefixed with `remove_on_`, like `remove_on_set_name`. This cancels a callback previously registered via the callback registration method. + +## Identify a client + +### Type `Identity` + +A unique public identifier for a client connected to a database. +See the [module docs](/docs/modules/c-sharp#struct-identity) for more details. + +### Type `ConnectionId` + +An opaque identifier for a client connection to a database, intended to differentiate between connections from the same [`Identity`](#type-identity). +See the [module docs](/docs/modules/c-sharp#struct-connectionid) for more details. + +### Type `Timestamp` + +A point in time, measured in microseconds since the Unix epoch. +See the [module docs](/docs/modules/c-sharp#struct-timestamp) for more details. + +### Type `TaggedEnum` -An `ISpacetimeDBLogger` implementation for Unity, using the Unity `Debug.Log` api. +A [tagged union](https://en.wikipedia.org/wiki/Tagged_union) type. +See the [module docs](/docs/modules/c-sharp#record-taggedenum) for more details. From 5f894e6c4eac059c6b0fbf03e12e767a77ce0ee6 Mon Sep 17 00:00:00 2001 From: Noa Date: Mon, 3 Mar 2025 19:06:25 -0600 Subject: [PATCH 124/164] Describe how a JWT's sub/iss are translated into an Identity (#204) --- docs/index.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/index.md b/docs/index.md index 9180ff68..6e4a0b65 100644 --- a/docs/index.md +++ b/docs/index.md @@ -237,6 +237,26 @@ Modules themselves also have Identities. When you `spacetime publish` a module, Identities are issued using the [OpenID Connect](https://openid.net/developers/how-connect-works/) specification. Database developers are responsible for issuing Identities to their end users. OpenID Connect lets users log in to these accounts through standard services like Google and Facebook. +Specifically, an identity is derived from the issuer and subject fields of a [JSON Web Token (JWT)](https://jwt.io/) hashed together. The psuedocode for this is as follows: + +```python +def identity_from_claims(issuer: str, subject: str) -> [u8; 32]: + hash1: [u8; 32] = blake3_hash(issuer + "|" + subject) + id_hash: [u8; 26] = hash1[:26] + checksum_hash: [u8; 32] = blake3_hash([ + 0xC2, + 0x00, + *id_hash + ]) + identity_big_endian_bytes: [u8; 32] = [ + 0xC2, + 0x00, + *checksum_hash[:4], + *id_hash + ] + return identity_big_endian_bytes +``` + ### ConnectionId From 8338e7221f4cbe983b4d2e39ad29dc7429e12138 Mon Sep 17 00:00:00 2001 From: rekhoff Date: Mon, 3 Mar 2025 18:21:44 -0800 Subject: [PATCH 125/164] Rekhoff/blackholio fixes (#205) * Updates to blackholio tutorials Page 3 - Fix duplicate code in Rust "disconnect reducer" instructions. Page 4 - Update use of `CallerIdentity` to `Sender` in C# instructions. * Fixes from running through Blackholio tutorial in Rust and C# on 1.0.0 * Minor formatting updates * Minor formatting update * Another minor format change * Whitespace fix. Co-authored-by: John Detter <4099508+jdetter@users.noreply.github.com> * Whitespace fix. Co-authored-by: John Detter <4099508+jdetter@users.noreply.github.com> * Whitespace fix. Co-authored-by: John Detter <4099508+jdetter@users.noreply.github.com> --------- Co-authored-by: John Detter <4099508+jdetter@users.noreply.github.com> --- docs/unity/part-2.md | 39 ++++++++++++++++++++++++++------------- docs/unity/part-3.md | 27 +++++++++++++-------------- docs/unity/part-4.md | 20 ++++++++++++++------ 3 files changed, 53 insertions(+), 33 deletions(-) diff --git a/docs/unity/part-2.md b/docs/unity/part-2.md index 9e9936c9..54c1983a 100644 --- a/docs/unity/part-2.md +++ b/docs/unity/part-2.md @@ -109,7 +109,7 @@ public partial struct Config Let's break down this code. This defines a normal C# `struct` with two fields: `id` and `world_size`. We have added the `[Table(Name = "config", Public = true)]` attribute the struct. This attribute signals to SpacetimeDB that it should create a new SpacetimeDB table with the row type defined by the `Config` type's fields. > Although we're using `lower_snake_case` for our column names to have consistent column names across languages in this tutorial, you can also use `camelCase` or `PascalCase` if you prefer. See [#2168](https://github.com/clockworklabs/SpacetimeDB/issues/2168) for more information. - + The `Table` attribute with takes two parameters, a `Name` which is the name of the table and what you will use to query the table in SQL, and a `Public` visibility modifier which ensures that the rows of this table are visible to everyone. The `[PrimaryKey]` attribute, specifies that the `id` field should be used as the primary key of the table. @@ -294,7 +294,7 @@ Add this function to the `Module` class in `Lib.cs`: [Reducer] public static void Debug(ReducerContext ctx) { - Log.Info($"This reducer was called by {ctx.CallerIdentity}"); + Log.Info($"This reducer was called by {ctx.Sender}"); } ``` ::: @@ -395,7 +395,7 @@ pub fn connect(ctx: &ReducerContext) -> Result<(), String> { The `client_connected` argument to the `spacetimedb::reducer` macro indicates to SpacetimeDB that this is a special reducer. This reducer is only every called by SpacetimeDB itself when a client connects to your module. > SpacetimeDB gives you the ability to define custom reducers that automatically trigger when certain events occur. -> +> > - `init` - Called the first time you publish your module and anytime you clear the database with `spacetime publish --delete-data`. > - `client_connected` - Called when a user connects to the SpacetimeDB module. Their identity can be found in the `sender` value of the `ReducerContext`. > - `client_disconnected` - Called when a user disconnects from the SpacetimeDB module. @@ -407,16 +407,16 @@ Next let's connect our client to our module. Let's start by modifying our `Debug [Reducer(ReducerKind.ClientConnected)] public static void Connect(ReducerContext ctx) { - Log.Info($"{ctx.CallerIdentity} just connected."); + Log.Info($"{ctx.Sender} just connected."); } ``` The `ReducerKind.ClientConnected` argument to the `SpacetimeDB.Reducer` attribute indicates to SpacetimeDB that this is a special reducer. This reducer is only every called by SpacetimeDB itself when a client connects to your module. > SpacetimeDB gives you the ability to define custom reducers that automatically trigger when certain events occur. -> +> > - `ReducerKind.Init` - Called the first time you publish your module and anytime you clear the database with `spacetime publish --delete-data`. -> - `ReducerKind.ClientConnected` - Called when a user connects to the SpacetimeDB module. Their identity can be found in the `CallerIdentity` value of the `ReducerContext`. +> - `ReducerKind.ClientConnected` - Called when a user connects to the SpacetimeDB module. Their identity can be found in the `Sender` value of the `ReducerContext`. > - `ReducerKind.ClientDisconnected` - Called when a user disconnects from the SpacetimeDB module. ::: @@ -443,13 +443,26 @@ spacetime generate --lang csharp --out-dir ../client-unity/Assets/autogen # you This will generate a set of files in the `client-unity/Assets/autogen` directory which contain the code generated types and reducer functions that are defined in your module, but usable on the client. -```sh -ls ../client-unity/Assets/autogen/*.cs -../client-unity/Assets/autogen/Circle.cs ../client-unity/Assets/autogen/DbVector2.cs ../client-unity/Assets/autogen/Food.cs -../client-unity/Assets/autogen/Config.cs ../client-unity/Assets/autogen/Entity.cs ../client-unity/Assets/autogen/Player.cs +``` +├── Reducers +│ └── Connect.g.cs +├── Tables +│ ├── Circle.g.cs +│ ├── Config.g.cs +│ ├── Entity.g.cs +│ ├── Food.g.cs +│ └── Player.g.cs +├── Types +│ ├── Circle.g.cs +│ ├── Config.g.cs +│ ├── DbVector2.g.cs +│ ├── Entity.g.cs +│ ├── Food.g.cs +│ └── Player.g.cs +└── SpacetimeDBClient.g.cs ``` -This will also generate a file in the `client-unity/Assets/autogen/_Globals` directory with a type aware `DbConnection` class. We will use this class to connect to your module from Unity. +This will also generate a file in the `client-unity/Assets/autogen/SpacetimeDBClient.g.cs` directory with a type aware `DbConnection` class. We will use this class to connect to your module from Unity. > IMPORTANT! At this point there will be an error in your Unity project. Due to a [known issue](https://docs.unity3d.com/6000.0/Documentation/Manual/csharp-compiler.html) with Unity and C# 9 you need to insert the following code into your Unity project. > @@ -509,7 +522,7 @@ public class GameManager : MonoBehaviour // If the user has a SpacetimeDB auth token stored in the Unity PlayerPrefs, // we can use it to authenticate the connection. - if (PlayerPrefs.HasKey(AuthToken.GetTokenKey())) + if (AuthToken.Token != "") { builder = builder.WithToken(AuthToken.Token); } @@ -548,7 +561,7 @@ public class GameManager : MonoBehaviour } } - private void HandleSubscriptionApplied(EventContext ctx) + private void HandleSubscriptionApplied(SubscriptionEventContext ctx) { Debug.Log("Subscription applied!"); OnSubscriptionApplied?.Invoke(); diff --git a/docs/unity/part-3.md b/docs/unity/part-3.md index f5f49bd4..52206f25 100644 --- a/docs/unity/part-3.md +++ b/docs/unity/part-3.md @@ -106,7 +106,7 @@ const uint TARGET_FOOD_COUNT = 600; public static float MassToRadius(uint mass) => MathF.Sqrt(mass); [Reducer] -public static void SpawnFood(ReducerContext ctx) +public static void SpawnFood(ReducerContext ctx, SpawnFoodTimer timer) { if (ctx.Db.player.Count == 0) //Are there no players yet? { @@ -220,7 +220,7 @@ pub fn init(ctx: &ReducerContext) -> Result<(), String> { })?; ctx.db.spawn_food_timer().try_insert(SpawnFoodTimer { scheduled_id: 0, - scheduled_at: ScheduleAt::Interval(Duration::from_millis(500).as_micros() as u64), + scheduled_at: ScheduleAt::Interval(Duration::from_millis(500).into()), })?; Ok(()) } @@ -347,7 +347,7 @@ Next, modify your `Connect` reducer and add a new `Disconnect` reducer below it: [Reducer(ReducerKind.ClientConnected)] public static void Connect(ReducerContext ctx) { - var player = ctx.Db.logged_out_player.identity.Find(ctx.CallerIdentity); + var player = ctx.Db.logged_out_player.identity.Find(ctx.Sender); if (player != null) { ctx.Db.player.Insert(player.Value); @@ -357,7 +357,7 @@ public static void Connect(ReducerContext ctx) { ctx.Db.player.Insert(new Player { - identity = ctx.CallerIdentity, + identity = ctx.Sender, name = "", }); } @@ -366,7 +366,7 @@ public static void Connect(ReducerContext ctx) [Reducer(ReducerKind.ClientDisconnected)] public static void Disconnect(ReducerContext ctx) { - var player = ctx.Db.player.identity.Find(ctx.CallerIdentity) ?? throw new Exception("Player not found"); + var player = ctx.Db.player.identity.Find(ctx.Sender) ?? throw new Exception("Player not found"); ctx.Db.logged_out_player.Insert(player); ctx.Db.player.identity.Delete(player.identity); } @@ -463,7 +463,7 @@ const uint START_PLAYER_MASS = 15; public static void EnterGame(ReducerContext ctx, string name) { Log.Info($"Creating player with name {name}"); - var player = ctx.Db.player.identity.Find(ctx.CallerIdentity) ?? throw new Exception("Player not found"); + var player = ctx.Db.player.identity.Find(ctx.Sender) ?? throw new Exception("Player not found"); player.name = name; ctx.Db.player.identity.Update(player); SpawnPlayerInitialCircle(ctx, player.player_id); @@ -539,7 +539,7 @@ pub fn disconnect(ctx: &ReducerContext) -> Result<(), String> { [Reducer(ReducerKind.ClientDisconnected)] public static void Disconnect(ReducerContext ctx) { - var player = ctx.Db.player.identity.Find(ctx.CallerIdentity) ?? throw new Exception("Player not found"); + var player = ctx.Db.player.identity.Find(ctx.Sender) ?? throw new Exception("Player not found"); // Remove any circles from the arena foreach (var circle in ctx.Db.circle.player_id.Filter(player.player_id)) { @@ -594,7 +594,7 @@ Start by adding `SetupArena` and `CreateBorderCube` methods to your `GameManager In your `HandleSubscriptionApplied` let's now call `SetupArea` method. Modify your `HandleSubscriptionApplied` method as in the below. ```cs - private void HandleSubscriptionApplied(EventContext ctx) + private void HandleSubscriptionApplied(SubscriptionEventContext ctx) { Debug.Log("Subscription applied!"); OnSubscriptionApplied?.Invoke(); @@ -667,7 +667,7 @@ public abstract class EntityController : MonoBehaviour protected float LerpTime; protected Vector3 LerpStartPosition; - protected Vector3 LerpTargetPositio; + protected Vector3 LerpTargetPosition; protected Vector3 TargetScale; protected virtual void Spawn(uint entityId) @@ -675,7 +675,7 @@ public abstract class EntityController : MonoBehaviour EntityId = entityId; var entity = GameManager.Conn.Db.Entity.EntityId.Find(entityId); - LerpStartPosition = LerpTargetPositio = transform.position = (Vector2)entity.Position; + LerpStartPosition = LerpTargetPosition = transform.position = (Vector2)entity.Position; transform.localScale = Vector3.one; TargetScale = MassToScale(entity.Mass); } @@ -689,7 +689,7 @@ public abstract class EntityController : MonoBehaviour { LerpTime = 0.0f; LerpStartPosition = transform.position; - LerpTargetPositio = (Vector2)newVal.Position; + LerpTargetPosition = (Vector2)newVal.Position; TargetScale = MassToScale(newVal.Mass); } @@ -702,7 +702,7 @@ public abstract class EntityController : MonoBehaviour { // Interpolate position and scale LerpTime = Mathf.Min(LerpTime + Time.deltaTime, LERP_DURATION_SEC); - transform.position = Vector3.Lerp(LerpStartPosition, LerpTargetPositio, LerpTime / LERP_DURATION_SEC); + transform.position = Vector3.Lerp(LerpStartPosition, LerpTargetPosition, LerpTime / LERP_DURATION_SEC); transform.localScale = Vector3.Lerp(transform.localScale, TargetScale, Time.deltaTime * 8); } @@ -1178,11 +1178,10 @@ At this point, you may need to regenerate your bindings the following command fr spacetime generate --lang csharp --out-dir ../client-unity/Assets/autogen ``` -> **BUG WORKAROUND NOTE**: As of `1.0.0-rc3` you will now have a compilation error in Unity. There is currently a bug in the C# code generation that requires you to delete `autogen/LoggedOutPlayer.cs` after running this command. The last step is to call the `enter_game` reducer on the server, passing in a username for our player, which will spawn a circle for our player. For the sake of simplicity, let's call the `enter_game` reducer from the `HandleSubscriptionApplied` callback with the name "3Blave". ```cs - private void HandleSubscriptionApplied(EventContext ctx) + private void HandleSubscriptionApplied(SubscriptionEventContext ctx) { Debug.Log("Subscription applied!"); OnSubscriptionApplied?.Invoke(); diff --git a/docs/unity/part-4.md b/docs/unity/part-4.md index 26676126..e2b58dd5 100644 --- a/docs/unity/part-4.md +++ b/docs/unity/part-4.md @@ -206,7 +206,6 @@ public static void UpdatePlayerInput(ReducerContext ctx, DbVector2 direction) circle.speed = Math.Clamp(direction.Magnitude, 0f, 1f); ctx.Db.circle.entity_id.Update(circle); } - } ``` @@ -243,7 +242,12 @@ pub fn move_all_players(ctx: &ReducerContext, _timer: MoveAllPlayersTimer) -> Re // Handle player input for circle in ctx.db.circle().iter() { - let mut circle_entity = ctx.db.entity().entity_id().find(&circle.entity_id).unwrap(); + let circle_entity = ctx.db.entity().entity_id().find(&circle.entity_id); + if !circle_entity.is_some() { + // This can happen if a circle is eaten by another circle + continue; + } + let mut circle_entity = circle_entity.unwrap(); let circle_radius = mass_to_radius(circle_entity.mass); let direction = circle.direction * circle.speed; let new_pos = @@ -283,7 +287,13 @@ public static void MoveAllPlayers(ReducerContext ctx, MoveAllPlayersTimer timer) // Handle player input foreach (var circle in ctx.Db.circle.Iter()) { - var circle_entity = ctx.Db.entity.entity_id.Find(circle.entity_id) ?? throw new Exception("Circle has no entity"); + var check_entity = ctx.Db.entity.entity_id.Find(circle.entity_id); + if (check_entity == null) + { + // This can happen if the circle has been eaten by another circle. + continue; + } + var circle_entity = check_entity.Value;; var circle_radius = MassToRadius(circle_entity.mass); var direction = circle_directions[circle.entity_id]; var new_pos = circle_entity.position + direction * MassToMaxMoveSpeed(circle_entity.mass); @@ -335,8 +345,6 @@ Regenerate your server bindings with: spacetime generate --lang csharp --out-dir ../client-unity/Assets/autogen ``` -> **BUG WORKAROUND NOTE**: You may have to delete LoggedOutPlayer.cs again. - ### Moving on the Client All that's left is to modify our `PlayerController` on the client to call the `update_player_input` reducer. Open `PlayerController.cs` and add an `Update` function: @@ -511,7 +519,7 @@ public static void MoveAllPlayers(ReducerContext ctx, MoveAllPlayersTimer timer) // This can happen if the circle has been eaten by another circle. continue; } - var circle_entity = check_entity.Value; + var circle_entity = check_entity.Value;; var circle_radius = MassToRadius(circle_entity.mass); var direction = circle.direction * circle.speed; var new_pos = circle_entity.position + direction * MassToMaxMoveSpeed(circle_entity.mass); From 3c00af3682ca3f9e16a4cb3fd0b66190799d98c8 Mon Sep 17 00:00:00 2001 From: John Detter <4099508+jdetter@users.noreply.github.com> Date: Tue, 4 Mar 2025 11:08:17 -0600 Subject: [PATCH 126/164] Bump Typescript SDK Version in Quickstart (#208) We didn't bump this version number Co-authored-by: John Detter --- docs/sdks/typescript/quickstart.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/sdks/typescript/quickstart.md b/docs/sdks/typescript/quickstart.md index d6f73f33..fcd1fc98 100644 --- a/docs/sdks/typescript/quickstart.md +++ b/docs/sdks/typescript/quickstart.md @@ -28,7 +28,7 @@ pnpm install We also need to install the `spacetime-client-sdk` package: ```bash -pnpm install @clockworklabs/spacetimedb-sdk@1.0.0-rc1.0 +pnpm install @clockworklabs/spacetimedb-sdk@1.0.1 ``` > If you are using another package manager like `yarn` or `npm`, the same steps should work with the appropriate commands for those tools. @@ -672,4 +672,4 @@ At this point you've learned how to create a basic TypeScript client for your Sp ## What's next? -We covered a lot here, but we haven't covered everything. Take a look at our [reference documentation](/docs/sdks/typescript) to find out how you can use SpacetimeDB in more advanced ways, including managing reducer errors and subscribing to reducer events. \ No newline at end of file +We covered a lot here, but we haven't covered everything. Take a look at our [reference documentation](/docs/sdks/typescript) to find out how you can use SpacetimeDB in more advanced ways, including managing reducer errors and subscribing to reducer events. From 06d65c514a14d7d5388982a5ac279b735c9525fd Mon Sep 17 00:00:00 2001 From: Steve Biedermann Date: Tue, 4 Mar 2025 19:34:52 +0100 Subject: [PATCH 127/164] Fix code error and wrong code block (#210) --- docs/modules/rust/quickstart.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/modules/rust/quickstart.md b/docs/modules/rust/quickstart.md index 04b7d206..0670bb89 100644 --- a/docs/modules/rust/quickstart.md +++ b/docs/modules/rust/quickstart.md @@ -110,7 +110,7 @@ To `server/src/lib.rs`, add: pub fn set_name(ctx: &ReducerContext, name: String) -> Result<(), String> { let name = validate_name(name)?; if let Some(user) = ctx.db.user().identity().find(ctx.sender) { - ctx.db.user().identity().update(User { name: Some(name), ..user }) + ctx.db.user().identity().update(User { name: Some(name), ..user }); Ok(()) } else { Err("Cannot set name for unknown user".to_string()) @@ -205,7 +205,8 @@ pub fn client_connected(ctx: &ReducerContext) { online: true, }); } -}``` +} +``` Similarly, whenever a client disconnects, the module will run the `#[reducer(client_disconnected)]` reducer if it's defined. By convention, it's named `client_disconnected`. We'll use it to un-set the `online` status of the `User` for the disconnected client. From b6b52299051dc515986df58ee23d5155fea30583 Mon Sep 17 00:00:00 2001 From: John Detter <4099508+jdetter@users.noreply.github.com> Date: Tue, 4 Mar 2025 13:04:52 -0600 Subject: [PATCH 128/164] Self-Hosted guide (#206) * Standalone guide * Several improvements * Title update * Updated nav.js * Guide updated * Small fix * Guide working again after `--root-dir` change * Finished + tested * Apply suggestions from code review Co-authored-by: Mazdak Farrokhzad * Updates after review * Update navigation * Apply suggestions from code review * Update docs/deploying/spacetimedb-standalone.md * Update docs/deploying/spacetimedb-standalone.md --------- Co-authored-by: John Detter Co-authored-by: Mazdak Farrokhzad --- docs/deploying/spacetimedb-standalone.md | 240 +++++++++++++++++++++++ docs/nav.js | 1 + nav.ts | 1 + 3 files changed, 242 insertions(+) create mode 100644 docs/deploying/spacetimedb-standalone.md diff --git a/docs/deploying/spacetimedb-standalone.md b/docs/deploying/spacetimedb-standalone.md new file mode 100644 index 00000000..34cb8ccf --- /dev/null +++ b/docs/deploying/spacetimedb-standalone.md @@ -0,0 +1,240 @@ +# Self Hosting SpacetimeDB + +This tutorial will guide you through setting up SpacetimeDB on an Ubuntu 24.04 server, securing it with HTTPS using Nginx and Let's Encrypt, and configuring a systemd service to keep it running. + +## Prerequisites +- A fresh Ubuntu 24.04 server (VM or cloud instance of your choice) +- A domain name (e.g., `example.com`) +- `sudo` privileges on the server + +## Step 1: Create a Dedicated User for SpacetimeDB +For security purposes, create a dedicated `spacetimedb` user to run SpacetimeDB: + +```sh +sudo mkdir /stdb +sudo useradd --system spacetimedb +sudo chown -R spacetimedb:spacetimedb /stdb +``` + +Install SpacetimeDB as the new user: + +```sh +sudo -u spacetimedb bash -c 'curl -sSf https://install.spacetimedb.com | sh -s -- --root-dir /stdb --yes' +``` + +## Step 2: Create a Systemd Service for SpacetimeDB +To ensure SpacetimeDB runs on startup, create a systemd service file: + +```sh +sudo nano /etc/systemd/system/spacetimedb.service +``` + +Add the following content: + +```ini +[Unit] +Description=SpacetimeDB Server +After=network.target + +[Service] +ExecStart=/stdb/spacetime --root-dir=/stdb start --listen-addr='127.0.0.1:3000' +Restart=always +User=spacetimedb +WorkingDirectory=/stdb + +[Install] +WantedBy=multi-user.target +``` + +Enable and start the service: + +```sh +sudo systemctl enable spacetimedb +sudo systemctl start spacetimedb +``` + +Check the status: + +```sh +sudo systemctl status spacetimedb +``` + +## Step 3: Install and Configure Nginx + +### Install Nginx + +```sh +sudo apt update +sudo apt install nginx -y +``` + +### Configure Nginx Reverse Proxy +Create a new Nginx configuration file: + +```sh +sudo nano /etc/nginx/sites-available/spacetimedb +``` + +Add the following configuration, remember to change `example.com` to your own domain: + +```nginx +server { + listen 80; + server_name example.com; + + location / { + proxy_pass http://localhost:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_set_header Host $host; + } + + # This restricts who can publish new databases to your SpacetimeDB instance. We recommend + # restricting this ability to local connections. + location /v1/publish { + allow 127.0.0.1; + deny all; + proxy_pass http://localhost:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_set_header Host $host; + } +} +``` + +This configuration contains a restriction to the `/v1/publish` route. This restriction makes it so that you can only publish to the database if you're publishing from a local connection on the host. + +Enable the configuration: + +```sh +sudo ln -s /etc/nginx/sites-available/spacetimedb /etc/nginx/sites-enabled/ +``` + +Restart Nginx: + +```sh +sudo systemctl restart nginx +``` + +### Configure Firewall +Ensure your firewall allows HTTPS traffic: + +```sh +sudo ufw allow 'Nginx Full' +sudo ufw reload +``` + +## Step 4: Secure with Let's Encrypt + +### Install Certbot + +```sh +sudo apt install certbot python3-certbot-nginx -y +``` + +### Obtain an SSL Certificate + +Run this command to request a new SSL cert from Let's Encrypt. Remember to replace `example.com` with your own domain: + +```sh +sudo certbot --nginx -d example.com +``` + +Certbot will automatically configure SSL for Nginx. Restart Nginx to apply changes: + +```sh +sudo systemctl restart nginx +``` + +### Auto-Renew SSL Certificates +Certbot automatically installs a renewal timer. Verify that it is active: + +```sh +sudo systemctl status certbot.timer +``` + +## Step 5: Verify Installation + +On your local machine, add this new server to your CLI config. Make sure to replace `example.com` with your own domain: + +```bash +spacetime server add self-hosted --url https://example.com +``` + +If you have uncommented the `/v1/publish` restriction in Step 3 then you won't be able to publish to this instance unless you copy your module to the host first and then publish. We recommend something like this: + +```bash +spacetime build +scp target/wasm32-unknown-unknown/release/spacetime_module.wasm ubuntu@:/home/ubuntu/ +ssh ubuntu@ spacetime publish -s local --bin-path spacetime_module.wasm +``` + +You could put the above commands into a shell script to make publishing to your server easier and faster. It's also possible to integrate a script like this into Github Actions to publish on some event (like a PR merging into master). + +## Step 6: Updating SpacetimeDB Version +To update SpacetimeDB to the latest version, first stop the service: + +```sh +sudo systemctl stop spacetimedb +``` + +Then upgrade SpacetimeDB: + +```sh +sudo -u spacetimedb -i -- spacetime --root-dir=/stdb version upgrade +``` + +To install a specific version, use: + +```sh +sudo -u spacetimedb -i -- spacetime --root-dir=/stdb install +``` + +Finally, restart the service: + +```sh +sudo systemctl start spacetimedb +``` + +## Step 7: Troubleshooting + +### SpacetimeDB Service Fails to Start +Check the logs for errors: + +```sh +sudo journalctl -u spacetimedb --no-pager | tail -20 +``` + +Verify that the `spacetimedb` user has the correct permissions: + +```sh +sudo ls -lah /stdb/spacetime +``` + +If needed, add the executable permission: + +```sh +sudo chmod +x /stdb/spacetime +``` + +### Let's Encrypt Certificate Renewal Issues +Manually renew the certificate and check for errors: + +```sh +sudo certbot renew --dry-run +``` + +### Nginx Fails to Start +Test the configuration: + +```sh +sudo nginx -t +``` + +If errors are found, check the logs: + +```sh +sudo journalctl -u nginx --no-pager | tail -20 +``` diff --git a/docs/nav.js b/docs/nav.js index aed58053..e7d0b944 100644 --- a/docs/nav.js +++ b/docs/nav.js @@ -11,6 +11,7 @@ const nav = { page('Getting Started', 'getting-started', 'getting-started.md'), section('Deploying'), page('Maincloud', 'deploying/maincloud', 'deploying/maincloud.md'), + page('Self-Hosting SpacetimeDB', 'deploying/spacetimedb-standalone', 'deploying/spacetimedb-standalone.md'), section('Unity Tutorial - Basic Multiplayer'), page('Overview', 'unity', 'unity/index.md'), page('1 - Setup', 'unity/part-1', 'unity/part-1.md'), diff --git a/nav.ts b/nav.ts index 4de5dae3..fb968928 100644 --- a/nav.ts +++ b/nav.ts @@ -35,6 +35,7 @@ const nav: Nav = { section('Deploying'), page('Maincloud', 'deploying/maincloud', 'deploying/maincloud.md'), + page('Self-Hosting SpacetimeDB', 'deploying/spacetimedb-standalone', 'deploying/spacetimedb-standalone.md'), section('Unity Tutorial - Basic Multiplayer'), page('Overview', 'unity', 'unity/index.md'), From eaa8ecd4440645717d5445598e2c069199a01764 Mon Sep 17 00:00:00 2001 From: John Detter <4099508+jdetter@users.noreply.github.com> Date: Tue, 4 Mar 2025 13:40:30 -0600 Subject: [PATCH 129/164] Bump typescript version to 1.0.2 (#213) Bump version to 1.0.2 Co-authored-by: John Detter --- docs/sdks/typescript/quickstart.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sdks/typescript/quickstart.md b/docs/sdks/typescript/quickstart.md index fcd1fc98..4978ddbe 100644 --- a/docs/sdks/typescript/quickstart.md +++ b/docs/sdks/typescript/quickstart.md @@ -28,7 +28,7 @@ pnpm install We also need to install the `spacetime-client-sdk` package: ```bash -pnpm install @clockworklabs/spacetimedb-sdk@1.0.1 +pnpm install @clockworklabs/spacetimedb-sdk@1.0.2 ``` > If you are using another package manager like `yarn` or `npm`, the same steps should work with the appropriate commands for those tools. From d8522de723fca4627a6ac4654f498b3c9150d56a Mon Sep 17 00:00:00 2001 From: Zeke Foppa <196249+bfops@users.noreply.github.com> Date: Wed, 5 Mar 2025 10:15:01 -0800 Subject: [PATCH 130/164] Update CLI docs for #2349 (#215) * [bfops/fix-docs]: Update CLI docs for #2349 * [bfops/fix-docs]: missed one --------- Co-authored-by: Zeke Foppa --- docs/cli-reference.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 8396f50a..24fb2ded 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -81,7 +81,7 @@ Run `spacetime help publish` for more detailed information. ###### Options: * `-c`, `--delete-data` — When publishing to an existing database identity, first DESTROY all data associated with the module -* `--build-options ` — Options to pass to the build command, for example --build-options='--skip-println-checks' +* `--build-options ` — Options to pass to the build command, for example --build-options='--lint-dir=' Default value: `` * `-p`, `--project-path ` — The system path (absolute or relative) to the module project @@ -282,7 +282,7 @@ Run `spacetime help publish` for more detailed information. Possible values: `csharp`, `typescript`, `rust` -* `--build-options ` — Options to pass to the build command, for example --build-options='--skip-println-checks' +* `--build-options ` — Options to pass to the build command, for example --build-options='--lint-dir=' Default value: `` * `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). From 1008f535a4c7a95df1f576b417a95d6f5b389e65 Mon Sep 17 00:00:00 2001 From: Zeke Foppa <196249+bfops@users.noreply.github.com> Date: Thu, 6 Mar 2025 08:27:19 -0800 Subject: [PATCH 131/164] Remove stale references to removed CLI functions (#218) [bfops/outdated-cli]: Remove stale references to removed CLI functions Co-authored-by: Zeke Foppa --- docs/http/database.md | 4 ---- docs/http/identity.md | 4 ---- 2 files changed, 8 deletions(-) diff --git a/docs/http/database.md b/docs/http/database.md index 8a73759c..0ac41070 100644 --- a/docs/http/database.md +++ b/docs/http/database.md @@ -129,8 +129,6 @@ Accessible through the CLI as `spacetime delete `. Get the names this datbase can be identified by. -Accessible through the CLI as `spacetime dns reverse-lookup `. - #### Returns Returns JSON in the form: @@ -206,8 +204,6 @@ If any of the new names already exist but the identity provided in the `Authoriz Get the identity of a database. -Accessible through the CLI as `spacetime dns lookup `. - #### Returns Returns a hex string of the specified database's identity. diff --git a/docs/http/identity.md b/docs/http/identity.md index 3cec4eb9..f3b68b28 100644 --- a/docs/http/identity.md +++ b/docs/http/identity.md @@ -16,8 +16,6 @@ The HTTP endpoints in `/v1/identity` allow clients to generate and manage Spacet Create a new identity. -Accessible through the CLI as `spacetime identity new`. - #### Returns Returns JSON in the form: @@ -63,8 +61,6 @@ Returns a response of content-type `application/pem-certificate-chain`. Associate an email with a Spacetime identity. -Accessible through the CLI as `spacetime identity set-email `. - #### Parameters | Name | Value | From 13f1d23ea3b57cd5a511e106a1ef2a9bbed2a4b1 Mon Sep 17 00:00:00 2001 From: rekhoff Date: Fri, 7 Mar 2025 10:58:06 -0800 Subject: [PATCH 132/164] Updated to correct old timestamp format. (#221) --- docs/unity/part-4.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/unity/part-4.md b/docs/unity/part-4.md index e2b58dd5..d4375f1f 100644 --- a/docs/unity/part-4.md +++ b/docs/unity/part-4.md @@ -317,7 +317,7 @@ ctx.db .move_all_players_timer() .try_insert(MoveAllPlayersTimer { scheduled_id: 0, - scheduled_at: ScheduleAt::Interval(Duration::from_millis(50).as_micros() as u64), + scheduled_at: ScheduleAt::Interval(Duration::from_millis(50).into()), })?; ``` ::: From 6b5fc5b814c6148d10cf4c7ca6b18b9596db25ed Mon Sep 17 00:00:00 2001 From: Noa Date: Fri, 7 Mar 2025 13:10:42 -0600 Subject: [PATCH 133/164] Clarify what is a valid module name (#252) --- docs/cli-reference.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 24fb2ded..69ebbbd5 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -76,7 +76,10 @@ Run `spacetime help publish` for more detailed information. ###### Arguments: -* `` — A valid domain or identity for this database +* `` — A valid domain or identity for this database. + + Database names must match the regex `/^[a-z0-9]+(-[a-z0-9]+)*$/`, + i.e. only lowercase ASCII letters and numbers, separated by dashes. ###### Options: From 9354ba633763fd2bf28fc6024b7641424da213b4 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Mon, 10 Mar 2025 12:11:05 -0400 Subject: [PATCH 134/164] Removed double semi-colon (#254) --- docs/unity/part-4.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/unity/part-4.md b/docs/unity/part-4.md index d4375f1f..ec67f775 100644 --- a/docs/unity/part-4.md +++ b/docs/unity/part-4.md @@ -293,7 +293,7 @@ public static void MoveAllPlayers(ReducerContext ctx, MoveAllPlayersTimer timer) // This can happen if the circle has been eaten by another circle. continue; } - var circle_entity = check_entity.Value;; + var circle_entity = check_entity.Value; var circle_radius = MassToRadius(circle_entity.mass); var direction = circle_directions[circle.entity_id]; var new_pos = circle_entity.position + direction * MassToMaxMoveSpeed(circle_entity.mass); @@ -519,7 +519,7 @@ public static void MoveAllPlayers(ReducerContext ctx, MoveAllPlayersTimer timer) // This can happen if the circle has been eaten by another circle. continue; } - var circle_entity = check_entity.Value;; + var circle_entity = check_entity.Value; var circle_radius = MassToRadius(circle_entity.mass); var direction = circle.direction * circle.speed; var new_pos = circle_entity.position + direction * MassToMaxMoveSpeed(circle_entity.mass); From edaa76f9062488ab67ff6de3f6507e9c53d07f4c Mon Sep 17 00:00:00 2001 From: Zeke Foppa <196249+bfops@users.noreply.github.com> Date: Tue, 11 Mar 2025 10:07:04 -0700 Subject: [PATCH 135/164] Explain `spacetime login` (#217) [bfops/login-docs]: do thing Co-authored-by: Zeke Foppa --- docs/getting-started.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index 7afeec31..466d4cc6 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -16,7 +16,17 @@ The server listens on port `3000` by default, customized via `--listen-addr`. ## What's Next? -You are ready to start developing SpacetimeDB modules. See below for a quickstart guide for both client and server (module) languages/frameworks. +### Log in to SpacetimeDB + +SpacetimeDB authenticates users using a GitHub login, to prevent unauthorized access (e.g. somebody else publishing over your module). Log in to SpacetimeDB using: + +```bash +spacetime login +``` + +This will open a browser and ask you to log in via GitHub. If you forget this step, any commands that require login (like `spacetime publish`) will ask you to log in when you run them. + +You are now ready to start developing SpacetimeDB modules. See below for a quickstart guide for both client and server (module) languages/frameworks. ### Server (Module) From 5cd718260b005cdccf865f0ccb8d674e69ba5b2d Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Tue, 11 Mar 2025 17:24:55 -0700 Subject: [PATCH 136/164] Updated tutorial for Blackholio/pull/19 (#163) Updated tutorial for https://github.com/clockworklabs/Blackholio/pull/19 Co-authored-by: rekhoff --- docs/unity/part-2.md | 2 +- docs/unity/part-3.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/unity/part-2.md b/docs/unity/part-2.md index 54c1983a..3bb45476 100644 --- a/docs/unity/part-2.md +++ b/docs/unity/part-2.md @@ -215,7 +215,7 @@ public partial struct Circle public uint player_id; public DbVector2 direction; public float speed; - public ulong last_split_time; + public SpacetimeDB.Timestamp last_split_time; } [Table(Name = "food", Public = true)] diff --git a/docs/unity/part-3.md b/docs/unity/part-3.md index 52206f25..e4aef92c 100644 --- a/docs/unity/part-3.md +++ b/docs/unity/part-3.md @@ -485,7 +485,7 @@ public static Entity SpawnPlayerInitialCircle(ReducerContext ctx, uint player_id ); } -public static Entity SpawnCircleAt(ReducerContext ctx, uint player_id, uint mass, DbVector2 position, DateTimeOffset timestamp) +public static Entity SpawnCircleAt(ReducerContext ctx, uint player_id, uint mass, DbVector2 position, SpacetimeDB.Timestamp timestamp) { var entity = ctx.Db.entity.Insert(new Entity { @@ -499,7 +499,7 @@ public static Entity SpawnCircleAt(ReducerContext ctx, uint player_id, uint mass player_id = player_id, direction = new DbVector2(0, 1), speed = 0f, - last_split_time = (ulong)timestamp.ToUnixTimeMilliseconds(), + last_split_time = timestamp, }); return entity; } From 05a112c5ceb9df4be1f9f5864d436c19cdb855a7 Mon Sep 17 00:00:00 2001 From: Mazdak Farrokhzad Date: Wed, 12 Mar 2025 14:08:47 +0100 Subject: [PATCH 137/164] Adjust the BSATN doc to fit reality better (#216) adjust BSATN doc to fit reality better --- docs/bsatn.md | 64 +++++++++++++++++++++++++++++++++++---------------- 1 file changed, 44 insertions(+), 20 deletions(-) diff --git a/docs/bsatn.md b/docs/bsatn.md index 703e210c..2e464b51 100644 --- a/docs/bsatn.md +++ b/docs/bsatn.md @@ -1,4 +1,4 @@ -# SATN Binary Format (BSATN) +# Binary SATN Format (BSATN) The Spacetime Algebraic Type Notation binary (BSATN) format defines how Spacetime `AlgebraicValue`s and friends are encoded as byte strings. @@ -29,16 +29,26 @@ To do this, we use inductive definitions, and define the following notation: | [`AlgebraicValue`](#algebraicvalue) | A value of any type. | | [`SumValue`](#sumvalue) | A value of a sum type, i.e. an enum or tagged union. | | [`ProductValue`](#productvalue) | A value of a product type, i.e. a struct or tuple. | -| [`BuiltinValue`](#builtinvalue) | A value of a builtin type, including numbers, booleans and sequences. | ### `AlgebraicValue` The BSATN encoding of an `AlgebraicValue` defers to the encoding of each variant: ```fsharp -bsatn(AlgebraicValue) = bsatn(SumValue) | bsatn(ProductValue) | bsatn(BuiltinValue) +bsatn(AlgebraicValue) + = bsatn(SumValue) + | bsatn(ProductValue) + | bsatn(ArrayValue) + | bsatn(String) + | bsatn(Bool) + | bsatn(U8) | bsatn(U16) | bsatn(U32) | bsatn(U64) | bsatn(U128) | bsatn(U256) + | bsatn(I8) | bsatn(I16) | bsatn(I32) | bsatn(I64) | bsatn(I128) | bsatn(I256) + | bsatn(F32) | bsatn(F64) ``` +Algebraic values include sums, products, arrays, strings, and primitives types. +The primitive types include booleans, unsigned and signed integers up to 256-bits, and floats, both single and double precision. + ### `SumValue` An instance of a sum type, i.e. an enum or tagged union. @@ -60,44 +70,58 @@ bsatn(elems) = bsatn(elem_0) ++ .. ++ bsatn(elem_n) Field names are not encoded. -### `BuiltinValue` +### `ArrayValue` + +The encoding of an `ArrayValue` is: + +``` +bsatn(ArrayValue(a)) + = bsatn(len(a) as u32) + ++ bsatn(normalize(a)_0) + ++ .. + ++ bsatn(normalize(a)_n) +``` + +where `normalize(a)` for `a: ArrayValue` converts `a` to a list of `AlgebraicValue`s. -An instance of a buil-in type. -Built-in types include booleans, integers, floats, strings and arrays. -The BSATN encoding of `BuiltinValue`s defers to the encoding of each variant: +### Strings +For strings, the encoding is defined as: ```fsharp -bsatn(BuiltinValue) - = bsatn(Bool) - | bsatn(U8) | bsatn(U16) | bsatn(U32) | bsatn(U64) | bsatn(U128) - | bsatn(I8) | bsatn(I16) | bsatn(I32) | bsatn(I64) | bsatn(I128) - | bsatn(F32) | bsatn(F64) - | bsatn(String) - | bsatn(Array) +bsatn(String(s)) = bsatn(len(s) as u32) ++ bsatn(utf8_to_bytes(s)) +``` +That is, the BSATN encoding is the concatenation of +- the bsatn of the string's length as a `u32` integer byte +- the utf8 representation of the string as a byte array -bsatn(Bool(b)) = bsatn(b as u8) +### Primitives + +For the primitive variants of `AlgebraicValue`, the BSATN encodings are:s + +```fsharp +bsatn(Bool(false)) = [0] +bsatn(Bool(true)) = [1] bsatn(U8(x)) = [x] bsatn(U16(x: u16)) = to_little_endian_bytes(x) bsatn(U32(x: u32)) = to_little_endian_bytes(x) bsatn(U64(x: u64)) = to_little_endian_bytes(x) bsatn(U128(x: u128)) = to_little_endian_bytes(x) +bsatn(U256(x: u256)) = to_little_endian_bytes(x) bsatn(I8(x: i8)) = to_little_endian_bytes(x) bsatn(I16(x: i16)) = to_little_endian_bytes(x) bsatn(I32(x: i32)) = to_little_endian_bytes(x) bsatn(I64(x: i64)) = to_little_endian_bytes(x) bsatn(I128(x: i128)) = to_little_endian_bytes(x) +bsatn(I256(x: i256)) = to_little_endian_bytes(x) bsatn(F32(x: f32)) = bsatn(f32_to_raw_bits(x)) // lossless conversion bsatn(F64(x: f64)) = bsatn(f64_to_raw_bits(x)) // lossless conversion bsatn(String(s)) = bsatn(len(s) as u32) ++ bsatn(bytes(s)) -bsatn(Array(a)) = bsatn(len(a) as u32) - ++ bsatn(normalize(a)_0) ++ .. ++ bsatn(normalize(a)_n) ``` Where -- `f32_to_raw_bits(x)` is the raw transmute of `x: f32` to `u32` -- `f64_to_raw_bits(x)` is the raw transmute of `x: f64` to `u64` -- `normalize(a)` for `a: ArrayValue` converts `a` to a list of `AlgebraicValue`s +- `f32_to_raw_bits(x)` extracts the raw bits of `x: f32` to `u32` +- `f64_to_raw_bits(x)` extracts the raw bits of `x: f64` to `u64` ## Types From c61f006e5d7023fca87272a1f4550f60fd405e13 Mon Sep 17 00:00:00 2001 From: Oliver Davies Date: Mon, 24 Mar 2025 20:24:56 -0700 Subject: [PATCH 138/164] Added "Connecting to Maincloud" section to part-4.md (#275) * Update part-4.md added a proper main cloud section * Update part-4.md * Update docs/unity/part-4.md * Apply suggestions from code review --------- Co-authored-by: Tyler Cloutier --- docs/unity/part-4.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/unity/part-4.md b/docs/unity/part-4.md index ec67f775..9ec81bd5 100644 --- a/docs/unity/part-4.md +++ b/docs/unity/part-4.md @@ -578,6 +578,30 @@ We didn't even have to update the client, because our client's `OnDelete` callba Notice that the food automatically respawns as you vaccuum them up. This is because our scheduled reducer is automatically replacing the food 2 times per second, to ensure that there is always 600 food on the map. +## Connecting to Maincloud +- Publish to Maincloud `spacetime publish -s maincloud --delete-data` + - `` This name should be unique and cannot contain any special characters other than internal hyphens (`-`). +- Update the URL in the Unity project to: `https://maincloud.spacetimedb.com` +- Update the module name in the Unity project to ``. +- Clear the PlayerPrefs in Start() within `GameManager.cs` +- Your `GameManager.cs` should look something like this: +```csharp +const string SERVER_URL = "https://maincloud.spacetimedb.com"; +const string MODULE_NAME = ""; + +... + +private void Start() +{ + // Clear cached connection data to ensure proper connection + PlayerPrefs.DeleteAll(); + + // Continue with initialization +} +``` + +To delete your Maincloud module, you can run: `spacetime delete -s maincloud ` + # Conclusion :::server-rust From f437751192fcafe9acca17c90150c702564c48d1 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Wed, 2 Apr 2025 13:05:15 -0400 Subject: [PATCH 139/164] TS quickstart: Install latest SDK, not specific version (#280) The version listed here was outdated and included bugs. I don't even know why we'd recommend a specific version, instead of just telling people to install the latest release. --- docs/sdks/typescript/quickstart.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sdks/typescript/quickstart.md b/docs/sdks/typescript/quickstart.md index 4978ddbe..53b9ff05 100644 --- a/docs/sdks/typescript/quickstart.md +++ b/docs/sdks/typescript/quickstart.md @@ -28,7 +28,7 @@ pnpm install We also need to install the `spacetime-client-sdk` package: ```bash -pnpm install @clockworklabs/spacetimedb-sdk@1.0.2 +pnpm install @clockworklabs/spacetimedb-sdk ``` > If you are using another package manager like `yarn` or `npm`, the same steps should work with the appropriate commands for those tools. From 1d7be0c7103e4521985acce9cc7a7ff38df8d1b5 Mon Sep 17 00:00:00 2001 From: Kane Viggers <72892893+kaneviggers@users.noreply.github.com> Date: Sat, 5 Apr 2025 08:14:50 +1300 Subject: [PATCH 140/164] Correction on c# server module quickstart (#253) Table name is 'messages' not 'Messages' --- docs/modules/c-sharp/quickstart.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/modules/c-sharp/quickstart.md b/docs/modules/c-sharp/quickstart.md index 86bcf16f..a41dccae 100644 --- a/docs/modules/c-sharp/quickstart.md +++ b/docs/modules/c-sharp/quickstart.md @@ -298,7 +298,7 @@ info: Hello, World! SpacetimeDB supports a subset of the SQL syntax so that you can easily query the data of your database. We can run a query using the `sql` command. ```bash -spacetime sql quickstart-chat "SELECT * FROM Message" +spacetime sql quickstart-chat "SELECT * FROM message" ``` ```bash From 8f7c9cd74dd392bab37da510206ee67682d339c2 Mon Sep 17 00:00:00 2001 From: Colter Haycock Date: Fri, 4 Apr 2025 13:15:53 -0600 Subject: [PATCH 141/164] Fixed Minor Typos in Unity Tutorial (#273) * Update part-2.md Minor typos * Update part-2.md reverting unnecessary change * Update part-3.md more tiny changes * Update part-3.md arena * Update part-3.md --- docs/unity/part-2.md | 14 +++++++------- docs/unity/part-3.md | 12 ++++++------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/unity/part-2.md b/docs/unity/part-2.md index 3bb45476..d1410cfe 100644 --- a/docs/unity/part-2.md +++ b/docs/unity/part-2.md @@ -96,7 +96,7 @@ Let's start by defining the `Config` table. This is a simple table which will st ```csharp // We're using this table as a singleton, so in this table -// there only be one element where the `id` is 0. +// there will only be one element where the `id` is 0. [Table(Name = "config", Public = true)] public partial struct Config { @@ -110,7 +110,7 @@ Let's break down this code. This defines a normal C# `struct` with two fields: ` > Although we're using `lower_snake_case` for our column names to have consistent column names across languages in this tutorial, you can also use `camelCase` or `PascalCase` if you prefer. See [#2168](https://github.com/clockworklabs/SpacetimeDB/issues/2168) for more information. -The `Table` attribute with takes two parameters, a `Name` which is the name of the table and what you will use to query the table in SQL, and a `Public` visibility modifier which ensures that the rows of this table are visible to everyone. +The `Table` attribute takes two parameters, a `Name` which is the name of the table and what you will use to query the table in SQL, and a `Public` visibility modifier which ensures that the rows of this table are visible to everyone. The `[PrimaryKey]` attribute, specifies that the `id` field should be used as the primary key of the table. ::: @@ -229,7 +229,7 @@ public partial struct Food The first table we defined is the `entity` table. An entity represents an object in our game world. We have decided, for convenience, that all entities in our game should share some common fields, namely `position` and `mass`. -We can create different types of entities with additional data by creating a new tables with additional fields that have an `entity_id` which references a row in the `entity` table. +We can create different types of entities with additional data by creating new tables with additional fields that have an `entity_id` which references a row in the `entity` table. We've created two types of entities in our game world: `Food`s and `Circle`s. `Food` does not have any additional fields beyond the attributes in the `entity` table, so the `food` table simply represents the set of `entity_id`s that we want to recognize as food. @@ -268,10 +268,10 @@ public partial struct Player } ``` -There's a few new concepts we should touch on. First of all, we are using the `[Unique]` attribute on the `player_id` field. This attribute adds a constraint to the table that ensures that only one row in the player table has a particular `player_id`. We are also using the `[AutoInc]` attribute on the `player_id` field, which indicates "this field should get automatically assigned an auto-incremented value". +There are a few new concepts we should touch on. First of all, we are using the `[Unique]` attribute on the `player_id` field. This attribute adds a constraint to the table that ensures that only one row in the player table has a particular `player_id`. We are also using the `[AutoInc]` attribute on the `player_id` field, which indicates "this field should get automatically assigned an auto-incremented value". ::: -We also have an `identity` field which uses the `Identity` type. The `Identity` type is a identifier that SpacetimeDB uses to uniquely assign and authenticate SpacetimeDB users. +We also have an `identity` field which uses the `Identity` type. The `Identity` type is an identifier that SpacetimeDB uses to uniquely assign and authenticate SpacetimeDB users. ### Writing a Reducer @@ -334,7 +334,7 @@ Now that SpacetimeDB is running we can publish our module to the SpacetimeDB hos Now that SpacetimeDB is running we can publish our module to the SpacetimeDB host. In a separate terminal window, navigate to the `blackholio/server-csharp` directory. ::: -If you are not already logged in to the `spacetime` CLI, run the `spacetime login` command log in to your SpacetimeDB website account. Once you are logged in, run `spacetime publish --server local blackholio`. This will publish our Blackholio server logic to SpacetimeDB. +If you are not already logged in to the `spacetime` CLI, run the `spacetime login` command to log in to your SpacetimeDB website account. Once you are logged in, run `spacetime publish --server local blackholio`. This will publish our Blackholio server logic to SpacetimeDB. If the publish completed successfully, you will see something like the following in the logs: @@ -582,7 +582,7 @@ public class GameManager : MonoBehaviour Here we configure the connection to the database, by passing it some callbacks in addition to providing the `SERVER_URI` and `MODULE_NAME` to the connection. When the client connects, the SpacetimeDB SDK will call the `HandleConnect` method, allowing us to start up the game. -In our `HandleConnect` callback we building a subscription and are calling `Subscribe` and subscribing to all data in the database. This will cause SpacetimeDB to synchronize the state of all your tables with your Unity client's SpacetimeDB SDK's "client cache". You can also subscribe to specific tables using SQL syntax, e.g. `SELECT * FROM my_table`. Our [SQL documentation](/docs/sql) enumerates the operations that are accepted in our SQL syntax. +In our `HandleConnect` callback we build a subscription and are calling `Subscribe` and subscribing to all data in the database. This will cause SpacetimeDB to synchronize the state of all your tables with your Unity client's SpacetimeDB SDK's "client cache". You can also subscribe to specific tables using SQL syntax, e.g. `SELECT * FROM my_table`. Our [SQL documentation](/docs/sql) enumerates the operations that are accepted in our SQL syntax. --- diff --git a/docs/unity/part-3.md b/docs/unity/part-3.md index e4aef92c..e6d42309 100644 --- a/docs/unity/part-3.md +++ b/docs/unity/part-3.md @@ -153,7 +153,7 @@ Although, we've written the reducer to spawn food, no food will actually be spaw We would like for this function to be called periodically to "top up" the amount of food on the map so that it never falls very far below our target amount of food. SpacetimeDB has built in functionality for exactly this. With SpacetimeDB you can schedule your module to call itself in the future or repeatedly with reducers. :::server-rust -In order to schedule a reducer to be called we have to create a new table which specifies when an how a reducer should be called. Add this new table to the top of the file, below your imports. +In order to schedule a reducer to be called we have to create a new table which specifies when and how a reducer should be called. Add this new table to the top of the file, below your imports. ```rust #[spacetimedb::table(name = spawn_food_timer, scheduled(spawn_food))] @@ -554,7 +554,7 @@ public static void Disconnect(ReducerContext ctx) ::: -Finally publish the new module to SpacetimeDB with this command: +Finally, publish the new module to SpacetimeDB with this command: ```sh spacetime publish --server local blackholio --delete-data @@ -591,7 +591,7 @@ Start by adding `SetupArena` and `CreateBorderCube` methods to your `GameManager } ``` -In your `HandleSubscriptionApplied` let's now call `SetupArea` method. Modify your `HandleSubscriptionApplied` method as in the below. +In your `HandleSubscriptionApplied` let's now call `SetupArena` method. Modify your `HandleSubscriptionApplied` method as in the below. ```cs private void HandleSubscriptionApplied(SubscriptionEventContext ctx) @@ -622,7 +622,7 @@ Now let's make some prefabs for our game objects. In the scene hierarchy window, 2D Object > Sprites > Circle ``` -Rename the new game object in the scene to `CirclePrefab`. Next in the `Inspector` window click the `Add Component` button and add the `Circle Controller` script component that we just created. Finally drag the object into the `Project` folder. Once the prefab file is created, delete the `CirclePrefab` object from the scene. We'll use this prefab to draw the circles that a player controllers. +Rename the new game object in the scene to `CirclePrefab`. Next in the `Inspector` window click the `Add Component` button and add the `Circle Controller` script component that we just created. Finally drag the object into the `Project` folder. Once the prefab file is created, delete the `CirclePrefab` object from the scene. We'll use this prefab to draw the circles that a player controls. Next repeat that same process for the `FoodPrefab` and `Food Controller` component. @@ -1146,7 +1146,7 @@ public class CameraController : MonoBehaviour Add the `CameraController` as a component to the `Main Camera` object in the scene. -Lastly modify the `GameManager.SetupArea` method to set the `WorldSize` on the `CameraController`. +Lastly modify the `GameManager.SetupArena` method to set the `WorldSize` on the `CameraController`. ```cs private void SetupArena(float worldSize) @@ -1212,4 +1212,4 @@ At this point, after publishing our module we can press the play button to see t ### Next Steps -It's pretty cool to see our player in game surrounded by food, but there's a problem! We can't move yet. In the next part, we'll explore how to get your player moving and interacting with food and other objects. \ No newline at end of file +It's pretty cool to see our player in game surrounded by food, but there's a problem! We can't move yet. In the next part, we'll explore how to get your player moving and interacting with food and other objects. From a8bf6470539424956af02e7b56ea764c5b05d686 Mon Sep 17 00:00:00 2001 From: AdielMag Date: Fri, 4 Apr 2025 22:16:29 +0300 Subject: [PATCH 142/164] Small fix on part-3.md (#264) Update part-3.md --- docs/unity/part-3.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/unity/part-3.md b/docs/unity/part-3.md index e6d42309..b31cac11 100644 --- a/docs/unity/part-3.md +++ b/docs/unity/part-3.md @@ -106,7 +106,7 @@ const uint TARGET_FOOD_COUNT = 600; public static float MassToRadius(uint mass) => MathF.Sqrt(mass); [Reducer] -public static void SpawnFood(ReducerContext ctx, SpawnFoodTimer timer) +public static void SpawnFood(ReducerContext ctx) { if (ctx.Db.player.Count == 0) //Are there no players yet? { From c38b6d2a17e80373014a72f616a929fea3a7da99 Mon Sep 17 00:00:00 2001 From: cjodo Date: Fri, 4 Apr 2025 13:17:09 -0600 Subject: [PATCH 143/164] fix(ts-sdk): correct the function signature of onConnectError (#255) fix(sdk): correct the function signature of onConnectError --- docs/sdks/typescript/quickstart.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/sdks/typescript/quickstart.md b/docs/sdks/typescript/quickstart.md index 53b9ff05..df4f4d59 100644 --- a/docs/sdks/typescript/quickstart.md +++ b/docs/sdks/typescript/quickstart.md @@ -387,7 +387,7 @@ module_bindings With `spacetime generate` we have generated TypeScript types derived from the types you specified in your module, which we can conveniently use in our client. We've placed these in the `module_bindings` folder. The main entry to the SpacetimeDB API is the `DbConnection`, a type which manages a connection to a remote database. Let's import it and a few other types into our `client/src/App.tsx`. ```tsx -import { DbConnection, EventContext, Message, User } from './module_bindings'; +import { DbConnection, ErrorContext, EventContext, Message, User } from './module_bindings'; import { Identity } from '@clockworklabs/spacetimedb-sdk'; ``` @@ -442,7 +442,7 @@ Add the following to your `App` function, just below `const [newMessage, setNewM setConnected(false); }; - const onConnectError = (_conn: DbConnection, err: Error) => { + const onConnectError = (_ctx: ErrorContext, err: Error) => { console.log('Error connecting to SpacetimeDB:', err); }; From e16fbe1ed5111ece4c05e5baff35189ce2d72ec6 Mon Sep 17 00:00:00 2001 From: heliam1 <30861916+heliam1@users.noreply.github.com> Date: Thu, 10 Apr 2025 01:35:59 +1000 Subject: [PATCH 144/164] Fix typo (#289) --- docs/sdks/typescript/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sdks/typescript/index.md b/docs/sdks/typescript/index.md index 322443c9..532fe951 100644 --- a/docs/sdks/typescript/index.md +++ b/docs/sdks/typescript/index.md @@ -757,7 +757,7 @@ Each table defined by a module has an accessor method, whose name is the table n | [Accessing rows](#accessing-rows) | Iterate over or count subscribed rows. | | [`onInsert` callback](#callback-oninsert) | Register a function to run when a row is added to the client cache. | | [`onDelete` callback](#callback-ondelete) | Register a function to run when a row is removed from the client cache. | -| [`onUpdate` callback](#callback-onupdate) | Register a functioNto run when a subscribed row is replaced with a new version. | +| [`onUpdate` callback](#callback-onupdate) | Register a function to run when a subscribed row is replaced with a new version. | | [Unique index access](#unique-constraint-index-access) | Seek a subscribed row by the value in its unique or primary key column. | | [BTree index access](#btree-index-access) | Not supported. | From 4dcc4a730f0925ecbd6eec244dea253cf1783181 Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Wed, 9 Apr 2025 11:50:51 -0700 Subject: [PATCH 145/164] Update the timestamp format output by spacetime sql in the quickstart (#288) Fixes #222. --- docs/modules/c-sharp/quickstart.md | 6 +++--- docs/modules/rust/quickstart.md | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/modules/c-sharp/quickstart.md b/docs/modules/c-sharp/quickstart.md index a41dccae..9bdb78c9 100644 --- a/docs/modules/c-sharp/quickstart.md +++ b/docs/modules/c-sharp/quickstart.md @@ -302,9 +302,9 @@ spacetime sql quickstart-chat "SELECT * FROM message" ``` ```bash - text ---------- - "Hello, World!" + sender | sent | text +--------------------------------------------------------------------+----------------------------------+----------------- + 0x93dda09db9a56d8fa6c024d843e805d8262191db3b4ba84c5efcd1ad451fed4e | 2025-04-08T15:47:46.935402+00:00 | "Hello, world!" ``` ## What's next? diff --git a/docs/modules/rust/quickstart.md b/docs/modules/rust/quickstart.md index 0670bb89..9bbb4b3b 100644 --- a/docs/modules/rust/quickstart.md +++ b/docs/modules/rust/quickstart.md @@ -266,9 +266,9 @@ spacetime sql quickstart-chat "SELECT * FROM message" ``` ```bash - sender | sent | text ---------------------------------------------------------------------+------------------+----------------- - 0x93dda09db9a56d8fa6c024d843e805d8262191db3b4ba84c5efcd1ad451fed4e | 1727858455560802 | "Hello, world!" + sender | sent | text +--------------------------------------------------------------------+----------------------------------+----------------- + 0x93dda09db9a56d8fa6c024d843e805d8262191db3b4ba84c5efcd1ad451fed4e | 2025-04-08T15:47:46.935402+00:00 | "Hello, world!" ``` ## What's next? From 1c5ce753a28259465712307f4a5e8ce2297cdf44 Mon Sep 17 00:00:00 2001 From: Zeke Foppa <196249+bfops@users.noreply.github.com> Date: Fri, 11 Apr 2025 23:00:31 -0700 Subject: [PATCH 146/164] CI - Add release branch check (#270) [bfops/release-branch-check]: Add release branch check Co-authored-by: Zeke Foppa --- .github/workflows/git-tree-checks.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .github/workflows/git-tree-checks.yml diff --git a/.github/workflows/git-tree-checks.yml b/.github/workflows/git-tree-checks.yml new file mode 100644 index 00000000..1166e526 --- /dev/null +++ b/.github/workflows/git-tree-checks.yml @@ -0,0 +1,22 @@ +name: Git tree checks + +on: + pull_request: + types: [opened, edited, reopened, synchronize] + merge_group: +permissions: read-all + +jobs: + check_base_ref: + name: Release branch restriction + runs-on: ubuntu-latest + steps: + - if: | + github.event_name == 'pull_request' && + github.event.pull_request.base.ref == 'release' && + ! startsWith(github.event.pull_request.head.ref, 'release-') + run: | + echo 'Only `release-*` branches are allowed to merge into the release branch `release`.' + echo 'Are you **sure** that you want to merge into release?' + echo 'Is this **definitely** just cherrypicking commits that are already in `master`?' + exit 1 From ba8934ed4fcca1c2483e8afc1f072b3c8041e690 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Mon, 14 Apr 2025 09:42:14 -0400 Subject: [PATCH 147/164] Added a preliminary llms.md/.txt file to the repo to help LLMs generate code for users and understand SpacetimeDB (#286) * Added a preliminary llms.md/.txt file to the repo to help LLMs generate code for users and understand SpacetimeDB * Update llms.md Co-authored-by: Phoebe Goldman * Addressed some feedback * Fixed up some stuff * Added TypeScript SDK code * Added subscription semantics section and small fixes * Added info about ConnectionId and the reducer context * Addressed comments --------- Co-authored-by: Phoebe Goldman --- llms.md | 2280 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 2280 insertions(+) create mode 100644 llms.md diff --git a/llms.md b/llms.md new file mode 100644 index 00000000..bcdf38b4 --- /dev/null +++ b/llms.md @@ -0,0 +1,2280 @@ +# SpacetimeDB + +> SpacetimeDB is a fully-featured relational database system that integrates +application logic directly within the database, eliminating the need for +separate web or game servers. It supports multiple programming languages, +including C# and Rust, allowing developers to write and deploy entire +applications as a single binary. It is optimized for high-throughput and low +latency multiplayer applications like multiplayer games. + +Users upload their application logic to run inside SpacetimeDB as a WebAssembly +module. There are three main features of SpacetimeDB: tables, reducers, and +subscription queries. Tables are relational database tables like you would find +in a database like Postgres. Reducers are atomic, transactional, RPC functions +that are defined in the WebAssembly module which can be called by clients. +Subscription queries are SQL queries which are made over a WebSocket connection +which are initially evaluated by SpacetimeDB and then incrementally evaluated +sending changes to the query result over the WebSocket. + +All data in the tables are stored in memory, but are persisted to the disk via a +Write-Ahead Log (WAL) called the Commitlog. All tables are persistent in +SpacetimeDB. + +SpacetimeDB allows users to code generate type-safe client libraries based on +the tables, types, and reducers defined in their module. Subscription queries +allows the client SDK to store a partial, live updating, replica of the servers +state. This makes reading database state on the client extremely low-latency. + +Authentication is implemented in SpacetimeDB using the OpenID Connect protocol. +An OpenID Connect token with a valid `iss`/`sub` pair constitutes a unique and +authenticable SpacetimeDB identity. SpacetimeDB uses the `Identity` type as an +identifier for all such identities. `Identity` is computed from the `iss`/`sub` +pair using the following algorithm: + +1. Concatenate the issuer and subject with a pipe symbol (`|`). +2. Perform the first BLAKE3 hash on the concatenated string. +3. Get the first 26 bytes of the hash (let's call this `idHash`). +4. Create a 28-byte sequence by concatenating the bytes `0xc2`, `0x00`, and `idHash`. +5. Compute the BLAKE3 hash of the 28-byte sequence from step 4 (let's call this `checksumHash`). +6. Construct the final 32-byte `Identity` by concatenating: the two prefix bytes (`0xc2`, `0x00`), the first 4 bytes of `checksumHash`, and the 26-byte `idHash`. +7. This final 32-byte value is typically represented as a hexadecimal string. + +```ascii +Byte Index: | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | ... | 31 | + +-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+ +Contents: | 0xc2| 0x00| Checksum Hash (4 bytes) | ID Hash (26 bytes) | + +-----+-----+-------------------------+---------------------+ + (First 4 bytes of (First 26 bytes of + BLAKE3(0xc200 || idHash)) BLAKE3(iss|sub)) +``` + +This allows SpacetimeDB to easily integrate with OIDC authentication +providers like FirebaseAuth, Auth0, or SuperTokens. + +Clockwork Labs, the developers of SpacetimeDB, offers three products: + +1. SpacetimeDB Standalone: a source available (Business Source License), single node, self-hosted version +2. SpacetimeDB Maincloud: a hosted, managed-service, serverless cluster +3. SpacetimeDB Enterprise: a closed-source, clusterized version of SpacetimeDB which can be licensed for on-prem hosting or dedicated hosting + +## Basic Project Workflow + +Getting started with SpacetimeDB involves a few key steps: + +1. **Install SpacetimeDB:** Install the `spacetime` CLI tool for your operating system. This tool is used for managing modules, databases, and local instances. + + * **macOS:** + ```bash + curl -sSf https://install.spacetimedb.com | sh + ``` + * **Windows (PowerShell):** + ```powershell + iwr https://windows.spacetimedb.com -useb | iex + ``` + * **Linux:** + ```bash + curl -sSf https://install.spacetimedb.com | sh + ``` + * **Docker (to run the server):** + ```bash + # This command starts a SpacetimeDB server instance in Docker + docker run --rm --pull always -p 3000:3000 clockworklabs/spacetime start + # Note: While the CLI can be installed separately (see above), you can also execute + # CLI commands *within* the running Docker container (e.g., using `docker exec`) + # or use the image as a base for a custom image containing your module management tools. + ``` + * **Docker (to execute CLI commands directly):** + You can also use the Docker image to run `spacetime` CLI commands without installing the CLI locally. For commands that operate on local files (like `build`, `publish`, `generate`), this involves mounting your project directory into the container. For commands that only interact with a database instance (like `sql`, `status`), mounting is typically not required, but network access to the database is. + ```bash + # Example: Build a module located in the current directory (.) + # Mount current dir to /module inside container, set working dir to /module + docker run --rm -v "$(pwd):/module" -w /module clockworklabs/spacetime build --project-path . + + # Example: Publish the module after building + # Assumes a local server is running (or use --host for Maincloud/other) + docker run --rm -v "$(pwd):/module" -w /module --network host clockworklabs/spacetime publish --project-path . my-database-name + # Note: `--network host` is often needed to connect to a local server from the container. + ``` + * For more details or troubleshooting, see the official [Getting Started Guide](https://spacetimedb.com/docs/getting-started) and [Installation Page](https://spacetimedb.com/install). + +1.b **Log In (If Necessary):** If you plan to publish to a server that requires authentication (like the public Maincloud at `maincloud.spacetimedb.com`), you generally need to log in first using `spacetime login`. This associates your actions with your global SpacetimeDB identity (e.g., linked to your spacetimedb.com account). + ```bash + spacetime login + # Follow the prompts to authenticate via web browser + ``` + If you attempt commands like `publish` against an authenticated server without being logged in, the CLI will prompt you: `You are not logged in. Would you like to log in with spacetimedb.com? [y/N]`. + * Choosing `y` initiates the standard browser login flow. + * Choosing `n` proceeds without a global login for this operation. The CLI will confirm `We have logged in directly to your target server. WARNING: This login will NOT work for any other servers.` This uses or creates a server-issued identity specific to that server (see Step 5). + + In general, using `spacetime login` (which authenticates via spacetimedb.com) is recommended, as the resulting identities are portable across different SpacetimeDB servers. + +2. **Initialize Server Module:** Create a new directory for your project and use the CLI to initialize the server module structure: + ```bash + # For Rust + spacetime init --lang rust my_server_module + # For C# + spacetime init --lang csharp my_server_module + ``` + :::note C# Project Filename Convention (SpacetimeDB CLI) + The `spacetime` CLI tool (particularly `publish` and `build`) follows a convention and often expects the C# project file (`.csproj`) to be named `StdbModule.csproj`, matching the default generated by `spacetime init`. This **is** a requirement of the SpacetimeDB tool itself (due to how it locates build artifacts), not the underlying .NET build system. This is a known issue tracked [here](https://github.com/clockworklabs/SpacetimeDB/issues/2475). If you encounter issues where the build succeeds but publishing fails (e.g., "couldn't find the output file" or silent failures after build), ensure your `.csproj` file is named `StdbModule.csproj` within your module's directory. + ::: +3. **Define Schema & Logic:** Edit the generated module code (`lib.rs` for Rust, `Lib.cs` for C#) to define your custom types (`[SpacetimeType]`/`[Type]`), database tables (`#[table]`/`[Table]`), and reducers (`#[reducer]`/`[Reducer]`). +4. **Build Module:** Compile your module code into WebAssembly using the CLI: + ```bash + # Run from the directory containing your module folder + spacetime build --project-path my_server_module + ``` + :::note C# Build Prerequisite (.NET SDK) + Building a **C# module** (on any platform: Windows, macOS, Linux) requires the .NET SDK to be installed. If the build fails with an error mentioning `dotnet workload list` or `No .NET SDKs were found`, you need to install the SDK first. Download and install the **.NET 8 SDK** specifically from the official Microsoft website: [https://dotnet.microsoft.com/download](https://dotnet.microsoft.com/download). Newer versions (like .NET 9) are not currently supported for building SpacetimeDB modules, although they can be installed alongside .NET 8 without conflicting. + ::: +5. **Publish Module:** Deploy your compiled module to a SpacetimeDB instance (either a local one started with `spacetime start` or the managed Maincloud). Publishing creates or updates a database associated with your module. + + * Providing a `[name|identity]` for the database is **optional**. If omitted, a nameless database will be created and assigned a unique `Identity` automatically. If providing a *name*, it must match the regex `^[a-z0-9]+(-[a-z0-9]+)*$`. + * By default (`--project-path`), it builds the module before publishing. Use `--bin-path ` to publish a pre-compiled WASM instead. + * Use `-s, --server ` to specify the target instance (e.g., `maincloud.spacetimedb.com` or the nickname `maincloud`). If omitted, it targets a local instance or uses your configured default (check with `spacetime server list`). + * Use `-c, --delete-data` when updating an existing database identity to destroy all existing data first. + + :::note Server-Issued Identities + If you publish without being logged in (and choose to proceed without a global login when prompted), the SpacetimeDB server instance will generate or use a unique "server-issued identity" for the database operation. This identity is specific to that server instance. Its issuer (`iss`) is specifically `http://localhost`, and its subject (`sub`) will be a generated UUIDv4. This differs from the global identities derived from OIDC providers (like spacetimedb.com) when you use `spacetime login`. The token associated with this identity is signed by the issuing server, and the signature will be considered invalid if the token is presented to any other SpacetimeDB server instance. + ::: + + ```bash + # Build and publish from source to 'my-database-name' on the default server + spacetime publish --project-path my_server_module my-database-name + + # Example: Publish a pre-compiled wasm to Maincloud using its nickname, clearing existing data + spacetime publish --bin-path ./my_module/target/wasm32-wasi/debug/my_module.wasm -s maincloud -c my-cloud-db-identity + ``` + +6. **List Databases (Optional):** Use `spacetime list` to see the databases associated with your logged-in identity on the target server (defaults to your configured server). This is helpful to find the `Identity` of databases, especially unnamed ones. + ```bash + # List databases on the default server + spacetime list + + # List databases on Maincloud + # spacetime list -s maincloud + ``` + +7. **Generate Client Bindings:** Create type-safe client code based on your module's definitions. + This command inspects your compiled module's schema (tables, types, reducers) and generates corresponding code (classes, structs, functions) for your target client language. This allows you to interact with your SpacetimeDB module in a type-safe way on the client. + ```bash + # For Rust client (output to src/module_bindings) + spacetime generate --lang rust --out-dir path/to/client/src/module_bindings --project-path my_server_module + # For C# client (output to module_bindings directory) + spacetime generate --lang csharp --out-dir path/to/client/module_bindings --project-path my_server_module + ``` +8. **Develop Client:** Create your client application (e.g., Rust binary, C# console app, Unity game). Use the generated bindings and the appropriate client SDK to: + * Connect to the database (`my-database-name`). + * Subscribe to data in public tables. + * Register callbacks to react to data changes. + * Call reducers defined in your module. +9. **Run:** Start your SpacetimeDB instance (if local or Docker), then run your client application. + +10. **Inspect Data (Optional):** Use the `spacetime sql` command to run SQL queries directly against your database to view or verify data. + ```bash + # Query all data from the 'player_state' table in 'my-database-name' + # Note: Table names are case-sensitive (match your definition) + spacetime sql my-database-name "SELECT * FROM PlayerState" + + # Use --interactive for a SQL prompt + # spacetime sql --interactive my-database-name + ``` + +11. **View Logs (Optional):** Use the `spacetime logs` command to view logs generated by your module's reducers (e.g., using `log::info!` in Rust or `Log.Info()` in C#). + ```bash + # Show all logs for 'my-database-name' + spacetime logs my-database-name + + # Follow the logs in real-time (like tail -f) + # spacetime logs -f my-database-name + + # Show the last 50 log lines + # spacetime logs -n 50 my-database-name + ``` + +12. **Delete Database (Optional):** When you no longer need a database (e.g., after testing), you can delete it using `spacetime delete` with its name or identity. + ```bash + # Delete the database named 'my-database-name' + spacetime delete my-database-name + + # Delete a database by its identity (replace with actual identity) + # spacetime delete 0x123abc... + ``` + +## Core Concepts and Syntax Examples + +### Reducer Context: Understanding Identities and Execution Information + +When a reducer function executes, it is provided with a **Reducer Context**. This context contains vital information about the call's origin and environment, crucial for logic, especially security checks. Key pieces of information typically available within the context include: + +* **Sender Identity**: The authenticated [`Identity`](#identity) of the entity that invoked the reducer. This could be: + * A client application connected to the database. + * The module itself, if the reducer was triggered by the internal scheduler (for scheduled reducers). + * The module itself, if the reducer was called internally by another reducer function within the same module. +* **Module Identity**: The authenticated [`Identity`](#identity) representing the database (module) itself. This is useful for checks where an action should only be performed by the module (e.g., in scheduled reducers). +* **Database Access**: Handles or interfaces for interacting with the database tables defined in the module. This allows the reducer to perform operations like inserting, updating, deleting, and querying rows based on primary keys or indexes. +* **Timestamp**: A [`Timestamp`](#timestamp) indicating precisely when the current reducer execution began. +* **Connection ID**: A [`ConnectionId`](#connectionid) representing the specific network connection instance (like a WebSocket session or a stateless HTTP request) that invoked the reducer. This is a unique, server-assigned identifier that persists only for the duration of that connection (from connection start to disconnect). + * **Important Distinction**: Unlike the **Sender Identity** (which represents the *authenticated user or module*), the **Connection ID** solely identifies the *transient network session*. It is assigned by the server and is not based on client-provided authentication credentials. Use the Connection ID for logic tied to a specific connection instance (e.g., tracking session state, rate limiting per connection), and use the Sender Identity for logic related to the persistent, authenticated user or the module itself. + +Understanding the difference between the **Sender Identity** and the **Module Identity** is particularly important for security. For example, when writing scheduled reducers, you often need to verify that the **Sender Identity** matches the **Module Identity** to ensure the action wasn't improperly triggered by an external client. + +### Server Module (Rust) + +#### Defining Types + +Custom structs or enums intended for use as fields within database tables or as parameters/return types in reducers must derive `SpacetimeType`. This derivation enables SpacetimeDB to handle the serialization and deserialization of these types. + +* **Basic Usage:** Apply `#[derive(SpacetimeType, ...)]` to your structs and enums. Other common derives like `Clone`, `Debug`, `PartialEq` are often useful. +* **Cross-Language Naming:** Use the `#[sats(name = "Namespace.TypeName")]` attribute *on the type definition* to explicitly control the name exposed in generated client bindings (e.g., for C# or TypeScript). This helps prevent naming collisions and provides better organization. You can also use `#[sats(name = "VariantName")]` *on enum variants* to control their generated names. +* **Type Aliases:** Standard Rust `pub type` aliases can be used for clarity (e.g., `pub type PlayerScore = u32;`). The underlying primitive type must still be serializable by SpacetimeDB. +* **Advanced Deserialization:** For types with complex requirements (like lifetimes or custom binary representations), you might need manual implementation using `spacetimedb::Deserialize` and the `bsatn` crate (available via `spacetimedb::spacetimedb_lib`), though this is uncommon for typical application types. + +```rust +use spacetimedb::{SpacetimeType, Identity, Timestamp}; + +// Example Struct +#[derive(SpacetimeType, Clone, Debug, PartialEq)] +pub struct Position { + pub x: i32, + pub y: i32, +} + +// Example Enum +#[derive(SpacetimeType, Clone, Debug, PartialEq)] +pub enum PlayerStatus { + Idle, + Walking(Position), + Fighting(Identity), // Store the identity of the opponent +} + +// Example Enum with Cross-Language Naming Control +// This enum will appear as `Game.ItemType` in C# bindings. +#[derive(SpacetimeType, Clone, Debug, PartialEq)] +#[sats(name = "Game.ItemType")] +pub enum ItemType { + Weapon, + Armor, + // This specific variant will be `ConsumableItem` in C# bindings. + #[sats(name = "ConsumableItem")] + Potion, +} + +// Example Type Alias +pub type PlayerScore = u32; + +// Advanced: For types with lifetimes or custom binary representations, +// you can derive `spacetimedb::Deserialize` and use the `bsatn` crate +// (provided by spacetimedb::spacetimedb_lib) for manual deserialization if needed. +``` + +:::info Rust `crate-type = ["cdylib"]` +The `[lib]` section in your module's `Cargo.toml` must contain `crate-type = ["cdylib"]`. This tells the Rust compiler to produce a dynamic system library compatible with the C ABI, which allows the SpacetimeDB host (written in Rust) to load and interact with your compiled WebAssembly module. +::: + +#### Defining Tables + +Database tables store the application's persistent state. They are defined using Rust structs annotated with the `#[table]` macro. + +* **Core Attribute:** `#[table(name = my_table_name, ...)]` marks a struct as a database table definition. The specified `name` (an identifier, *not* a string literal) is how the table will be referenced in SQL queries and generated APIs. +* **Derivations:** The `#[table]` macro automatically handles deriving necessary traits like `SpacetimeType`, `Serialize`, `Deserialize`, and `Debug`. **Do not** manually add `#[derive(SpacetimeType)]` to a `#[table]` struct, as it will cause compilation conflicts. +* **Public vs. Private:** By default, tables are **private**, accessible only by server-side reducer code. To allow clients to read or subscribe to a table's data, mark it as `public` using `#[table(..., public)]`. This is a common source of errors if forgotten. +* **Primary Keys:** Designate a single field as the primary key using `#[primary_key]`. This ensures uniqueness, creates an efficient index, and allows clients to track row updates. +* **Auto-Increment:** Mark an integer-typed primary key field with `#[auto_inc]` to have SpacetimeDB automatically assign unique, sequentially increasing values upon insertion. Provide `0` as the value for this field when inserting a new row to trigger the auto-increment mechanism. +* **Unique Constraints:** Enforce uniqueness on non-primary key fields using `#[unique]`. Attempts to insert or update rows violating this constraint will fail. +* **Indexes:** Create B-tree indexes for faster lookups on specific fields or combinations of fields. Use `#[index(btree)]` on a single field for a simple index, or `#[table(index(name = my_index_name, btree(columns = [col_a, col_b])))])` within the `#[table(...)]` attribute for named, multi-column indexes. +* **Nullable Fields:** Use standard Rust `Option` for fields that can hold null values. +* **Instances vs. Database:** Remember that table struct instances (e.g., `let player = PlayerState { ... };`) are just data. Modifying an instance does **not** automatically update the database. Interaction happens through generated handles accessed via the `ReducerContext` (e.g., `ctx.db.player_state().insert(...)`). +* **Case Sensitivity:** Table names specified via `name = ...` are case-sensitive and must be matched exactly in SQL queries. +* **Pitfalls:** + * Avoid manually inserting values into `#[auto_inc]` fields that are also `#[unique]`, especially values larger than the current sequence counter, as this can lead to future unique constraint violations when the counter catches up. + * Ensure `public` is set if clients need access. + * Do not manually derive `SpacetimeType`. + * Define indexes *within* the main `#[table(name=..., index=...)]` attribute. Each `#[table]` macro invocation defines a *distinct* table and requires a `name`; separate `#[table]` attributes cannot be used solely to add indexes to a previously named table. + +```rust +use spacetimedb::{table, Identity, Timestamp, SpacetimeType, Table}; // Added Table import + +// Assume Position, PlayerStatus, ItemType are defined as types + +// Example Table Definition +#[table( + name = player_state, + public, + // Index definition is included here + index(name = idx_level_btree, btree(columns = [level])) +)] +#[derive(Clone, Debug)] // No SpacetimeType needed here +pub struct PlayerState { + #[primary_key] + player_id: Identity, + #[unique] // Player names must be unique + name: String, + conn_id: Option, // Nullable field + health: u32, + level: u16, + position: Position, // Custom type field + status: PlayerStatus, // Custom enum field + last_login: Option, // Nullable timestamp +} + +#[table(name = inventory_item, public)] +#[derive(Clone, Debug)] +pub struct InventoryItem { + #[primary_key] + #[auto_inc] // Automatically generate IDs + item_id: u64, + owner_id: Identity, + #[index(btree)] // Simple index on this field + item_type: ItemType, + quantity: u32, +} + +// Example of a private table +#[table(name = internal_game_data)] // No `public` flag +#[derive(Clone, Debug)] +struct InternalGameData { + #[primary_key] + key: String, + value: String, +} +``` + +##### Multiple Tables from One Struct + +:::caution Wrapper Struct Pattern Not Supported for This Use Case +Defining multiple tables using wrapper tuple structs (e.g., `struct ActiveCharacter(CharacterInfo);`) where field attributes like `#[primary_key]`, `#[unique]`, etc., are defined only on fields inside the inner struct (`CharacterInfo` in this example) is **not supported**. This pattern can lead to macro expansion issues and compilation errors because the `#[table]` macro applied to the wrapper struct cannot correctly process attributes defined within the inner type. +::: + +**Recommended Pattern:** Apply multiple `#[table(...)]` attributes directly to the single struct definition that contains the necessary fields and field-level attributes (like `#[primary_key]`). This maps the same underlying type definition to multiple distinct tables reliably: + +```rust +use spacetimedb::{table, Identity, Timestamp, Table}; // Added Table import + +// Define the core data structure once +// Note: #[table] automatically derives SpacetimeType, Serialize, Deserialize +// Do NOT add #[derive(SpacetimeType)] here. +#[derive(Clone, Debug)] +#[table(name = logged_in_players, public)] // Identifier name +#[table(name = players_in_lobby, public)] // Identifier name +pub struct PlayerSessionData { + #[primary_key] + player_id: Identity, + #[unique] + #[auto_inc] + session_id: u64, + last_activity: Timestamp, +} + +// Example Reducer demonstrating interaction +#[spacetimedb::reducer] +fn example_reducer(ctx: &spacetimedb::ReducerContext) { + // Reducers interact with the specific table handles: + let session = PlayerSessionData { + player_id: ctx.sender, // Example: Use sender identity + session_id: 0, // Assuming auto_inc + last_activity: ctx.timestamp, + }; + + // Insert into the 'logged_in_players' table + match ctx.db.logged_in_players().try_insert(session.clone()) { + Ok(inserted) => spacetimedb::log::info!("Player {} logged in, session {}", inserted.player_id, inserted.session_id), + Err(e) => spacetimedb::log::error!("Failed to insert into logged_in_players: {}", e), + } + + // Find a player in the 'players_in_lobby' table by primary key + if let Some(lobby_player) = ctx.db.players_in_lobby().player_id().find(&ctx.sender) { + spacetimedb::log::info!("Player {} found in lobby.", lobby_player.player_id); + } + + // Delete from the 'logged_in_players' table using the PK index + ctx.db.logged_in_players().player_id().delete(&ctx.sender); +} +``` + +##### Browsing Generated Table APIs + +The `#[table]` macro generates specific accessor methods based on your table definition (name, fields, indexes, constraints). To see the exact API generated for your tables: + +1. Run `cargo doc --open` in your module project directory. +2. This compiles your code and opens the generated documentation in your web browser. +3. Navigate to your module's documentation. You will find: + * The struct you defined (e.g., `PlayerState`). + * A generated struct representing the table handle (e.g., `player_state__TableHandle`), which implements `spacetimedb::Table` and contains methods for accessing indexes and unique columns. + * A generated trait (e.g., `player_state`) used to access the table handle via `ctx.db.{table_name}()`. + +Reviewing this generated documentation is the best way to understand the specific methods available for interacting with your defined tables and their indexes. + +#### Defining Reducers + +Reducers are the functions within your server module responsible for atomically modifying the database state in response to client requests or internal events (like lifecycle triggers or schedules). + +* **Core Attribute:** Reducers are defined as standard Rust functions annotated with `#[reducer]`. +* **Signature:** Every reducer function must accept `&ReducerContext` as its first argument. Subsequent arguments represent data passed from the client caller or scheduler, and their types must derive `SpacetimeType`. +* **Return Type:** Reducers typically return `()` for success or `Result<(), E>` (where `E: Display`) to signal recoverable errors. +* **Necessary Imports:** To perform table operations (insert, update, delete, query indexes), the `spacetimedb::Table` trait must be in scope. Add `use spacetimedb::Table;` to the top of your `lib.rs`. +* **Reducer Context:** The `ReducerContext` (`ctx`) provides access to: + * `ctx.db`: Handles for interacting with database tables. + * `ctx.sender`: The `Identity` of the caller. + * `ctx.identity`: The `Identity` of the module itself. + * `ctx.timestamp`: The `Timestamp` of the invocation. + * `ctx.connection_id`: The optional `ConnectionId` of the caller. + * `ctx.rng`: A source for deterministic random number generation (if needed). +* **Transactionality:** Each reducer call executes within a single, atomic database transaction. If the function returns `()` or `Ok(())`, all database changes are committed. If it returns `Err(...)` or panics, the transaction is aborted, and **all changes are rolled back**, preserving data integrity. +* **Execution Environment:** Reducers run in a sandbox and **cannot** directly perform network I/O (`std::net`) or filesystem operations (`std::fs`, `std::io`). External interaction primarily occurs through database table modifications (observed by clients) and logging (`spacetimedb::log`). +* **Calling Other Reducers:** A reducer can directly call another reducer defined in the same module. This is a standard function call and executes within the *same* transaction; it does not create a sub-transaction. + +```rust +use spacetimedb::{reducer, ReducerContext, Table, Identity, Timestamp, log}; + +// Assume User and Message tables are defined as previously +#[table(name = user, public)] +#[derive(Clone, Debug)] pub struct User { #[primary_key] identity: Identity, name: Option, online: bool } +#[table(name = message, public)] +#[derive(Clone, Debug)] pub struct Message { #[primary_key] #[auto_inc] id: u64, sender: Identity, text: String, sent: Timestamp } + +// Example: Basic reducer to set a user's name +#[reducer] +pub fn set_name(ctx: &ReducerContext, name: String) -> Result<(), String> { + let sender_id = ctx.sender; + let name = validate_name(name)?; // Use helper for validation + + // Find the user row by primary key + if let Some(mut user) = ctx.db.user().identity().find(&sender_id) { + // Update the field + user.name = Some(name); + // Persist the change using the PK index update method + ctx.db.user().identity().update(user); + log::info!("User {} set name", sender_id); + Ok(()) + } else { + Err(format!("User not found: {}", sender_id)) + } +} + +// Example: Basic reducer to send a message +#[reducer] +pub fn send_message(ctx: &ReducerContext, text: String) -> Result<(), String> { + let text = validate_message(text)?; // Use helper for validation + log::info!("User {} sent message: {}", ctx.sender, text); + + // Insert a new row into the Message table + // Note: id is auto_inc, so we provide 0. insert() panics on constraint violation. + let new_message = Message { + id: 0, + sender: ctx.sender, + text, + sent: ctx.timestamp, + }; + ctx.db.message().insert(new_message); + // For Result-based error handling on insert, use try_insert() - see below + + Ok(()) +} + +// Helper validation functions (example) +fn validate_name(name: String) -> Result { + if name.is_empty() { Err("Name cannot be empty".to_string()) } else { Ok(name) } +} + +fn validate_message(text: String) -> Result { + if text.is_empty() { Err("Message cannot be empty".to_string()) } else { Ok(text) } +} +``` + +##### Error Handling: `Result` vs. Panic + +Reducers can indicate failure either by returning `Err` from a function with a `Result` return type or by panicking (e.g., using `panic!`, `unwrap`, `expect`). Both methods trigger a transaction rollback, ensuring atomicity. + +* **Returning `Err(E)**:** + * This is generally preferred for handling *expected* or recoverable failures (e.g., invalid input, failed validation checks). + * The error value `E` (which must implement `Display`) is propagated back to the calling client and can be observed in the `ReducerEventContext` status. + * Crucially, returning `Err` does **not** destroy the underlying WebAssembly (WASM) instance. + +* **Panicking:** + * This typically represents an *unexpected* bug, violated invariant, or unrecoverable state (e.g., assertion failure, unexpected `None` value). + * The client **will** receive an error message derived from the panic payload (the argument provided to `panic!`, or the messages from `unwrap`/`expect`). + * Panicking does **not** cause the client to be disconnected. + * However, a panic **destroys the current WASM instance**. This means the *next* reducer call (from any client) that runs on this module will incur additional latency as SpacetimeDB needs to create and initialize a fresh WASM instance. + +**Choosing between them:** While both ensure data consistency via rollback, returning `Result::Err` is generally better for predictable error conditions as it avoids the performance penalty associated with WASM instance recreation caused by panics. Use `panic!` for truly exceptional circumstances where state is considered unrecoverable or an unhandled bug is detected. + +##### Lifecycle Reducers + +Special reducers handle specific events: +* `#[reducer(init)]`: Runs once when the module is first published **and** any time the database is manually cleared (e.g., via `spacetime publish -c` or `spacetime server clear`). Failure prevents publishing or clearing. Often used for initial data setup. +* `#[reducer(client_connected)]`: Runs when any distinct client connection (e.g., WebSocket, HTTP call) is established. Failure disconnects the client. `ctx.connection_id` is guaranteed to be `Some(...)` within this reducer. +* `#[reducer(client_disconnected)]`: Runs when any distinct client connection terminates. Failure is logged but does not prevent disconnection. `ctx.connection_id` is guaranteed to be `Some(...)` within this reducer. + +These reducers cannot take arguments beyond `&ReducerContext`. + +```rust +use spacetimedb::{reducer, table, ReducerContext, Table, log}; + +#[table(name = settings)] +#[derive(Clone, Debug)] +pub struct Settings { + #[primary_key] + key: String, + value: String, +} + +// Example init reducer: Insert default settings if the table is empty +#[reducer(init)] +pub fn initialize_database(ctx: &ReducerContext) { + log::info!( + "Database Initializing! Module Identity: {}, Timestamp: {}", + ctx.identity(), + ctx.timestamp + ); + // Check if settings table is empty + if ctx.db.settings().count() == 0 { + log::info!("Settings table is empty, inserting default values..."); + // Insert default settings + ctx.db.settings().insert(Settings { + key: "welcome_message".to_string(), + value: "Hello from SpacetimeDB!".to_string(), + }); + ctx.db.settings().insert(Settings { + key: "default_score".to_string(), + value: "0".to_string(), + }); + } else { + log::info!("Settings table already contains data."); + } +} + +// Example client_connected reducer +#[reducer(client_connected)] +pub fn handle_connect(ctx: &ReducerContext) { + log::info!("Client connected: {}, Connection ID: {:?}", ctx.sender, ctx.connection_id); + // ... setup initial state for ctx.sender ... +} + +// Example client_disconnected reducer +#[reducer(client_disconnected)] +pub fn handle_disconnect(ctx: &ReducerContext) { + log::info!("Client disconnected: {}, Connection ID: {:?}", ctx.sender, ctx.connection_id); + // ... cleanup state for ctx.sender ... +} +``` + +##### Filtering and Deleting with Indexes + +SpacetimeDB provides powerful ways to filter and delete table rows using B-tree indexes. The generated accessor methods accept various argument types: + +* **Single Value (Equality):** + * For columns of type `String`, you can pass `&String` or `&str`. + * For columns of a type `T` that implements `Copy`, you can pass `&T` or an owned `T`. + * For other column types `T`, pass a reference `&T`. +* **Ranges:** Use Rust's range syntax (`start..end`, `start..=end`, `..end`, `..=end`, `start..`). Values within the range can typically be owned or references. +* **Multi-Column Indexes:** + * To filter on an exact match for a *prefix* of the index columns, provide a tuple containing single values (following the rules above) for that prefix (e.g., `filter((val_a, val_b))` for an index on `[a, b, c]`). + * To filter using a range, you **must** provide single values for all preceding columns in the index, and the range can **only** be applied to the *last* column in your filter tuple (e.g., `filter((val_a, val_b, range_c))` is valid, but `filter((val_a, range_b, val_c))` or `filter((range_a, val_b))` are **not** valid tuple filters). + * Filtering or deleting using a range on *only the first column* of the index (without using a tuple) remains valid (e.g., `filter(range_a)`). + +```rust +use spacetimedb::{table, reducer, ReducerContext, Table, log}; + +#[table(name = points, index(name = idx_xy, btree(columns = [x, y])))] +#[derive(Clone, Debug)] +pub struct Point { #[primary_key] id: u64, x: i64, y: i64 } +#[table(name = items, index(btree(columns = [name])))] +#[derive(Clone, Debug)] // No SpacetimeType derive +pub struct Item { #[primary_key] item_key: u32, name: String } + +#[reducer] +fn index_operations(ctx: &ReducerContext) { + // Example: Find items named "Sword" using the generated 'name' index handle + // Passing &str for a String column is allowed. + for item in ctx.db.items().name().filter("Sword") { + // ... + } + + // Example: Delete points where x is between 5 (inclusive) and 10 (exclusive) + // using the multi-column index 'idx_xy' - filtering on first column range is OK. + let num_deleted = ctx.db.points().idx_xy().delete(5i64..10i64); + log::info!("Deleted {} points", num_deleted); + + // Example: Find points where x = 3 and y >= 0 + // using the multi-column index 'idx_xy' - (value, range) is OK. + // Note: x is i64 which is Copy, so passing owned 3i64 is allowed. + for point in ctx.db.points().idx_xy().filter((3i64, 0i64..)) { + // ... + } + + // Example: Find points where x > 5 and y = 1 + // This is INVALID: Cannot use range on non-last element of tuple filter. + // for point in ctx.db.points().idx_xy().filter((5i64.., 1i64)) { ... } + + // Example: Delete all points where x = 7 (filtering on index prefix with single value) + // using the multi-column index 'idx_xy'. Passing owned 7i64 is allowed (Copy type). + ctx.db.points().idx_xy().delete(7i64); + + // Example: Delete a single item by its primary key 'item_key' + // Use the PK field name as the method to get the PK index handle, then call delete. + // item_key is u32 (Copy), passing owned value is allowed. + let item_id_to_delete = 101u32; + ctx.db.items().item_key().delete(item_id_to_delete); + + // Using references for a range filter on the first column - OK + let min_x = 100i64; + let max_x = 200i64; + for point in ctx.db.points().idx_xy().filter(&min_x..=&max_x) { + // ... + } +} +``` + +##### Using `try_insert()` + +Instead of `insert()`, which panics or throws if a constraint (like a primary key or unique index violation) occurs, Rust modules can use `try_insert()`. This method returns a `Result>`, allowing you to gracefully handle potential insertion failures without aborting the entire reducer transaction due to a panic. + +The `TryInsertError` enum provides specific variants detailing the cause of failure, such as `UniqueConstraintViolation` or `AutoIncOverflow`. These variants contain associated types specific to the table's constraints (e.g., `TableHandleType::UniqueConstraintViolation`). If a table lacks a certain constraint (like a unique index), the corresponding associated type might be uninhabited. + +```rust +use spacetimedb::{table, reducer, ReducerContext, Table, log, TryInsertError}; + +#[table(name = items)] +#[derive(Clone, Debug)] +pub struct Item { + #[primary_key] #[auto_inc] id: u64, + #[unique] name: String +} + +#[reducer] +pub fn try_add_item(ctx: &ReducerContext, name: String) -> Result<(), String> { + // Assume Item has an auto-incrementing primary key 'id' and a unique 'name' + let new_item = Item { id: 0, name }; // Provide 0 for auto_inc + + // try_insert returns Result> + match ctx.db.items().try_insert(new_item) { + Ok(inserted_item) => { + // try_insert returns the inserted row (with assigned PK if auto_inc) on success + log::info!("Successfully inserted item with ID: {}", inserted_item.id); + Ok(()) + } + Err(e) => { + // Match on the specific TryInsertError variant + match e { + TryInsertError::UniqueConstraintViolation(constraint_error) => { + // constraint_error is of type items__TableHandle::UniqueConstraintViolation + // This type often provides details about the violated constraint. + // For simplicity, we just log a generic message here. + let error_msg = format!("Failed to insert item: Name '{}' already exists.", name); + log::error!("{}", error_msg); + // Return an error to the calling client + Err(error_msg) + } + TryInsertError::AutoIncOverflow(_) => { + // Handle potential overflow of the auto-incrementing key + let error_msg = "Failed to insert item: Auto-increment counter overflow.".to_string(); + log::error!("{}", error_msg); + Err(error_msg) + } + // Use a wildcard for other potential errors or uninhabited variants + _ => { + let error_msg = format!("Failed to insert item: Unknown constraint violation."); + log::error!("{}", error_msg); + Err(error_msg) + } + } + } + } +} + +#### Scheduled Reducers (Rust) + +In addition to lifecycle annotations, reducers can be scheduled. This allows calling the reducers at a particular time, or in a loop. This can be used for game loops. + +The scheduling information for a reducer is stored in a table. This table has two mandatory fields: + +* A primary key that identifies scheduled reducer calls (often using `#[auto_inc]`). +* A field of type `spacetimedb::ScheduleAt` that says when to call the reducer. + +The table definition itself links to the reducer function using the `scheduled(reducer_function_name)` parameter within the `#[table(...)]` attribute. + +Managing timers with a scheduled table is as simple as inserting or deleting rows from the table. This makes scheduling transactional in SpacetimeDB. If a reducer A first schedules B but then errors for some other reason, B will not be scheduled to run. + +A `ScheduleAt` value can be created using `.into()` from: + +* A `spacetimedb::Timestamp`: Schedules the reducer to run **once** at that specific time. +* A `spacetimedb::TimeDuration` or `std::time::Duration`: Schedules the reducer to run **periodically** with that duration as the interval. + +The scheduled reducer function itself is defined like a normal reducer (`#[reducer]`), taking `&ReducerContext` and an instance of the schedule table struct as arguments. + +```rust +use spacetimedb::{table, reducer, ReducerContext, Timestamp, TimeDuration, ScheduleAt, Table}; +use log::debug; + +// 1. Declare the table with scheduling information, linking it to `send_message`. +#[table(name = send_message_schedule, scheduled(send_message))] +struct SendMessageSchedule { + // Mandatory fields: + // ============================ + + /// An identifier for the scheduled reducer call. + #[primary_key] + #[auto_inc] + scheduled_id: u64, + + /// Information about when the reducer should be called. + scheduled_at: ScheduleAt, + + // In addition to the mandatory fields, any number of fields can be added. + // These can be used to provide extra information to the scheduled reducer. + + // Custom fields: + // ============================ + + /// The text of the scheduled message to send. + text: String, +} + +// 2. Declare the scheduled reducer. +// The second argument is a row of the scheduling information table. +#[reducer] +fn send_message(ctx: &ReducerContext, args: SendMessageSchedule) -> Result<(), String> { + // Security check is important! + if ctx.sender != ctx.identity() { + return Err("Reducer `send_message` may not be invoked by clients, only via scheduling.".into()); + } + + let message_to_send = &args.text; + log::info!("Scheduled SendMessage: {}", message_to_send); + + // ... potentially send the message or perform other actions ... + + Ok(()) +} + +// 3. Example of scheduling reducers (e.g., in init) +#[reducer(init)] +fn init(ctx: &ReducerContext) -> Result<(), String> { + + let current_time = ctx.timestamp; + let ten_seconds = TimeDuration::from_micros(10_000_000); + let future_timestamp: Timestamp = ctx.timestamp + ten_seconds; + + // Schedule a one-off message + ctx.db.send_message_schedule().insert(SendMessageSchedule { + scheduled_id: 0, // Use 0 for auto_inc + text: "I'm a bot sending a message one time".to_string(), + // Creating a `ScheduleAt` from a `Timestamp` results in the reducer + // being called once, at exactly the time `future_timestamp`. + scheduled_at: future_timestamp.into() + }); + log::info!("Scheduled one-off message."); + + // Schedule a periodic message (every 10 seconds) + let loop_duration: TimeDuration = ten_seconds; + ctx.db.send_message_schedule().insert(SendMessageSchedule { + scheduled_id: 0, // Use 0 for auto_inc + text: "I'm a bot sending a message every 10 seconds".to_string(), + // Creating a `ScheduleAt` from a `Duration`/`TimeDuration` results in the reducer + // being called in a loop, once every `loop_duration`. + scheduled_at: loop_duration.into() + }); + log::info!("Scheduled periodic message."); + + Ok(()) +} +``` + +Refer to the [official Rust Module SDK documentation on docs.rs](https://docs.rs/spacetimedb/latest/spacetimedb/attr.reducer.html#scheduled-reducers) for more detailed syntax and alternative scheduling approaches (like using `schedule::periodic`). + +##### Scheduled Reducer Details + +* **Best-Effort Scheduling:** Scheduled reducers are called on a best-effort basis and may be slightly delayed in their execution when a database is under heavy load. + +* **Restricting Access (Security):** Scheduled reducers are normal reducers and *can* still be called directly by clients. If a scheduled reducer should *only* be called by the scheduler, it is crucial to begin the reducer with a check comparing the caller's identity (`ctx.sender`) to the module's own identity (`ctx.identity()`). + ```rust + use spacetimedb::{reducer, ReducerContext}; + // Assuming MyScheduleArgs table is defined + struct MyScheduleArgs {/*...*/} + + #[reducer] + fn my_scheduled_reducer(ctx: &ReducerContext, args: MyScheduleArgs) -> Result<(), String> { + if ctx.sender != ctx.identity() { + return Err("Reducer `my_scheduled_reducer` may not be invoked by clients, only via scheduling.".into()); + } + // ... Reducer body proceeds only if called by scheduler ... + Ok(()) + } + ``` + +:::info Scheduled Reducers and Connections +Scheduled reducer calls originate from the SpacetimeDB scheduler itself, not from an external client connection. Therefore, within a scheduled reducer, `ctx.sender` will be the module's own identity, and `ctx.connection_id` will be `None`. +::: + +#### Row-Level Security (Client Visibility Filters) + +(Unstable Feature) + +SpacetimeDB allows defining row-level security rules using the `#[spacetimedb::client_visibility_filter]` attribute. This attribute is applied to a `const` binding of type `Filter` and defines an SQL-like query that determines which rows of a table are visible to clients making subscription requests. + +* The query uses `:sender` to refer to the identity of the subscribing client. +* Multiple filters on the same table are combined with `OR` logic. +* Query errors (syntax, type errors, unknown tables) are reported during `spacetime publish`. + +```rust +use spacetimedb::{client_visibility_filter, Filter, table, Identity}; + +#[table(name = "location_state")] +struct LocationState { #[primary_key] entity_id: u64, chunk_index: u32 } +#[table(name = "user_state")] +struct UserState { #[primary_key] identity: Identity, entity_id: u64 } + +/// Players can only see entities located in the same chunk as their own entity. +#[client_visibility_filter] +const PLAYERS_SEE_ENTITIES_IN_SAME_CHUNK: Filter = Filter::Sql(" + SELECT * FROM LocationState WHERE chunk_index IN ( + SELECT chunk_index FROM LocationState WHERE entity_id IN ( + SELECT entity_id FROM UserState WHERE identity = :sender + ) + ) +"); +``` + +:::info Version-Specific Status and Usage + +* **SpacetimeDB 1.0:** The Row-Level Security feature was not fully implemented or enforced in version 1.0. Modules developed for SpacetimeDB 1.0 should **not** use this feature. +* **SpacetimeDB 1.1:** The feature is available but considered **unstable** in version 1.1. To use it, you must explicitly opt-in by enabling the `unstable` feature flag for the `spacetimedb` crate in your module's `Cargo.toml`: + ```toml + [dependencies] + spacetimedb = { version = "1.1", features = ["unstable"] } + # ... other dependencies + ``` + Modules developed for 1.1 can use row-level security only if this feature flag is enabled. +::: + +### Client SDK (Rust) + +This section details how to build native Rust client applications that interact with a SpacetimeDB module. + +#### 1. Project Setup + +Start by creating a standard Rust binary project and adding the `spacetimedb_sdk` crate as a dependency: + +```bash +cargo new my_rust_client +cd my_rust_client +cargo add spacetimedb_sdk # Ensure version matches your SpacetimeDB installation +``` + +#### 2. Generate Module Bindings + +Client code relies on generated bindings specific to your server module. Use the `spacetime generate` command, pointing it to your server module project: + +```bash +# From your client project directory +mkdir -p src/module_bindings +spacetime generate --lang rust \ + --out-dir src/module_bindings \ + --project-path ../path/to/your/server_module +``` + +Then, declare the generated module in your `main.rs` or `lib.rs`: + +```rust +mod module_bindings; +// Optional: bring generated types into scope +// use module_bindings::*; +``` + +#### 3. Connecting to the Database + +The core type for managing a connection is `module_bindings::DbConnection`. You configure and establish a connection using a builder pattern. + +* **Builder:** Start with `DbConnection::builder()`. +* **URI & Name:** Specify the SpacetimeDB instance URI (`.with_uri("http://localhost:3000")`) and the database name or identity (`.with_module_name("my_database")`). +* **Authentication:** Provide an identity token using `.with_token(Option)`. If `None` or omitted for the first connection, the server issues a new identity and token (retrieved via the `on_connect` callback). +* **Callbacks:** Register callbacks for connection lifecycle events: + * `.on_connect(|conn, identity, token| { ... })`: Runs on successful connection. Often used to store the `token` for future connections. + * `.on_connect_error(|err_ctx, error| { ... })`: Runs if connection fails. + * `.on_disconnect(|err_ctx, maybe_error| { ... })`: Runs when the connection closes, either gracefully or due to an error. +* **Build:** Call `.build()` to initiate the connection attempt. + +```rust +use spacetimedb_sdk::{identity, DbContext, Identity, credentials}; +use crate::module_bindings::{DbConnection, connect_event_callbacks, table_update_callbacks}; + +const HOST: &str = "http://localhost:3000"; +const DB_NAME: &str = "my_database"; // Or your specific DB name/identity + +fn connect_to_db() -> DbConnection { + // Helper for storing/loading auth token + fn creds_store() -> credentials::File { + credentials::File::new(".my_client_creds") // Unique filename + } + + DbConnection::builder() + .with_uri(HOST) + .with_module_name(DB_NAME) + .with_token(creds_store().load().ok()) // Load token if exists + .on_connect(|conn, identity, auth_token| { + println!("Connected. Identity: {}", identity.to_hex()); + // Save the token for future connections + if let Err(e) = creds_store().save(auth_token) { + eprintln!("Failed to save auth token: {}", e); + } + // Register other callbacks *after* successful connection + connect_event_callbacks(conn); + table_update_callbacks(conn); + // Initiate subscriptions + subscribe_to_tables(conn); + }) + .on_connect_error(|err_ctx, err| { + eprintln!("Connection Error: {}", err); + std::process::exit(1); + }) + .on_disconnect(|err_ctx, maybe_err| { + println!("Disconnected. Reason: {:?}", maybe_err); + std::process::exit(0); + }) + .build() + .expect("Failed to connect") +} +``` + +#### 4. Managing the Connection Loop + +After establishing the connection, you need to continuously process incoming messages and trigger callbacks. The SDK offers several ways: + +* **Threaded:** `connection.run_threaded()`: Spawns a dedicated background thread that automatically handles message processing. +* **Async:** `async connection.run_async()`: Integrates with async runtimes like Tokio or async-std. +* **Manual Tick:** `connection.frame_tick()`: Processes pending messages without blocking. Suitable for integrating into game loops or other manual polling scenarios. You must call this repeatedly. + +```rust +// Example using run_threaded +fn main() { + let connection = connect_to_db(); + let handle = connection.run_threaded(); // Spawns background thread + + // Main thread can now do other work, like handling user input + // handle_user_input(&connection); + + handle.join().expect("Connection thread panicked"); +} +``` + +#### 5. Subscribing to Data + +Clients receive data by subscribing to SQL queries against the database's public tables. + +* **Builder:** Start with `connection.subscription_builder()`. +* **Callbacks:** + * `.on_applied(|sub_ctx| { ... })`: Runs when the initial data for the subscription arrives. + * `.on_error(|err_ctx, error| { ... })`: Runs if the subscription fails (e.g., invalid SQL). +* **Subscribe:** Call `.subscribe(vec!["SELECT * FROM table_a", "SELECT * FROM table_b WHERE some_col > 10"])` with a list of query strings. This returns a `SubscriptionHandle`. +* **All Tables:** `.subscribe_to_all_tables()` is a convenience for simple clients but cannot be easily unsubscribed. +* **Unsubscribing:** Use `handle.unsubscribe()` or `handle.unsubscribe_then(|sub_ctx| { ... })` to stop receiving updates for specific queries. + +```rust +use crate::module_bindings::{SubscriptionEventContext, ErrorContext}; + +fn subscribe_to_tables(conn: &DbConnection) { + println!("Subscribing to tables..."); + conn.subscription_builder() + .on_applied(on_subscription_applied) + .on_error(|err_ctx, err| { + eprintln!("Subscription failed: {}", err); + }) + // Example: Subscribe to all rows from 'player' and 'message' tables + .subscribe(vec!["SELECT * FROM player", "SELECT * FROM message"]); +} + +fn on_subscription_applied(ctx: &SubscriptionEventContext) { + println!("Subscription applied! Initial data received."); + // Example: Print initial messages sorted by time + let mut messages: Vec<_> = ctx.db().message().iter().collect(); + messages.sort_by_key(|m| m.sent); + for msg in messages { + // print_message(ctx.db(), &msg); // Assuming a print_message helper + } +} +``` + +#### 6. Accessing Cached Data & Handling Row Callbacks + +Subscribed data is stored locally in the client cache, accessible via `ctx.db()` (where `ctx` can be a `DbConnection` or any event context). + +* **Accessing Tables:** Use `ctx.db().table_name()` to get a handle to a table. +* **Iterating:** `table_handle.iter()` returns an iterator over all cached rows. +* **Filtering/Finding:** Use index accessors like `table_handle.primary_key_field().find(&pk_value)` or `table_handle.indexed_field().filter(value_or_range)` for efficient lookups (similar to server-side). +* **Row Callbacks:** Register callbacks to react to changes in the cache: + * `table_handle.on_insert(|event_ctx, inserted_row| { ... })` + * `table_handle.on_delete(|event_ctx, deleted_row| { ... })` + * `table_handle.on_update(|event_ctx, old_row, new_row| { ... })` (Only for tables with a `#[primary_key]`) + +```rust +use crate::module_bindings::{Player, Message, EventContext, Event, DbView}; + +// Placeholder for where other callbacks are registered +fn table_update_callbacks(conn: &DbConnection) { + conn.db().player().on_insert(handle_player_insert); + conn.db().player().on_update(handle_player_update); + conn.db().message().on_insert(handle_message_insert); +} + +fn handle_player_insert(ctx: &EventContext, player: &Player) { + // Only react to updates caused by reducers, not initial subscription load + if let Event::Reducer(_) = ctx.event { + println!("Player joined: {}", player.name.as_deref().unwrap_or("Unknown")); + } +} + +fn handle_player_update(ctx: &EventContext, old: &Player, new: &Player) { + if old.name != new.name { + println!("Player renamed: {} -> {}", + old.name.as_deref().unwrap_or("??"), + new.name.as_deref().unwrap_or("??") + ); + } + // ... handle other changes like online status ... +} + +fn handle_message_insert(ctx: &EventContext, message: &Message) { + if let Event::Reducer(_) = ctx.event { + // Find sender name from cache + let sender_name = ctx.db().player().identity().find(&message.sender) + .map_or("Unknown".to_string(), |p| p.name.clone().unwrap_or("??".to_string())); + println!("{}: {}", sender_name, message.text); + } +} +``` + +:::info Handling Initial Data vs. Live Updates in Callbacks +Callbacks like `on_insert` and `on_update` are triggered for both the initial data received when a subscription is first applied *and* for subsequent live changes caused by reducers. If you need to differentiate (e.g., only react to *new* messages, not the backlog), you can inspect the `ctx.event` type. For example, `if let Event::Reducer(_) = ctx.event { ... }` checks if the change came from a reducer call. +::: + +#### 7. Invoking Reducers & Handling Reducer Callbacks + +Clients trigger state changes by calling reducers defined in the server module. + +* **Invoking:** Access generated reducer functions via `ctx.reducers().reducer_name(arg1, arg2, ...)`. +* **Reducer Callbacks:** Register callbacks to react to the *outcome* of reducer calls (especially useful for handling failures or confirming success if not directly observing table changes): + * `ctx.reducers().on_reducer_name(|reducer_event_ctx, arg1, ...| { ... })` + * The `reducer_event_ctx.event` contains: + * `reducer`: The specific reducer variant and its arguments. + * `status`: `Status::Committed`, `Status::Failed(reason)`, or `Status::OutOfEnergy`. + * `caller_identity`, `timestamp`, etc. + +```rust +use crate::module_bindings::{ReducerEventContext, Status}; + +// Placeholder for where other callbacks are registered +fn connect_event_callbacks(conn: &DbConnection) { + conn.reducers().on_set_name(handle_set_name_result); + conn.reducers().on_send_message(handle_send_message_result); +} + +fn handle_set_name_result(ctx: &ReducerContext, name: &String) { + if let Status::Failed(reason) = &ctx.event.status { + // Check if the failure was for *our* call (important in multi-user contexts) + if ctx.event.caller_identity == ctx.identity() { + eprintln!("Error setting name to '{}': {}", name, reason); + } + } +} + +fn handle_send_message_result(ctx: &ReducerContext, text: &String) { + if let Status::Failed(reason) = &ctx.event.status { + if ctx.event.caller_identity == ctx.identity() { // Our call failed + eprintln!("[Error] Failed to send message '{}': {}", text, reason); + } + } +} + +// Example of calling a reducer (e.g., from user input handler) +fn send_chat_message(conn: &DbConnection, message: String) { + if !message.is_empty() { + conn.reducers().send_message(message); // Fire-and-forget style + } +} +``` + +// ... (Keep the second info box about C# callbacks, it will be moved later) ... +:::info Handling Initial Data vs. Live Updates in Callbacks +Callbacks like `OnInsert` and `OnUpdate` are triggered for both the initial data received when a subscription is first applied *and* for subsequent live changes caused by reducers. If you need to differentiate (e.g., only react to *new* messages, not the backlog), you can inspect the `ctx.Event` type. For example, checking `if (ctx.Event is not Event.SubscribeApplied) { ... }` ensures the code only runs for events triggered by reducers, not the initial subscription data load. +::: + +### Server Module (C#) + +#### Defining Types + +Custom classes, structs, or records intended for use as fields within database tables or as parameters/return types in reducers must be marked with the `[Type]` attribute. This attribute enables SpacetimeDB to handle the serialization and deserialization of these types. + +* **Basic Usage:** Apply `[Type]` to your classes, structs, or records. Use the `partial` modifier to allow SpacetimeDB's source generators to augment the type definition. +* **Cross-Language Naming:** Currently, the C# module SDK does **not** provide a direct equivalent to Rust's `#[sats(name = "...")]` attribute for controlling the generated names in *other* client languages (like TypeScript). The C# type name itself (including its namespace) is typically used. Standard C# namespacing (`namespace MyGame.SharedTypes { ... }`) is the primary way to organize and avoid collisions. +* **Enums:** Standard C# enums can be marked with `[Type]`. For "tagged unions" or "discriminated unions" (like Rust enums with associated data), use the pattern of an abstract base record/class with the `[Type]` attribute, and derived records/classes for each variant, also marked with `[Type]`. Then, define a final `[Type]` record that inherits from `TaggedEnum<(...)>` listing the variants. +* **Type Aliases:** Use standard C# `using` aliases for clarity (e.g., `using PlayerScore = System.UInt32;`). The underlying primitive type must still be serializable by SpacetimeDB. + +```csharp +using SpacetimeDB; +using System; // Required for System.UInt32 if using aliases like below + +// Example Struct +[Type] +public partial struct Position { public int X; public int Y; } + +// Example Tagged Union (Enum with Data) Pattern: +// 1. Base abstract record +[Type] public abstract partial record PlayerStatusBase { } +// 2. Derived records for variants +[Type] public partial record IdleStatus : PlayerStatusBase { } +[Type] public partial record WalkingStatus : PlayerStatusBase { public Position Target; } +[Type] public partial record FightingStatus : PlayerStatusBase { public Identity OpponentId; } +// 3. Final type inheriting from TaggedEnum +[Type] +public partial record PlayerStatus : TaggedEnum<( + IdleStatus Idle, + WalkingStatus Walking, + FightingStatus Fighting +)> { } + +// Example Standard Enum +[Type] +public enum ItemType { Weapon, Armor, Potion } + +// Example Type Alias +using PlayerScore = System.UInt32; + +``` + +:::info C# `partial` Keyword +Table and Type definitions in C# should use the `partial` keyword (e.g., `public partial class MyTable`). This allows the SpacetimeDB source generator to add necessary internal methods and serialization logic to your types without requiring you to write boilerplate code. +::: + +#### Defining Tables + +Database tables store the application's persistent state. They are defined using C# classes or structs marked with the `[Table]` attribute. + +* **Core Attribute:** `[Table(Name = "my_table_name", ...)]` marks a class or struct as a database table definition. The specified string `Name` is how the table will be referenced in SQL queries and generated APIs. +* **Partial Modifier:** Use the `partial` keyword (e.g., `public partial class MyTable`) to allow SpacetimeDB's source generators to add necessary methods and logic to your definition. +* **Public vs. Private:** By default, tables are **private**, accessible only by server-side reducer code. To allow clients to read or subscribe to a table's data, set `Public = true` within the attribute: `[Table(..., Public = true)]`. This is a common source of errors if forgotten. +* **Primary Keys:** Designate a single **public field** as the primary key using `[PrimaryKey]`. This ensures uniqueness, creates an efficient index, and allows clients to track row updates. +* **Auto-Increment:** Mark an integer-typed primary key **public field** with `[AutoInc]` to have SpacetimeDB automatically assign unique, sequentially increasing values upon insertion. Provide `0` as the value for this field when inserting a new row to trigger the auto-increment mechanism. +* **Unique Constraints:** Enforce uniqueness on non-primary key **public fields** using `[Unique]`. Attempts to insert or update rows violating this constraint will fail (throw an exception). +* **Indexes:** Create B-tree indexes for faster lookups on specific **public fields** or combinations of fields. Use `[Index.BTree]` on a single field for a simple index, or define indexes at the class/struct level using `[Index.BTree(Name = "MyIndexName", Columns = new[] { nameof(ColA), nameof(ColB) })]`. +* **Nullable Fields:** Use standard C# nullable reference types (`string?`) or nullable value types (`int?`, `Timestamp?`) for fields that can hold null values. +* **Instances vs. Database:** Remember that table class/struct instances (e.g., `var player = new PlayerState { ... };`) are just data objects. Modifying an instance does **not** automatically update the database. Interaction happens through generated handles accessed via the `ReducerContext` (e.g., `ctx.Db.player_state.Insert(...)`). +* **Case Sensitivity:** Table names specified via `Name = "..."` are case-sensitive and must be matched exactly in SQL queries. +* **Pitfalls:** + * SpacetimeDB attributes (`[PrimaryKey]`, `[AutoInc]`, `[Unique]`, `[Index.BTree]`) **must** be applied to **public fields**, not properties (`{ get; set; }`). Using properties can cause build errors or runtime issues. + * Avoid manually inserting values into `[AutoInc]` fields that are also `[Unique]`, especially values larger than the current sequence counter, as this can lead to future unique constraint violations when the counter catches up. + * Ensure `Public = true` is set if clients need access. + * Always use the `partial` keyword on table definitions. + * Define indexes *within* the main `#[table(name=..., index=...)]` attribute. Each `#[table]` macro invocation defines a *distinct* table and requires a `name`; separate `#[table]` attributes cannot be used solely to add indexes to a previously named table. + +```csharp +using SpacetimeDB; +using System; // For Nullable types if needed + +// Assume Position, PlayerStatus, ItemType are defined as types + +// Example Table Definition +[Table(Name = "player_state", Public = true)] +[Index.BTree(Name = "idx_level", Columns = new[] { nameof(Level) })] // Table-level index +public partial class PlayerState +{ + [PrimaryKey] + public Identity PlayerId; // Public field + [Unique] + public string Name = ""; // Public field (initialize to avoid null warnings if needed) + public uint Health; // Public field + public ushort Level; // Public field + public Position Position; // Public field (custom struct type) + public PlayerStatus Status; // Public field (custom record type) + public Timestamp? LastLogin; // Public field, nullable struct +} + +[Table(Name = "inventory_item", Public = true)] +public partial class InventoryItem +{ + [PrimaryKey] + [AutoInc] // Automatically generate IDs + public ulong ItemId; // Public field + public Identity OwnerId; // Public field + [Index.BTree] // Simple index on this field + public ItemType ItemType; // Public field + public uint Quantity; // Public field +} + +// Example of a private table +[Table(Name = "internal_game_data")] // Public = false is default +public partial class InternalGameData +{ + [PrimaryKey] + public string Key = ""; // Public field + public string Value = ""; // Public field +} +``` + +##### Multiple Tables from One Class + +You can use the same underlying data class for multiple tables, often using inheritance. Ensure SpacetimeDB attributes like `[PrimaryKey]` are applied to **public fields**, not properties. + +```csharp +using SpacetimeDB; + +// Define the core data structure (must be [Type] if used elsewhere) +[Type] +public partial class CharacterInfo +{ + [PrimaryKey] + public ulong CharacterId; // Use public field + public string Name = ""; // Use public field + public ushort Level; // Use public field +} + +// Define derived classes, each with its own table attribute +[Table(Name = "active_characters")] +public partial class ActiveCharacter : CharacterInfo { + // Can add specific public fields if needed + public bool IsOnline; +} + +[Table(Name = "deleted_characters")] +public partial class DeletedCharacter : CharacterInfo { + // Can add specific public fields if needed + public Timestamp DeletionTime; +} + +// Reducers would interact with ActiveCharacter or DeletedCharacter tables +// E.g., ctx.Db.active_characters.Insert(new ActiveCharacter { CharacterId = 1, Name = "Hero", Level = 10, IsOnline = true }); +``` + +Alternatively, you can define multiple `[Table]` attributes directly on a single class or struct. This maps the same underlying type to multiple distinct tables: + +```csharp +using SpacetimeDB; + +// Define the core data structure once +// Apply multiple [Table] attributes to map it to different tables +[Type] // Mark as a type if used elsewhere (e.g., reducer args) +[Table(Name = "logged_in_players", Public = true)] +[Table(Name = "players_in_lobby", Public = true)] +public partial class PlayerSessionData +{ + [PrimaryKey] + public Identity PlayerId; // Use public field + [Unique] + [AutoInc] + public ulong SessionId; // Use public field + public Timestamp LastActivity; +} + +// Reducers would interact with the specific table handles: +// E.g., ctx.Db.logged_in_players.Insert(new PlayerSessionData { ... }); +// E.g., var lobbyPlayer = ctx.Db.players_in_lobby.PlayerId.Find(someId); +``` + +#### Defining Reducers + +Reducers are the functions within your server module responsible for atomically modifying the database state in response to client requests or internal events (like lifecycle triggers or schedules). + +* **Core Attribute:** Reducers are defined as `static` methods within a (typically `static partial`) class, annotated with `[SpacetimeDB.Reducer]`. +* **Signature:** Every reducer method must accept `ReducerContext` as its first argument. Subsequent arguments represent data passed from the client caller or scheduler, and their types must be marked with `[Type]`. +* **Return Type:** Reducers should typically return `void`. Errors are signaled by throwing exceptions. +* **Reducer Context:** The `ReducerContext` (`ctx`) provides access to: + * `ctx.Db`: Handles for interacting with database tables. + * `ctx.Sender`: The `Identity` of the caller. + * `ctx.Identity`: The `Identity` of the module itself. + * `ctx.Timestamp`: The `Timestamp` of the invocation. + * `ctx.ConnectionId`: The nullable `ConnectionId` of the caller. + * `ctx.Rng`: A `System.Random` instance for deterministic random number generation (if needed). +* **Transactionality:** Each reducer call executes within a single, atomic database transaction. If the method completes without an unhandled exception, all database changes are committed. If an exception is thrown, the transaction is aborted, and **all changes are rolled back**, preserving data integrity. +* **Execution Environment:** Reducers run in a sandbox and **cannot** directly perform network I/O (`System.Net`) or filesystem operations (`System.IO`). External interaction primarily occurs through database table modifications (observed by clients) and logging (`SpacetimeDB.Log`). +* **Calling Other Reducers:** A reducer can directly call another static reducer method defined in the same module. This is a standard method call and executes within the *same* transaction; it does not create a sub-transaction. + +```csharp +using SpacetimeDB; +using System; +using System.Linq; // Used in more complex examples later + +public static partial class Module +{ + // Assume PlayerState and InventoryItem tables are defined as previously + [Table(Name = "player_state", Public = true)] public partial class PlayerState { + [PrimaryKey] public Identity PlayerId; + [Unique] public string Name = ""; + public uint Health; public ushort Level; /* ... other fields */ } + [Table(Name = "inventory_item", Public = true)] public partial class InventoryItem { + [PrimaryKey] #[AutoInc] public ulong ItemId; + public Identity OwnerId; /* ... other fields */ } + + // Example: Basic reducer to update player data + [Reducer] + public static void UpdatePlayerData(ReducerContext ctx, string? newName) + { + var playerId = ctx.Sender; + + // Find player by primary key + var player = ctx.Db.player_state.PlayerId.Find(playerId); + if (player == null) + { + throw new Exception($"Player not found: {playerId}"); + } + + // Update fields conditionally + bool requiresUpdate = false; + if (!string.IsNullOrWhiteSpace(newName)) + { + // Basic check for name uniqueness (simplified) + var existing = ctx.Db.player_state.Name.Find(newName); + if(existing != null && !existing.PlayerId.Equals(playerId)) { + throw new Exception($"Name '{newName}' already taken."); + } + if (player.Name != newName) { + player.Name = newName; + requiresUpdate = true; + } + } + + if (player.Level < 100) { // Example simple update + player.Level += 1; + requiresUpdate = true; + } + + // Persist changes if any were made + if (requiresUpdate) { + ctx.Db.player_state.PlayerId.Update(player); + Log.Info($"Updated player data for {playerId}"); + } + } + + // Example: Basic reducer to register a player + [Reducer] + public static void RegisterPlayer(ReducerContext ctx, string name) + { + if (string.IsNullOrWhiteSpace(name)) { + throw new ArgumentException("Name cannot be empty."); + } + Log.Info($"Attempting to register player: {name} ({ctx.Sender})"); + + // Check if player identity or name already exists + if (ctx.Db.player_state.PlayerId.Find(ctx.Sender) != null || ctx.Db.player_state.Name.Find(name) != null) + { + throw new Exception("Player already registered or name taken."); + } + + // Create new player instance + var newPlayer = new PlayerState + { + PlayerId = ctx.Sender, + Name = name, + Health = 100, + Level = 1, + // Initialize other fields as needed... + }; + + // Insert the new player. This will throw on constraint violation. + ctx.Db.player_state.Insert(newPlayer); + Log.Info($"Player registered successfully: {ctx.Sender}"); + } + + // Example: Basic reducer showing deletion + [Reducer] + public static void DeleteMyItems(ReducerContext ctx) + { + var ownerId = ctx.Sender; + int deletedCount = 0; + + // Find items by owner (Requires an index on OwnerId for efficiency) + // This example iterates if no index exists. + var itemsToDelete = ctx.Db.inventory_item.Iter() + .Where(item => item.OwnerId.Equals(ownerId)) + .ToList(); // Collect IDs to avoid modification during iteration + + foreach(var item in itemsToDelete) + { + // Delete using the primary key index + if (ctx.Db.inventory_item.ItemId.Delete(item.ItemId)) { + deletedCount++; + } + } + Log.Info($"Deleted {deletedCount} items for player {ownerId}."); + } +} +``` + +##### Handling Insert Constraint Violations + +Unlike Rust's `try_insert` which returns a `Result`, the C# `Insert` method throws an exception if a constraint (like a primary key or unique index violation) occurs. There are two main ways to handle this in C# reducers: + +1. **Pre-checking:** Before calling `Insert`, explicitly query the database using the relevant indexes to check if the insertion would violate any constraints (e.g., check if a user with the same ID or unique name already exists). This is often cleaner if the checks are straightforward. The `RegisterPlayer` example above demonstrates this pattern. + +2. **Using `try-catch`:** Wrap the `Insert` call in a `try-catch` block. This allows you to catch the specific exception (often a `SpacetimeDB.ConstraintViolationException` or potentially a more general `Exception` depending on the SDK version and error type) and handle the failure gracefully (e.g., log an error, return a specific error message to the client via a different mechanism if applicable, or simply allow the transaction to roll back cleanly without crashing the reducer unexpectedly). + +```csharp +using SpacetimeDB; +using System; + +public static partial class Module +{ + [Table(Name = "unique_items")] + public partial class UniqueItem { + [PrimaryKey] public string ItemName; + public int Value; + } + + // Example using try-catch for insertion + [Reducer] + public static void AddUniqueItemWithCatch(ReducerContext ctx, string name, int value) + { + var newItem = new UniqueItem { ItemName = name, Value = value }; + try + { + // Attempt to insert + ctx.Db.unique_items.Insert(newItem); + Log.Info($"Successfully inserted item: {name}"); + } + catch (Exception ex) // Catch a general exception or a more specific one if available + { + // Log the specific error + Log.Error($"Failed to insert item '{name}': Constraint violation or other error. Details: {ex.Message}"); + // Optionally, re-throw a custom exception or handle differently + // Throwing ensures the transaction is rolled back + throw new Exception($"Item name '{name}' might already exist."); + } + } +} +``` +Choosing between pre-checking and `try-catch` depends on the complexity of the constraints and the desired flow. Pre-checking can avoid the overhead of exception handling for predictable violations, while `try-catch` provides a direct way to handle unexpected insertion failures. + +:::note C# `Insert` vs Rust `try_insert` +Unlike Rust, the C# SDK does not currently provide a `TryInsert` method that returns a result. The standard `Insert` method will throw an exception if a constraint (primary key, unique index) is violated. Therefore, C# reducers should typically check for potential constraint violations *before* calling `Insert`, or be prepared to handle the exception (which will likely roll back the transaction). +::: + +##### Lifecycle Reducers + +Special reducers handle specific events: +* `[Reducer(ReducerKind.Init)]`: Runs once when the module is first published **and** any time the database is manually cleared (e.g., via `spacetime publish -c` or `spacetime server clear`). Failure prevents publishing or clearing. Often used for initial data setup. +* `[Reducer(ReducerKind.ClientConnected)]`: Runs when any distinct client connection (e.g., WebSocket, HTTP call) is established. Failure disconnects the client. `ctx.connection_id` is guaranteed to have a value within this reducer. +* `[Reducer(ReducerKind.ClientDisconnected)]`: Runs when any distinct client connection terminates. Failure is logged but does not prevent disconnection. `ctx.connection_id` is guaranteed to have a value within this reducer. + +These reducers cannot take arguments beyond `&ReducerContext`. + +```csharp +// Example init reducer is shown in Scheduled Reducers section +[Reducer(ReducerKind.ClientConnected)] +public static void HandleConnect(ReducerContext ctx) { + Log.Info($"Client connected: {ctx.Sender}"); + // ... setup initial state for ctx.sender ... +} + +[Reducer(ReducerKind.ClientDisconnected)] +public static void HandleDisconnect(ReducerContext ctx) { + Log.Info($"Client disconnected: {ctx.Sender}"); + // ... cleanup state for ctx.sender ... +} +``` + +#### Scheduled Reducers (C#) + +In addition to lifecycle annotations, reducers can be scheduled. This allows calling the reducers at a particular time, or periodically for loops (e.g., game loops). + +The scheduling information for a reducer is stored in a table. This table links to the reducer function and has specific mandatory fields: + +1. **Define the Schedule Table:** Create a table class/struct using `[Table(Name = ..., Scheduled = nameof(YourReducerName), ScheduledAt = nameof(YourScheduleAtColumnName))]`. + * The `Scheduled` parameter links this table to the static reducer method `YourReducerName`. + * The `ScheduledAt` parameter specifies the name of the field within this table that holds the scheduling information. This field **must** be of type `SpacetimeDB.ScheduleAt`. + * The table **must** also have a primary key field (often `[AutoInc] ulong Id`). + * Additional fields can be included to pass arguments to the scheduled reducer. +2. **Define the Scheduled Reducer:** Create the `static` reducer method (`YourReducerName`) specified in the table attribute. It takes `ReducerContext` and an instance of the schedule table class/struct as arguments. +3. **Schedule an Invocation:** Inside another reducer, create an instance of your schedule table struct. + * Set the `ScheduleAt` field (using the name specified in the `ScheduledAt` parameter) to either: + * `new ScheduleAt.Time(timestamp)`: Schedules the reducer to run **once** at the specified `Timestamp`. + * `new ScheduleAt.Interval(timeDuration)`: Schedules the reducer to run **periodically** with the specified `TimeDuration` interval. + * Set the primary key (e.g., to `0` if using `[AutoInc]`) and any other argument fields. + * Insert this instance into the schedule table using `ctx.Db.your_schedule_table_name.Insert(...)`. + +Managing timers with a scheduled table is as simple as inserting or deleting rows. This makes scheduling transactional in SpacetimeDB. If a reducer A schedules B but then throws an exception, B will not be scheduled. + +```csharp +using SpacetimeDB; +using System; + +public static partial class Module +{ + // 1. Define the table with scheduling information, linking to `SendMessage` reducer. + // Specifies that the `ScheduledAt` field holds the schedule info. + [Table(Name = "send_message_schedule", Scheduled = nameof(SendMessage), ScheduledAt = nameof(ScheduledAt))] + public partial struct SendMessageSchedule + { + // Mandatory fields: + [PrimaryKey] + [AutoInc] + public ulong Id; // Identifier for the scheduled call + + public ScheduleAt ScheduledAt; // Holds the schedule timing + + // Custom fields (arguments for the reducer): + public string Message; + } + + // 2. Define the scheduled reducer. + // It takes the schedule table struct as its second argument. + [Reducer] + public static void SendMessage(ReducerContext ctx, SendMessageSchedule scheduleArgs) + { + // Security check! + if (!ctx.Sender.Equals(ctx.Identity)) + { + throw new Exception("Reducer SendMessage may not be invoked by clients, only via scheduling."); + } + + Log.Info($"Scheduled SendMessage: {scheduleArgs.Message}"); + // ... perform action with scheduleArgs.Message ... + } + + // 3. Example of scheduling reducers (e.g., in Init) + [Reducer(ReducerKind.Init)] + public static void Init(ReducerContext ctx) + { + // Avoid rescheduling if Init runs again + if (ctx.Db.send_message_schedule.Count > 0) { + return; + } + + var tenSeconds = new TimeDuration { Microseconds = 10_000_000 }; + var futureTimestamp = ctx.Timestamp + tenSeconds; + + // Schedule a one-off message + ctx.Db.send_message_schedule.Insert(new SendMessageSchedule + { + Id = 0, // Let AutoInc assign ID + // Use ScheduleAt.Time for one-off execution at a specific Timestamp + ScheduledAt = new ScheduleAt.Time(futureTimestamp), + Message = "I'm a bot sending a message one time!" + }); + Log.Info("Scheduled one-off message."); + + // Schedule a periodic message (every 10 seconds) + ctx.Db.send_message_schedule.Insert(new SendMessageSchedule + { + Id = 0, // Let AutoInc assign ID + // Use ScheduleAt.Interval for periodic execution with a TimeDuration + ScheduledAt = new ScheduleAt.Interval(tenSeconds), + Message = "I'm a bot sending a message every 10 seconds!" + }); + Log.Info("Scheduled periodic message."); + } +} +``` + +##### Scheduled Reducer Details + +* **Best-Effort Scheduling:** Scheduled reducers are called on a best-effort basis and may be slightly delayed in their execution when a database is under heavy load. + +* **Restricting Access (Security):** Scheduled reducers are normal reducers and *can* still be called directly by clients. If a scheduled reducer should *only* be called by the scheduler, it is crucial to begin the reducer with a check comparing the caller's identity (`ctx.Sender`) to the module's own identity (`ctx.Identity`). + ```csharp + [Reducer] // Assuming linked via [Table(Scheduled=...)] + public static void MyScheduledTask(ReducerContext ctx, MyScheduleArgs args) + { + if (!ctx.Sender.Equals(ctx.Identity)) + { + throw new Exception("Reducer MyScheduledTask may not be invoked by clients, only via scheduling."); + } + // ... Reducer body proceeds only if called by scheduler ... + Log.Info("Executing scheduled task..."); + } + // Define MyScheduleArgs table elsewhere with [Table(Scheduled=nameof(MyScheduledTask), ...)] + public partial struct MyScheduleArgs { /* ... fields including ScheduleAt ... */ } + ``` + +:::info Scheduled Reducers and Connections +Scheduled reducer calls originate from the SpacetimeDB scheduler itself, not from an external client connection. Therefore, within a scheduled reducer, `ctx.Sender` will be the module's own identity, and `ctx.ConnectionId` will be `null`. +::: + +##### Error Handling: Exceptions + +Throwing an unhandled exception within a C# reducer will cause the transaction to roll back. +* **Expected Failures:** For predictable errors (e.g., invalid arguments, state violations), explicitly `throw` an `Exception`. The exception message can be observed by the client in the `ReducerEventContext` status. +* **Unexpected Errors:** Unhandled runtime exceptions (e.g., `NullReferenceException`) also cause rollbacks but might provide less informative feedback to the client, potentially just indicating a general failure. + +It's generally good practice to validate input and state early in the reducer and `throw` specific exceptions for handled error conditions. + +### Client SDK (C#) + +This section details how to build native C# client applications (including Unity games) that interact with a SpacetimeDB module. + +#### 1. Project Setup + +* **For .NET Console/Desktop Apps:** Create a new project and add the `SpacetimeDB.ClientSDK` NuGet package: + ```bash + dotnet new console -o my_csharp_client + cd my_csharp_client + dotnet add package SpacetimeDB.ClientSDK + ``` +* **For Unity:** Download the latest `.unitypackage` from the [SpacetimeDB Unity SDK releases](https://github.com/clockworklabs/com.clockworklabs.spacetimedbsdk/releases/latest). In Unity, go to `Assets > Import Package > Custom Package` and import the downloaded file. + +#### 2. Generate Module Bindings + +Client code relies on generated bindings specific to your server module. Use the `spacetime generate` command, pointing it to your server module project: + +```bash +# From your client project directory +mkdir -p module_bindings # Or your preferred output location +spacetime generate --lang csharp \ + --out-dir module_bindings \ + --project-path ../path/to/your/server_module +``` + +Include the generated `.cs` files in your C# project or Unity Assets folder. + +#### 3. Connecting to the Database + +The core type for managing a connection is `SpacetimeDB.Types.DbConnection` (this type name comes from the generated bindings). You configure and establish a connection using a builder pattern. + +* **Builder:** Start with `DbConnection.Builder()`. +* **URI & Name:** Specify the SpacetimeDB instance URI (`.WithUri("http://localhost:3000")`) and the database name or identity (`.WithModuleName("my_database")`). +* **Authentication:** Provide an identity token using `.WithToken(string?)`. The SDK provides a helper `AuthToken.Token` which loads a token from a local file (initialized via `AuthToken.Init(".credentials_filename")`). If `null` or omitted for the first connection, the server issues a new identity and token (retrieved via the `OnConnect` callback). +* **Callbacks:** Register callbacks (as delegates or lambda expressions) for connection lifecycle events: + * `.OnConnect((conn, identity, token) => { ... })`: Runs on successful connection. Often used to save the `token` using `AuthToken.SaveToken(token)`. + * `.OnConnectError((exception) => { ... })`: Runs if connection fails. + * `.OnDisconnect((conn, maybeException) => { ... })`: Runs when the connection closes, either gracefully (`maybeException` is null) or due to an error. +* **Build:** Call `.Build()` to initiate the connection attempt. + +```csharp +using SpacetimeDB; +using SpacetimeDB.Types; +using System; + +public class ClientManager // Example class +{ + const string HOST = "http://localhost:3000"; + const string DB_NAME = "my_database"; // Or your specific DB name/identity + private DbConnection connection; + + public void StartConnecting() + { + // Initialize token storage (e.g., in AppData) + AuthToken.Init(".my_client_creds"); + + connection = DbConnection.Builder() + .WithUri(HOST) + .WithModuleName(DB_NAME) + .WithToken(AuthToken.Token) // Load token if exists + .OnConnect(HandleConnect) + .OnConnectError(HandleConnectError) + .OnDisconnect(HandleDisconnect) + .Build(); + + // Need to call FrameTick regularly - see next section + } + + private void HandleConnect(DbConnection conn, Identity identity, string authToken) + { + Console.WriteLine($"Connected. Identity: {identity}"); + AuthToken.SaveToken(authToken); // Save token for future connections + + // Register other callbacks after connecting + RegisterEventCallbacks(conn); + + // Subscribe to data + SubscribeToTables(conn); + } + + private void HandleConnectError(Exception e) + { + Console.WriteLine($"Connection Error: {e.Message}"); + // Handle error, e.g., retry or exit + } + + private void HandleDisconnect(DbConnection conn, Exception? e) + { + Console.WriteLine($"Disconnected. Reason: {(e == null ? "Requested" : e.Message)}"); + // Handle disconnection + } + + // Placeholder methods - implementations shown in later sections + private void RegisterEventCallbacks(DbConnection conn) { /* ... */ } + private void SubscribeToTables(DbConnection conn) { /* ... */ } +} +``` + +#### 4. Managing the Connection Loop + +Unlike the Rust SDK's `run_threaded` or `run_async`, the C# SDK primarily uses a manual update loop. You **must** call `connection.FrameTick()` regularly (e.g., every frame in Unity's `Update`, or in a loop in a console app) to process incoming messages and trigger callbacks. + +* **`FrameTick()`:** Processes all pending network messages, updates the local cache, and invokes registered callbacks. +* **Threading:** It is generally **not recommended** to call `FrameTick()` on a background thread if your main thread also accesses the connection's data (`connection.Db`), as this can lead to race conditions. Handle computationally intensive logic triggered by callbacks separately if needed. + +```csharp +// Example in a simple console app loop: +public void RunUpdateLoop() +{ + Console.WriteLine("Running update loop..."); + bool isRunning = true; + while(isRunning && connection != null && connection.IsConnected) + { + connection.FrameTick(); // Process messages + + // Check for user input or other app logic... + if (Console.KeyAvailable) { + var key = Console.ReadKey(true).Key; + if (key == ConsoleKey.Escape) isRunning = false; + // Handle other input... + } + + System.Threading.Thread.Sleep(16); // Avoid busy-waiting + } + connection?.Disconnect(); + Console.WriteLine("Update loop stopped."); +} +``` + +#### 5. Subscribing to Data + +Clients receive data by subscribing to SQL queries against the database's public tables. + +* **Builder:** Start with `connection.SubscriptionBuilder()`. +* **Callbacks:** + * `.OnApplied((subCtx) => { ... })`: Runs when the initial data for the subscription arrives. + * `.OnError((errCtx, exception) => { ... })`: Runs if the subscription fails (e.g., invalid SQL). +* **Subscribe:** Call `.Subscribe(new string[] {"SELECT * FROM table_a", "SELECT * FROM table_b WHERE some_col > 10"})` with a list of query strings. This returns a `SubscriptionHandle`. +* **All Tables:** `.SubscribeToAllTables()` is a convenience for simple clients but cannot be easily unsubscribed. +* **Unsubscribing:** Use `handle.Unsubscribe()` or `handle.UnsubscribeThen((subCtx) => { ... })` to stop receiving updates for specific queries. + +```csharp +using SpacetimeDB.Types; // For SubscriptionEventContext, ErrorContext +using System.Linq; + +// In ClientManager or similar class... +private void SubscribeToTables(DbConnection conn) +{ + Console.WriteLine("Subscribing to tables..."); + conn.SubscriptionBuilder() + .OnApplied(OnSubscriptionApplied) + .OnError((errCtx, err) => { + Console.WriteLine($"Subscription failed: {err.Message}"); + }) + // Example: Subscribe to all rows from 'Player' and 'Message' tables + .Subscribe(new string[] { "SELECT * FROM Player", "SELECT * FROM Message" }); +} + +private void OnSubscriptionApplied(SubscriptionEventContext ctx) +{ + Console.WriteLine("Subscription applied! Initial data received."); + // Example: Print initial messages sorted by time + var messages = ctx.Db.Message.Iter().ToList(); + messages.Sort((a, b) => a.Sent.CompareTo(b.Sent)); + foreach (var msg in messages) + { + // PrintMessage(ctx.Db, msg); // Assuming a PrintMessage helper + } +} +``` + +#### 6. Accessing Cached Data & Handling Row Callbacks + +Subscribed data is stored locally in the client cache, accessible via `ctx.Db` (where `ctx` can be a `DbConnection` or any event context like `EventContext`, `SubscriptionEventContext`). + +* **Accessing Tables:** Use `ctx.Db.TableName` (e.g., `ctx.Db.Player`) to get a handle to a table's cache. +* **Iterating:** `tableHandle.Iter()` returns an `IEnumerable` over all cached rows. +* **Filtering/Finding:** Use LINQ methods (`.Where()`, `.FirstOrDefault()`, etc.) on the result of `Iter()`, or use generated index accessors like `tableHandle.FindByPrimaryKeyField(pkValue)` or `tableHandle.FilterByIndexField(value)` for efficient lookups. +* **Row Callbacks:** Register callbacks using C# events to react to changes in the cache: + * `tableHandle.OnInsert += (eventCtx, insertedRow) => { ... };` + * `tableHandle.OnDelete += (eventCtx, deletedRow) => { ... };` + * `tableHandle.OnUpdate += (eventCtx, oldRow, newRow) => { ... };` (Only for tables with a `[PrimaryKey]`) + +```csharp +using SpacetimeDB.Types; // For EventContext, Event, Reducer +using System.Linq; + +// In ClientManager or similar class... +private void RegisterEventCallbacks(DbConnection conn) +{ + conn.Db.Player.OnInsert += HandlePlayerInsert; + conn.Db.Player.OnUpdate += HandlePlayerUpdate; + conn.Db.Message.OnInsert += HandleMessageInsert; + // Remember to unregister callbacks on disconnect/cleanup: -= HandlePlayerInsert; +} + +private void HandlePlayerInsert(EventContext ctx, Player insertedPlayer) +{ + // Only react to updates caused by reducers, not initial subscription load + if (ctx.Event is not Event.SubscribeApplied) + { + Console.WriteLine($"Player joined: {insertedPlayer.Name ?? "Unknown"}"); + } +} + +private void HandlePlayerUpdate(EventContext ctx, Player oldPlayer, Player newPlayer) +{ + if (oldPlayer.Name != newPlayer.Name) + { + Console.WriteLine($"Player renamed: {oldPlayer.Name ?? "??"} -> {newPlayer.Name ?? "??"}"); + } + // ... handle other changes like online status ... +} + +private void HandleMessageInsert(EventContext ctx, Message insertedMessage) +{ + if (ctx.Event is not Event.SubscribeApplied) + { + // Find sender name from cache + var sender = ctx.Db.Player.FindByPlayerId(insertedMessage.Sender); + string senderName = sender?.Name ?? "Unknown"; + Console.WriteLine($"{senderName}: {insertedMessage.Text}"); + } +} +``` + +:::info Handling Initial Data vs. Live Updates in Callbacks +Callbacks like `OnInsert` and `OnUpdate` are triggered for both the initial data received when a subscription is first applied *and* for subsequent live changes caused by reducers. If you need to differentiate (e.g., only react to *new* messages, not the backlog), you can inspect the `ctx.Event` type. For example, checking `if (ctx.Event is not Event.SubscribeApplied) { ... }` ensures the code only runs for events triggered by reducers, not the initial subscription data load. +::: + +#### 7. Invoking Reducers & Handling Reducer Callbacks + +Clients trigger state changes by calling reducers defined in the server module. + +* **Invoking:** Access generated static reducer methods via `SpacetimeDB.Types.Reducer.ReducerName(arg1, arg2, ...)`. +* **Reducer Callbacks:** Register callbacks using C# events to react to the *outcome* of reducer calls: + * `Reducer.OnReducerName += (reducerEventCtx, arg1, ...) => { ... };` + * The `reducerEventCtx.Event` contains: + * `Reducer`: The specific reducer variant record and its arguments. + * `Status`: A tagged union record: `Status.Committed`, `Status.Failed(reason)`, or `Status.OutOfEnergy`. + * `CallerIdentity`, `Timestamp`, etc. + +```csharp +using SpacetimeDB.Types; + +// In ClientManager or similar class, likely where HandleConnect is... +private void RegisterEventCallbacks(DbConnection conn) // Updated registration point +{ + // Table callbacks (from previous section) + conn.Db.Player.OnInsert += HandlePlayerInsert; + conn.Db.Player.OnUpdate += HandlePlayerUpdate; + conn.Db.Message.OnInsert += HandleMessageInsert; + + // Reducer callbacks + Reducer.OnSetName += HandleSetNameResult; + Reducer.OnSendMessage += HandleSendMessageResult; +} + +private void HandleSetNameResult(ReducerEventContext ctx, string name) +{ + // Check if the status is Failed + if (ctx.Event.Status is Status.Failed failedStatus) + { + // Check if the failure was for *our* call + if (ctx.Event.CallerIdentity == ctx.Identity) { + Console.WriteLine($"Error setting name to '{name}': {failedStatus.Reason}"); + } + } +} + +private void HandleSendMessageResult(ReducerEventContext ctx, string text) +{ + if (ctx.Event.Status is Status.Failed failedStatus) + { + if (ctx.Event.CallerIdentity == ctx.Identity) { // Our call failed + Console.WriteLine($"[Error] Failed to send message '{text}': {failedStatus.Reason}"); + } + } +} + +// Example of calling a reducer (e.g., from user input handler) +public void SendChatMessage(string message) +{ + if (!string.IsNullOrEmpty(message)) + { + Reducer.SendMessage(message); // Static method call + } +} + +``` + +### Client SDK (TypeScript) + +This section details how to build TypeScript/JavaScript client applications (for web browsers or Node.js) that interact with a SpacetimeDB module, using a framework-agnostic approach. + +#### 1. Project Setup + +Install the SDK package into your project: + +```bash +# Using npm +npm install @clockworklabs/spacetimedb-sdk + +# Or using yarn +yarn add @clockworklabs/spacetimedb-sdk +``` + +#### 2. Generate Module Bindings + +Generate the module-specific bindings using the `spacetime generate` command: + +```bash +mkdir -p src/module_bindings +spacetime generate --lang typescript \ + --out-dir src/module_bindings \ + --project-path ../path/to/your/server_module +``` + +Import the necessary generated types and SDK components: + +```typescript +// Import SDK core types +import { Identity, Status } from "@clockworklabs/spacetimedb-sdk"; +// Import generated connection class, event contexts, and table types +import { DbConnection, EventContext, ReducerEventContext, Message, User } from "./module_bindings"; +// Reducer functions are accessed via conn.reducers +``` + +#### 3. Connecting to the Database + +Use the generated `DbConnection` class and its builder pattern to establish a connection. + +```typescript +import { DbConnection, EventContext, ReducerEventContext, Message, User } from './module_bindings'; +import { Identity, Status } from '@clockworklabs/spacetimedb-sdk'; + +const HOST = "ws://localhost:3000"; +const DB_NAME = "quickstart-chat"; +const CREDS_KEY = "auth_token"; + +class ChatClient { + public conn: DbConnection | null = null; + public identity: Identity | null = null; + public connected: boolean = false; + // Client-side cache for user lookups + private userMap: Map = new Map(); + + constructor() { + // Bind methods to ensure `this` is correct in callbacks + this.handleConnect = this.handleConnect.bind(this); + this.handleDisconnect = this.handleDisconnect.bind(this); + this.handleConnectError = this.handleConnectError.bind(this); + this.registerTableCallbacks = this.registerTableCallbacks.bind(this); + this.registerReducerCallbacks = this.registerReducerCallbacks.bind(this); + this.subscribeToTables = this.subscribeToTables.bind(this); + this.handleMessageInsert = this.handleMessageInsert.bind(this); + this.handleUserInsert = this.handleUserInsert.bind(this); + this.handleUserUpdate = this.handleUserUpdate.bind(this); + this.handleUserDelete = this.handleUserDelete.bind(this); + this.handleSendMessageResult = this.handleSendMessageResult.bind(this); + } + + public connect() { + console.log("Attempting to connect..."); + const token = localStorage.getItem(CREDS_KEY) || null; + + const connectionInstance = DbConnection.builder() + .withUri(HOST) + .withModuleName(DB_NAME) + .withToken(token) + .onConnect(this.handleConnect) + .onDisconnect(this.handleDisconnect) + .onConnectError(this.handleConnectError) + .build(); + + this.conn = connectionInstance; + } + + private handleConnect(conn: DbConnection, identity: Identity, token: string) { + this.identity = identity; + this.connected = true; + localStorage.setItem(CREDS_KEY, token); // Save new/refreshed token + console.log('Connected with identity:', identity.toHexString()); + + // Register callbacks and subscribe now that we are connected + this.registerTableCallbacks(); + this.registerReducerCallbacks(); + this.subscribeToTables(); + } + + private handleDisconnect() { + console.log('Disconnected'); + this.connected = false; + this.identity = null; + this.conn = null; + this.userMap.clear(); // Clear local cache on disconnect + } + + private handleConnectError(err: Error) { + console.error('Connection Error:', err); + localStorage.removeItem(CREDS_KEY); // Clear potentially invalid token + this.conn = null; // Ensure connection is marked as unusable + } + + // Placeholder implementations for callback registration and subscription + private registerTableCallbacks() { /* See Section 6 */ } + private registerReducerCallbacks() { /* See Section 7 */ } + private subscribeToTables() { /* See Section 5 */ } + + // Placeholder implementations for table callbacks + private handleMessageInsert(ctx: EventContext | undefined, message: Message) { /* See Section 6 */ } + private handleUserInsert(ctx: EventContext | undefined, user: User) { /* See Section 6 */ } + private handleUserUpdate(ctx: EventContext | undefined, oldUser: User, newUser: User) { /* See Section 6 */ } + private handleUserDelete(ctx: EventContext, user: User) { /* See Section 6 */ } + + // Placeholder for reducer callback + private handleSendMessageResult(ctx: ReducerEventContext, messageText: string) { /* See Section 7 */ } + + // Public methods for interaction + public sendChatMessage(message: string) { /* See Section 7 */ } + public setPlayerName(newName: string) { /* See Section 7 */ } +} + +// Example Usage: +// const client = new ChatClient(); +// client.connect(); +``` + +#### 4. Managing the Connection Loop + +The TypeScript SDK is event-driven. No manual `FrameTick()` is needed. + +#### 5. Subscribing to Data + +Subscribe to SQL queries to receive data. + +```typescript +// Part of the ChatClient class +private subscribeToTables() { + if (!this.conn) return; + + const queries = ["SELECT * FROM message", "SELECT * FROM user"]; + + console.log("Subscribing..."); + this.conn + .subscriptionBuilder() + .onApplied(() => { + console.log(`Subscription applied for: ${queries}`); + // Initial cache is now populated, process initial data if needed + this.processInitialCache(); + }) + .onError((error: Error) => { + console.error(`Subscription error:`, error); + }) + .subscribe(queries); +} + +private processInitialCache() { + if (!this.conn) return; + console.log("Processing initial cache..."); + // Populate userMap from initial cache + this.userMap.clear(); + for (const user of this.conn.db.User.iter()) { + this.handleUserInsert(undefined, user); // Pass undefined context for initial load + } + // Process initial messages, e.g., sort and display + const initialMessages = Array.from(this.conn.db.Message.iter()); + initialMessages.sort((a, b) => a.sent.getTime() - b.sent.getTime()); + for (const message of initialMessages) { + this.handleMessageInsert(undefined, message); // Pass undefined context + } +} +``` + +#### 6. Accessing Cached Data & Handling Row Callbacks + +Maintain your own collections (e.g., `Map`) updated via table callbacks for efficient lookups. + +```typescript +// Part of the ChatClient class +private registerTableCallbacks() { + if (!this.conn) return; + + this.conn.db.Message.onInsert(this.handleMessageInsert); + + // User table callbacks update the local userMap + this.conn.db.User.onInsert(this.handleUserInsert); + this.conn.db.User.onUpdate(this.handleUserUpdate); + this.conn.db.User.onDelete(this.handleUserDelete); + + // Note: In a real app, you might return a cleanup function + // to unregister these if the ChatClient is destroyed. + // e.g., return () => { this.conn?.db.Message.removeOnInsert(...) }; +} + +private handleMessageInsert(ctx: EventContext | undefined, message: Message) { + const identityStr = message.sender.toHexString(); + // Look up sender in our local map + const sender = this.userMap.get(identityStr); + const senderName = sender?.name ?? identityStr.substring(0, 8); + + if (ctx) { // Live update + console.log(`LIVE MSG: ${senderName}: ${message.text}`); + // TODO: Update UI (e.g., add to message list) + } else { // Initial load (handled in processInitialCache) + // console.log(`Initial MSG loaded: ${message.text} from ${senderName}`); + } +} + +private handleUserInsert(ctx: EventContext | undefined, user: User) { + const identityStr = user.identity.toHexString(); + this.userMap.set(identityStr, user); + const name = user.name ?? identityStr.substring(0, 8); + if (ctx) { // Live update + if (user.online) console.log(`${name} connected.`); + } else { // Initial load + // console.log(`Loaded user: ${name} (Online: ${user.online})`); + } + // TODO: Update UI (e.g., user list) +} + +private handleUserUpdate(ctx: EventContext | undefined, oldUser: User, newUser: User) { + const oldIdentityStr = oldUser.identity.toHexString(); + const newIdentityStr = newUser.identity.toHexString(); + if(oldIdentityStr !== newIdentityStr) { + this.userMap.delete(oldIdentityStr); + } + this.userMap.set(newIdentityStr, newUser); + + const name = newUser.name ?? newIdentityStr.substring(0, 8); + if (ctx) { // Live update + if (!oldUser.online && newUser.online) console.log(`${name} connected.`); + else if (oldUser.online && !newUser.online) console.log(`${name} disconnected.`); + else if (oldUser.name !== newUser.name) console.log(`Rename: ${oldUser.name ?? '...'} -> ${name}.`); + } + // TODO: Update UI (e.g., user list, messages from this user) +} + +private handleUserDelete(ctx: EventContext, user: User) { + const identityStr = user.identity.toHexString(); + const name = user.name ?? identityStr.substring(0, 8); + this.userMap.delete(identityStr); + console.log(`${name} left/deleted.`); + // TODO: Update UI +} +``` + +:::info Handling Initial Data vs. Live Updates in Callbacks +In TypeScript, the first argument (`ctx: EventContext | undefined`) to row callbacks indicates the cause. If `ctx` is defined, it's a live update. If `undefined`, it's part of the initial subscription load. +::: + +#### 7. Invoking Reducers & Handling Reducer Callbacks + +Call reducers via `conn.reducers`. Register callbacks via `conn.reducers.onReducerName(...)` to observe outcomes. + +```typescript +// Part of the ChatClient class +private registerReducerCallbacks() { + if (!this.conn) return; + + this.conn.reducers.onSendMessage(this.handleSendMessageResult); + // Register other reducer callbacks if needed + // this.conn.reducers.onSetName(this.handleSetNameResult); + + // Note: Consider returning a cleanup function to unregister +} + +private handleSendMessageResult(ctx: ReducerEventContext, messageText: string) { + const wasOurCall = ctx.reducerEvent.callerIdentity.isEqual(this.identity); + if (!wasOurCall) return; // Only care about our own calls here + + if (ctx.reducerEvent.status === Status.Committed) { + console.log(`Our message "${messageText}" sent successfully.`); + } else if (ctx.reducerEvent.status.isFailed()) { + console.error(`Failed to send "${messageText}": ${ctx.reducerEvent.status.getFailedMessage()}`); + } +} + +// Public methods to be called from application logic +public sendChatMessage(message: string) { + if (this.conn && this.connected && message.trim()) { + this.conn.reducers.sendMessage(message); + } +} + +public setPlayerName(newName: string) { + if (this.conn && this.connected && newName.trim()) { + this.conn.reducers.setName(newName); + } +} +``` + +## SpacetimeDB Subscription Semantics + +This document describes the subscription semantics maintained by the SpacetimeDB host over WebSocket connections. These semantics outline message ordering guarantees, subscription handling, transaction updates, and client cache consistency. + +### WebSocket Communication Channels + +A single WebSocket connection between a client and the SpacetimeDB host consists of two distinct message channels: + +- **Client → Server:** Sends requests such as reducer invocations and subscription queries. +- **Server → Client:** Sends responses to client requests and database transaction updates. + +#### Ordering Guarantees + +The server maintains the following guarantees: + +1. **Sequential Response Ordering:** + - Responses to client requests are always sent back in the same order the requests were received. If request A precedes request B, the response to A will always precede the response to B, even if A takes longer to process. + +2. **Atomic Transaction Updates:** + - Each database transaction (e.g., reducer invocation, INSERT, UPDATE, DELETE queries) generates exactly zero or one update message sent to clients. These updates are atomic and reflect the exact order of committed transactions. + +3. **Atomic Subscription Initialization:** + - When subscriptions are established, clients receive exactly one response containing all initially matching rows from a consistent database state snapshot taken between two transactions. + - The state snapshot reflects a committed database state that includes all previous transaction updates received and excludes all future transaction updates. + +### Subscription Workflow + +When invoking `SubscriptionBuilder::subscribe(QUERIES)` from the client SDK: + +1. **Client SDK → Host:** + - Sends a `Subscribe` message containing the specified QUERIES. + +2. **Host Processing:** + - Captures a snapshot of the committed database state. + - Evaluates QUERIES against this snapshot to determine matching rows. + +3. **Host → Client SDK:** + - Sends a `SubscribeApplied` message containing the matching rows. + +4. **Client SDK Processing:** + - Receives and processes the message. + - Locks the client cache and inserts all rows atomically. + - Invokes relevant callbacks: + - `on_insert` callback for each row. + - `on_applied` callback for the subscription. + +> **Note:** No relative ordering guarantees are made regarding the invocation order of these callbacks. + +### Transaction Update Workflow + +Upon committing a database transaction: + +1. **Host Evaluates State Delta:** + - Calculates the state delta (inserts and deletes) resulting from the transaction. + +2. **Host Evaluates Queries:** + - Computes the incremental query updates relevant to subscribed clients. + +3. **Host → Client SDK:** + - Sends a `TransactionUpdate` message if relevant updates exist, containing affected rows and transaction metadata. + +4. **Client SDK Processing:** + - Receives and processes the message. + - Locks the client cache, applying deletions and insertions atomically. + - Invokes relevant callbacks: + - `on_insert`, `on_delete`, `on_update`, and `on_reducer` as necessary. + +> **Note:** +- No relative ordering guarantees are made regarding the invocation order of these callbacks. +- Delete and insert operations within a `TransactionUpdate` have no internal order guarantees and are grouped into operation maps. + +#### Client Updates and Compute Processing + +Client SDKs must explicitly request processing time (e.g., `conn.FrameTick()` in C# or `conn.run_threaded()` in Rust) to receive and process messages. Until such a processing call is made, messages remain queued on the server-to-client channel. + +### Multiple Subscription Sets + +If multiple subscription sets are active, updates across these sets are bundled together into a single `TransactionUpdate` message. + +### Client Cache Guarantees + +- The client cache always maintains a consistent and correct subset of the committed database state. +- Callback functions invoked due to events have guaranteed visibility into a fully updated cache state. +- Reads from the client cache are effectively free as they access locally cached data. +- During callback execution, the client cache accurately reflects the database state immediately following the event-triggering transaction. + +#### Pending Callbacks and Cache Consistency + +Callbacks (`pendingCallbacks`) are queued and deferred until the cache updates (inserts/deletes) from a transaction are fully applied. This ensures all callbacks see the fully consistent state of the cache, preventing callbacks from observing an inconsistent intermediate state. From 3c9529bfe0c91ac178ee636eff0dfe7830519ee7 Mon Sep 17 00:00:00 2001 From: 8Times Date: Mon, 14 Apr 2025 17:31:16 +0200 Subject: [PATCH 148/164] Typo fix quickstart.md (#294) "SpacetimDB" -> "SpacetimeDB" --- docs/sdks/rust/quickstart.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sdks/rust/quickstart.md b/docs/sdks/rust/quickstart.md index 888782e6..ea1080ac 100644 --- a/docs/sdks/rust/quickstart.md +++ b/docs/sdks/rust/quickstart.md @@ -1,6 +1,6 @@ # Rust Client SDK Quick Start -In this guide we'll show you how to get up and running with a simple SpacetimDB app with a client written in Rust. +In this guide we'll show you how to get up and running with a simple SpacetimeDB app with a client written in Rust. We'll implement a command-line client for the module created in our Rust or C# Module Quickstart guides. Make sure you follow one of these guides before you start on this one. From 77985fc762bbbbb44551b773bcb07ce294f5de72 Mon Sep 17 00:00:00 2001 From: torjusik Date: Mon, 14 Apr 2025 17:32:52 +0200 Subject: [PATCH 149/164] Update quickstart.md (#295) fixed typo: "Next add let's add" -> "Let's add" --- docs/sdks/typescript/quickstart.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sdks/typescript/quickstart.md b/docs/sdks/typescript/quickstart.md index df4f4d59..e567dc3d 100644 --- a/docs/sdks/typescript/quickstart.md +++ b/docs/sdks/typescript/quickstart.md @@ -546,7 +546,7 @@ These custom React hooks update the React state anytime a row in our tables chan > In principle, it should be possible to automatically generate these hooks based on your module's schema, or use [`useSyncExternalStore`](https://react.dev/reference/react/useSyncExternalStore). For simplicity, rather than creating them mechanically, we're just going to do it manually. -Next add let's add these hooks to our `App` component just below our connection setup: +Let's add these hooks to our `App` component just below our connection setup: ```tsx const messages = useMessages(conn); From 4f672f523ba63a1e9a7772f71ff7b29877e3691f Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Mon, 14 Apr 2025 17:30:41 -0700 Subject: [PATCH 150/164] Reference docs for row level security (#291) --- docs/nav.js | 2 + docs/rls/index.md | 303 ++++++++++++++++++++++++++++++++++++++++++++++ nav.ts | 3 + 3 files changed, 308 insertions(+) create mode 100644 docs/rls/index.md diff --git a/docs/nav.js b/docs/nav.js index e7d0b944..9e5af152 100644 --- a/docs/nav.js +++ b/docs/nav.js @@ -39,6 +39,8 @@ const nav = { page('SQL Reference', 'sql', 'sql/index.md'), section('Subscriptions'), page('Subscription Reference', 'subscriptions', 'subscriptions/index.md'), + section('Row Level Security'), + page('Row Level Security', 'rls', 'rls/index.md'), section('How To'), page('Incremental Migrations', 'how-to/incremental-migrations', 'how-to/incremental-migrations.md'), section('HTTP API'), diff --git a/docs/rls/index.md b/docs/rls/index.md new file mode 100644 index 00000000..a357f96b --- /dev/null +++ b/docs/rls/index.md @@ -0,0 +1,303 @@ +# Row Level Security (RLS) + +Row Level Security (RLS) allows module authors to restrict which rows of a public table each client can access. +These access rules are expressed in SQL and evaluated automatically for queries and subscriptions. + +## Enabling RLS + +RLS is currently **experimental** and must be explicitly enabled in your module. + +:::server-rust +To enable RLS, activate the `unstable` feature in your project's `Cargo.toml`: + +```toml +spacetimedb = { version = "...", features = ["unstable"] } +``` +::: +:::server-csharp +To enable RLS, include the following preprocessor directive at the top of your module files: + +```cs +#pragma warning disable STDB_UNSTABLE +``` +::: + +## How It Works + +:::server-rust +RLS rules are expressed in SQL and declared as constants of type `Filter`. + +```rust +use spacetimedb::{client_visibility_filter, Filter}; + +/// A client can only see their account +#[client_visibility_filter] +const ACCOUNT_FILTER: Filter = Filter::Sql( + "SELECT * FROM account WHERE identity = :sender" +); +``` +::: +:::server-csharp +RLS rules are expressed in SQL and declared as public static readonly fields of type `Filter`. + +```cs +using SpacetimeDB; + +#pragma warning disable STDB_UNSTABLE + +public partial class Module +{ + /// + /// A client can only see their account. + /// + [SpacetimeDB.ClientVisibilityFilter] + public static readonly Filter ACCOUNT_FILTER = new Filter.Sql( + "SELECT * FROM account WHERE identity = :sender" + ); +} +``` +::: + +A module will fail to publish if any of its RLS rules are invalid or malformed. + +### `:sender` + +You can use the special `:sender` parameter in your rules for user specific access control. +This parameter is automatically bound to the requesting client's [Identity]. + +Note that module owners have unrestricted access to all tables regardless of RLS. + + +[Identity]: /docs/index.md#identity + +### Semantic Constraints + +RLS rules are similar to subscriptions in that logically they act as filters on a particular table. +Also like subscriptions, arbitrary column projections are **not** allowed. +Joins **are** allowed, but each rule must return rows from one and only one table. + +### Multiple Rules Per Table + +Multiple rules may be declared for the same table and will be evaluated as a logical `OR`. +This means clients will be able to see to any row that matches at least one of the rules. + +#### Example + +:::server-rust +```rust +use spacetimedb::{client_visibility_filter, Filter}; + +/// A client can only see their account +#[client_visibility_filter] +const ACCOUNT_FILTER: Filter = Filter::Sql( + "SELECT * FROM account WHERE identity = :sender" +); + +/// An admin can see all accounts +#[client_visibility_filter] +const ACCOUNT_FILTER_FOR_ADMINS: Filter = Filter::Sql( + "SELECT account.* FROM account JOIN admin WHERE admin.identity = :sender" +); +``` +::: +:::server-csharp +```cs +using SpacetimeDB; + +#pragma warning disable STDB_UNSTABLE + +public partial class Module +{ + /// + /// A client can only see their account. + /// + [SpacetimeDB.ClientVisibilityFilter] + public static readonly Filter ACCOUNT_FILTER = new Filter.Sql( + "SELECT * FROM account WHERE identity = :sender" + ); + + /// + /// An admin can see all accounts. + /// + [SpacetimeDB.ClientVisibilityFilter] + public static readonly Filter ACCOUNT_FILTER_FOR_ADMINS = new Filter.Sql( + "SELECT account.* FROM account JOIN admin WHERE admin.identity = :sender" + ); +} +``` +::: + +### Recursive Application + +RLS rules can reference other tables with RLS rules, and they will be applied recursively. +This ensures that data is never leaked through indirect access patterns. + +#### Example + +:::server-rust +```rust +use spacetimedb::{client_visibility_filter, Filter}; + +/// A client can only see their account +#[client_visibility_filter] +const ACCOUNT_FILTER: Filter = Filter::Sql( + "SELECT * FROM account WHERE identity = :sender" +); + +/// An admin can see all accounts +#[client_visibility_filter] +const ACCOUNT_FILTER_FOR_ADMINS: Filter = Filter::Sql( + "SELECT account.* FROM account JOIN admin WHERE admin.identity = :sender" +); + +/// Explicitly filtering by client identity in this rule is not necessary, +/// since the above RLS rules on `account` will be applied automatically. +/// Hence a client can only see their player, but an admin can see all players. +#[client_visibility_filter] +const PLAYER_FILTER: Filter = Filter::Sql( + "SELECT p.* FROM account a JOIN player p ON a.id = p.id" +); +``` +::: +:::server-csharp +```cs +using SpacetimeDB; + +public partial class Module +{ + /// + /// A client can only see their account. + /// + [SpacetimeDB.ClientVisibilityFilter] + public static readonly Filter ACCOUNT_FILTER = new Filter.Sql( + "SELECT * FROM account WHERE identity = :sender" + ); + + /// + /// An admin can see all accounts. + /// + [SpacetimeDB.ClientVisibilityFilter] + public static readonly Filter ACCOUNT_FILTER_FOR_ADMINS = new Filter.Sql( + "SELECT account.* FROM account JOIN admin WHERE admin.identity = :sender" + ); + + /// + /// Explicitly filtering by client identity in this rule is not necessary, + /// since the above RLS rules on `account` will be applied automatically. + /// Hence a client can only see their player, but an admin can see all players. + /// + [SpacetimeDB.ClientVisibilityFilter] + public static readonly Filter PLAYER_FILTER = new Filter.Sql( + "SELECT p.* FROM account a JOIN player p ON a.id = p.id" + ); +} +``` +::: + +And while self-joins are allowed, in general RLS rules cannot be self-referential, +as this would result in infinite recursion. + +#### Example: Self-Join + +:::server-rust +```rust +use spacetimedb::{client_visibility_filter, Filter}; + +/// A client can only see players on their same level +#[client_visibility_filter] +const PLAYER_FILTER: Filter = Filter::Sql(" + SELECT q.* + FROM account a + JOIN player p ON u.id = p.id + JOIN player q on p.level = q.level + WHERE a.identity = :sender +"); +``` +::: +:::server-csharp +```cs +using SpacetimeDB; + +public partial class Module +{ + /// + /// A client can only see players on their same level. + /// + [SpacetimeDB.ClientVisibilityFilter] + public static readonly Filter PLAYER_FILTER = new Filter.Sql(@" + SELECT q.* + FROM account a + JOIN player p ON u.id = p.id + JOIN player q on p.level = q.level + WHERE a.identity = :sender + "); +} +``` +::: + +#### Example: Recursive Rules + +This module will fail to publish because each rule depends on the other one. + +:::server-rust +```rust +use spacetimedb::{client_visibility_filter, Filter}; + +/// An account must have a corresponding player +#[client_visibility_filter] +const ACCOUNT_FILTER: Filter = Filter::Sql( + "SELECT a.* FROM account a JOIN player p ON a.id = p.id WHERE a.identity = :sender" +); + +/// A player must have a corresponding account +#[client_visibility_filter] +const PLAYER_FILTER: Filter = Filter::Sql( + "SELECT p.* FROM account a JOIN player p ON a.id = p.id WHERE a.identity = :sender" +); +``` +::: +:::server-csharp +```cs +using SpacetimeDB; + +public partial class Module +{ + /// + /// An account must have a corresponding player. + /// + [SpacetimeDB.ClientVisibilityFilter] + public static readonly Filter ACCOUNT_FILTER = new Filter.Sql( + "SELECT a.* FROM account a JOIN player p ON a.id = p.id WHERE a.identity = :sender" + ); + + /// + /// A player must have a corresponding account. + /// + [SpacetimeDB.ClientVisibilityFilter] + public static readonly Filter ACCOUNT_FILTER = new Filter.Sql( + "SELECT p.* FROM account a JOIN player p ON a.id = p.id WHERE a.identity = :sender" + ); +} +``` +::: + +## Usage in Subscriptions + +RLS rules automatically apply to subscriptions so that if a client subscribes to a table with RLS filters, +the subscription will only return rows that the client is allowed to see. + +While the contraints and limitations outlined in the [reference docs] do not apply to RLS rules, +they do apply to the subscriptions that use them. +For example, it is valid for an RLS rule to have more joins than are supported by subscriptions. +However a client will not be able to subscribe to the table for which that rule is defined. + + +[reference docs]: /docs/sql/index.md#subscriptions + +## Best Practices + +1. Use `:sender` for client specific filtering. +2. Follow the [SQL best practices] for optimizing your RLS rules. + + +[SQL best practices]: /docs/sql/index.md#best-practices-for-performance-and-scalability diff --git a/nav.ts b/nav.ts index fb968928..4ffa931e 100644 --- a/nav.ts +++ b/nav.ts @@ -90,6 +90,9 @@ const nav: Nav = { section('Subscriptions'), page('Subscription Reference', 'subscriptions', 'subscriptions/index.md'), + section('Row Level Security'), + page('Row Level Security', 'rls', 'rls/index.md'), + section('How To'), page('Incremental Migrations', 'how-to/incremental-migrations', 'how-to/incremental-migrations.md'), From bc95b746108ec64d46ccc8050171904dda23be86 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Mon, 14 Apr 2025 20:33:08 -0400 Subject: [PATCH 151/164] Add convention for table names to style guide (#300) --- STYLE.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/STYLE.md b/STYLE.md index 4fe1f676..d8b8be85 100644 --- a/STYLE.md +++ b/STYLE.md @@ -81,6 +81,10 @@ When describing a chain of accesses through menus and submenus, use the **->** t It's generally not necessary or desirable to tell users where to look for the top-level menu. You may be tempted to write something like, "Open the **File** menu in the upper left, and navigate **File -> Export as -> Export as PDF**." Do not include "in the upper left" unless you are absolutely confident that the menu will be located there on any combination of OS, version, desktop environment, window manager, theming configuration &c. Even within a single system, UI designers are known to move graphical elements around during updates, making statements like "upper left" obsolete and stale. We can generally trust our readers to be familiar with their own systems and the software they use, and none of our documents involve introducing readers to new GUI software. (E.g. the Unity tutorial is targeted at introducing SpacetimeDB to people who already know Unity.) "Open the **File** menu and navigate **File -> Export as -> Export as PDF**" is sufficient. +### Table names + +Table names should be in the singular. `user` rather than `users`, `player` rather than `players`, &c. This applies both to SQL code snippets and to modules. In module code, table names should obey the language's casing for method names: in Rust, `snake_case`, and in C#, `PascalCase`. A table which has a row for each player, containing their most recent login time, might be named `player_last_login_time` in a Rust module, or `PlayerLastLoginTime` in a C# module. + ## Key vocabulary There are a small number of key terms that we need to use consistently throughout the documentation. From 13587cd391533b41e2c906de3ad2a0299662a15f Mon Sep 17 00:00:00 2001 From: Michael Nadeau <48649516+waaverecords@users.noreply.github.com> Date: Tue, 15 Apr 2025 12:03:02 -0400 Subject: [PATCH 152/164] Fix typo in unity/part-2.md (#298) --- docs/unity/part-2.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/unity/part-2.md b/docs/unity/part-2.md index d1410cfe..84e262b5 100644 --- a/docs/unity/part-2.md +++ b/docs/unity/part-2.md @@ -392,7 +392,7 @@ pub fn connect(ctx: &ReducerContext) -> Result<(), String> { } ``` -The `client_connected` argument to the `spacetimedb::reducer` macro indicates to SpacetimeDB that this is a special reducer. This reducer is only every called by SpacetimeDB itself when a client connects to your module. +The `client_connected` argument to the `spacetimedb::reducer` macro indicates to SpacetimeDB that this is a special reducer. This reducer is only ever called by SpacetimeDB itself when a client connects to your module. > SpacetimeDB gives you the ability to define custom reducers that automatically trigger when certain events occur. > @@ -411,7 +411,7 @@ public static void Connect(ReducerContext ctx) } ``` -The `ReducerKind.ClientConnected` argument to the `SpacetimeDB.Reducer` attribute indicates to SpacetimeDB that this is a special reducer. This reducer is only every called by SpacetimeDB itself when a client connects to your module. +The `ReducerKind.ClientConnected` argument to the `SpacetimeDB.Reducer` attribute indicates to SpacetimeDB that this is a special reducer. This reducer is only ever called by SpacetimeDB itself when a client connects to your module. > SpacetimeDB gives you the ability to define custom reducers that automatically trigger when certain events occur. > From 9831f935ca2b6a7cb87ffc9ca460fcca1391a43f Mon Sep 17 00:00:00 2001 From: Zeke Foppa <196249+bfops@users.noreply.github.com> Date: Tue, 15 Apr 2025 13:30:40 -0700 Subject: [PATCH 153/164] Deploying: Maincloud: add section for connecting via SDKs (#311) * [bfops/maincloud-sdks]: wIP * [bfops/maincloud-sdks]: WIP * [bfops/maincloud-sdks]: do thing * [bfops/maincloud-sdks]: review --------- Co-authored-by: Zeke Foppa --- docs/deploying/maincloud.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/deploying/maincloud.md b/docs/deploying/maincloud.md index ea14ebbd..8baff4cc 100644 --- a/docs/deploying/maincloud.md +++ b/docs/deploying/maincloud.md @@ -28,3 +28,24 @@ spacetime login --- With SpacetimeDB Maincloud, you benefit from automatic scaling, robust security, and the convenience of not having to manage the hosting environment. + +# Connect from Client SDKs +To connect to your deployed module in your client code, use the host url of `https://maincloud.spacetimedb.com`: + +## Rust +```rust +DbConnection::builder() + .with_uri("https://maincloud.spacetimedb.com") +``` + +## C# +```csharp +DbConnection.Builder() + .WithUri("https://maincloud.spacetimedb.com") +``` + +## TypeScript +```ts + DbConnection.builder() + .withUri('https://maincloud.spacetimedb.com') +``` From e6da60fe12c9ec9a2d4dd11b68af7afd99580775 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Wed, 16 Apr 2025 09:58:40 -0400 Subject: [PATCH 154/164] Add docs for indexable types to C# (#285) Includes references to changes made by https://github.com/clockworklabs/SpacetimeDB/pull/2506 , which as of writing has not merged. We should not push this commit live until after that PR is released. --- docs/modules/c-sharp/index.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/modules/c-sharp/index.md b/docs/modules/c-sharp/index.md index fc2acc95..bc6ef893 100644 --- a/docs/modules/c-sharp/index.md +++ b/docs/modules/c-sharp/index.md @@ -611,6 +611,17 @@ public partial struct AcademicPaper { Any table supports getting an [`Index`](#class-index) using `ctx.Db.{table}.{index}`. For example, `ctx.Db.academic_paper.TitleAndDate` or `ctx.Db.academic_paper.Venue`. +## Indexable Types + +SpacetimeDB supports only a restricted set of types as index keys: + +- Signed and unsigned integers of various widths. +- `bool`. +- `string`. +- [`Identity`](#struct-identity). +- [`ConnectionId`](#struct-connectionid). +- `enum`s annotated with [`SpacetimeDB.Type`](#attribute-spacetimedbtype). + ## Class `Index` ```csharp @@ -1391,4 +1402,4 @@ Stored in reducer-scheduling tables as a column. [`DateTimeOffset`]: https://learn.microsoft.com/en-us/dotnet/api/system.datetimeoffset?view=net-9.0 [`TimeSpan`]: https://learn.microsoft.com/en-us/dotnet/api/system.timespan?view=net-9.0 [unix epoch]: https://en.wikipedia.org/wiki/Unix_time -[`System.Random`]: https://learn.microsoft.com/en-us/dotnet/api/system.random?view=net-9.0 \ No newline at end of file +[`System.Random`]: https://learn.microsoft.com/en-us/dotnet/api/system.random?view=net-9.0 From e46c8284a7c6b10b2529f70b0e11575e489a315b Mon Sep 17 00:00:00 2001 From: Tamaro Skaljic <49238587+tamaro-skaljic@users.noreply.github.com> Date: Wed, 16 Apr 2025 19:22:22 +0200 Subject: [PATCH 155/164] remove specific programming lang for server module code (#312) in unity tutorial text. There was confusion: https://discord.com/channels/1037340874172014652/1037343189339742298/1361811650713227285 --- docs/unity/part-2.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/unity/part-2.md b/docs/unity/part-2.md index 84e262b5..5a74b8ae 100644 --- a/docs/unity/part-2.md +++ b/docs/unity/part-2.md @@ -8,7 +8,7 @@ This progressive tutorial is continued from [part 1](/docs/unity/part-1). If you have not already installed the `spacetime` CLI, check out our [Getting Started](/docs/getting-started) guide for instructions on how to install. -In your `blackholio` directory, run the following command to initialize the SpacetimeDB server module project with Rust as the language: +In your `blackholio` directory, run the following command to initialize the SpacetimeDB server module project with your desired language: :::server-rust Run the following command to initialize the SpacetimeDB server module project with Rust as the language: From 3d2c50c72c9e05a8fc45ffffba150a71c63ac2d3 Mon Sep 17 00:00:00 2001 From: Tamaro Skaljic <49238587+tamaro-skaljic@users.noreply.github.com> Date: Wed, 16 Apr 2025 19:26:28 +0200 Subject: [PATCH 156/164] fix sql identifier casing in c# quickstart test query #220 (#309) From 17ee47de6373375d065d75dea06f532b1820b496 Mon Sep 17 00:00:00 2001 From: Tamaro Skaljic <49238587+tamaro-skaljic@users.noreply.github.com> Date: Wed, 16 Apr 2025 22:09:48 +0200 Subject: [PATCH 157/164] Rename module to database where appropriate (#277) * clients connect to databases, not modules * the name is for the database, not the module * reducers are exposed by databases, not modules * don't name clients "modules" too * Modules have no state, the database has it * more database instead of client connection * databases are hosted, not modules * users interact with databases, not modules * fix typo * Apply suggestions from code review Co-authored-by: Tyler Cloutier * Apply suggestions from code review Co-authored-by: Tyler Cloutier --------- Co-authored-by: Tyler Cloutier --- STYLE.md | 6 +++--- docs/cli-reference.md | 6 +++--- docs/deploying/spacetimedb-standalone.md | 2 +- docs/http/database.md | 2 +- docs/index.md | 6 +++--- docs/modules/c-sharp/index.md | 6 +++--- docs/modules/c-sharp/quickstart.md | 4 ++-- docs/modules/rust/quickstart.md | 10 +++++----- docs/sdks/c-sharp/index.md | 24 ++++++++++++------------ docs/sdks/c-sharp/quickstart.md | 18 +++++++++--------- docs/sdks/index.md | 4 ++-- docs/sdks/rust/index.md | 18 +++++++++--------- docs/sdks/rust/quickstart.md | 6 +++--- docs/sdks/typescript/index.md | 18 +++++++++--------- docs/sdks/typescript/quickstart.md | 10 +++++----- docs/unity/part-2.md | 20 ++++++++++---------- docs/unity/part-3.md | 6 +++--- docs/unity/part-4.md | 10 +++++----- 18 files changed, 88 insertions(+), 88 deletions(-) diff --git a/STYLE.md b/STYLE.md index d8b8be85..81de954f 100644 --- a/STYLE.md +++ b/STYLE.md @@ -311,9 +311,9 @@ For example: > > You can supply your users with authorization tokens in several different ways; which one is best for you will depend on the needs of your app. [...] (I don't actually want to write a real answer to this question - pgoldman 2024-11-19.) > -> #### Can my client connect to multiple modules at the same time? +> #### Can my client connect to multiple databases at the same time? > -> Yes! Your client can construct as many `DbConnection`s simultaneously as it wants to, each of which will operate independently. If you want to connect to two modules with different schemas, use `spacetime generate` to include bindings for both of them in your client project. Note that SpacetimeDB may reject multiple concurrent connections to the same module by a single client. +> Yes! Your client can construct as many `DbConnection`s simultaneously as it wants to, each of which will operate independently. If you want to connect to two databases with different schemas, use `spacetime generate` to include bindings for both of them in your client project. Note that SpacetimeDB may reject multiple concurrent connections to the same database by a single client. ## Tutorial pages @@ -341,7 +341,7 @@ The first time a tutorial or series introduces a new type / function / method / ### Tutorial code -If the tutorial involves writing code, e.g. for a module or client, the tutorial should include the complete result code within its text. Ideally, it should be possible for a reader to copy and paste all the code blocks in the document into a file, effectively concatentating them together, and wind up with a coherent and runnable program. Sometimes this is not possible, e.g. because C# requires wrapping your whole file in a bunch of scopes. In this case, precede each code block with a sentence that describes where the reader is going to paste it. +If the tutorial involves writing code, e.g. for a module or client, the tutorial should include the complete result code within its text. Ideally, it should be possible for a reader to copy and paste all the code blocks in the document into a file, effectively concatenating them together, and wind up with a coherent and runnable program. Sometimes this is not possible, e.g. because C# requires wrapping your whole file in a bunch of scopes. In this case, precede each code block with a sentence that describes where the reader is going to paste it. Include even uninteresting code, like imports! You can rush through these without spending too much time on them, but make sure that every line of code required to make the project work appears in the tutorial. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 69ebbbd5..4da30b2c 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -54,7 +54,7 @@ This document contains the help content for the `spacetime` command-line program * `logout` — * `init` — Initializes a new spacetime project. WARNING: This command is UNSTABLE and subject to breaking changes. * `build` — Builds a spacetime module. -* `server` — Manage the connection to the SpacetimeDB server. WARNING: This command is UNSTABLE and subject to breaking changes. +* `server` — Manage the connection to the SpacetimeDB database server. WARNING: This command is UNSTABLE and subject to breaking changes. * `subscribe` — Subscribe to SQL queries on the database. WARNING: This command is UNSTABLE and subject to breaking changes. * `start` — Start a local SpacetimeDB instance * `version` — Manage installed spacetime versions @@ -83,7 +83,7 @@ Run `spacetime help publish` for more detailed information. ###### Options: -* `-c`, `--delete-data` — When publishing to an existing database identity, first DESTROY all data associated with the module +* `-c`, `--delete-data` — When publishing to an existing database identity, first DESTROY all data associated with the database * `--build-options ` — Options to pass to the build command, for example --build-options='--lint-dir=' Default value: `` @@ -391,7 +391,7 @@ Builds a spacetime module. ## spacetime server -Manage the connection to the SpacetimeDB server. WARNING: This command is UNSTABLE and subject to breaking changes. +Manage the connection to the SpacetimeDB database server. WARNING: This command is UNSTABLE and subject to breaking changes. **Usage:** `spacetime server server ` diff --git a/docs/deploying/spacetimedb-standalone.md b/docs/deploying/spacetimedb-standalone.md index 34cb8ccf..49b92c27 100644 --- a/docs/deploying/spacetimedb-standalone.md +++ b/docs/deploying/spacetimedb-standalone.md @@ -168,7 +168,7 @@ If you have uncommented the `/v1/publish` restriction in Step 3 then you won't b ```bash spacetime build scp target/wasm32-unknown-unknown/release/spacetime_module.wasm ubuntu@:/home/ubuntu/ -ssh ubuntu@ spacetime publish -s local --bin-path spacetime_module.wasm +ssh ubuntu@ spacetime publish -s local --bin-path spacetime_module.wasm ``` You could put the above commands into a shell script to make publishing to your server easier and faster. It's also possible to integrate a script like this into Github Actions to publish on some event (like a PR merging into master). diff --git a/docs/http/database.md b/docs/http/database.md index 0ac41070..de495511 100644 --- a/docs/http/database.md +++ b/docs/http/database.md @@ -127,7 +127,7 @@ Accessible through the CLI as `spacetime delete `. ## `GET /v1/database/:name_or_identity/names` -Get the names this datbase can be identified by. +Get the names this database can be identified by. #### Returns diff --git a/docs/index.md b/docs/index.md index 6e4a0b65..a8b671c0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -229,7 +229,7 @@ Clients are regular software applications that developers can choose how to depl ### Identity -A SpacetimeDB `Identity` identifies someone interacting with a module. It is a long lived, public, globally valid identifier that will always refer to the same end user, even across different connections. +A SpacetimeDB `Identity` identifies someone interacting with a database. It is a long lived, public, globally valid identifier that will always refer to the same end user, even across different connections. A user's `Identity` is attached to every [reducer call](#reducer) they make, and you can use this to decide what they are allowed to do. @@ -261,9 +261,9 @@ def identity_from_claims(issuer: str, subject: str) -> [u8; 32]: ### ConnectionId -A `ConnectionId` identifies client connections to a SpacetimeDB module. +A `ConnectionId` identifies client connections to a SpacetimeDB database. -A user has a single [`Identity`](#identity), but may open multiple connections to your module. Each of these will receive a unique `ConnectionId`. +A user has a single [`Identity`](#identity), but may open multiple connections to your database. Each of these will receive a unique `ConnectionId`. ### Energy **Energy** is the currency used to pay for data storage and compute operations in a SpacetimeDB host. diff --git a/docs/modules/c-sharp/index.md b/docs/modules/c-sharp/index.md index bc6ef893..3deeb2b7 100644 --- a/docs/modules/c-sharp/index.md +++ b/docs/modules/c-sharp/index.md @@ -841,13 +841,13 @@ This reducer can be used to configure any static data tables used by your module ### The `ClientConnected` reducer -This reducer is marked with `[SpacetimeDB.Reducer(ReducerKind.ClientConnected)]`. It is run when a client connects to the SpacetimeDB module. Their identity can be found in the sender value of the `ReducerContext`. +This reducer is marked with `[SpacetimeDB.Reducer(ReducerKind.ClientConnected)]`. It is run when a client connects to the SpacetimeDB database. Their identity can be found in the sender value of the `ReducerContext`. If an error occurs in the reducer, the client will be disconnected. ### The `ClientDisconnected` reducer -This reducer is marked with `[SpacetimeDB.Reducer(ReducerKind.ClientDisconnected)]`. It is run when a client disconnects from the SpacetimeDB module. Their identity can be found in the sender value of the `ReducerContext`. +This reducer is marked with `[SpacetimeDB.Reducer(ReducerKind.ClientDisconnected)]`. It is run when a client disconnects from the SpacetimeDB database. Their identity can be found in the sender value of the `ReducerContext`. If an error occurs in the disconnect reducer, the client is still recorded as disconnected. @@ -1013,7 +1013,7 @@ namespace SpacetimeDB Methods for writing to a private debug log. Log messages will include file and line numbers. -Log outputs of a running module can be inspected using the `spacetime logs` command: +Log outputs of a running database can be inspected using the `spacetime logs` command: ```text spacetime logs diff --git a/docs/modules/c-sharp/quickstart.md b/docs/modules/c-sharp/quickstart.md index 9bdb78c9..72d907e3 100644 --- a/docs/modules/c-sharp/quickstart.md +++ b/docs/modules/c-sharp/quickstart.md @@ -233,7 +233,7 @@ public static void ClientConnected(ReducerContext ctx) } ``` -Similarly, whenever a client disconnects, the module will execute the `OnDisconnect` event if it's registered with `ReducerKind.ClientDisconnected`. We'll use it to un-set the `Online` status of the `User` for the disconnected client. +Similarly, whenever a client disconnects, the database will execute the `OnDisconnect` event if it's registered with `ReducerKind.ClientDisconnected`. We'll use it to un-set the `Online` status of the `User` for the disconnected client. Add the following code after the `OnConnect` handler: @@ -311,6 +311,6 @@ spacetime sql quickstart-chat "SELECT * FROM message" You've just set up your first database in SpacetimeDB! You can find the full code for this client [in the C# server module example](https://github.com/clockworklabs/com.clockworklabs.spacetimedbsdk/tree/master/examples~/quickstart-chat/server). -The next step would be to create a client module that interacts with this module. You can use any of SpacetimDB's supported client languages to do this. Take a look at the quick start guide for your client language of choice: [Rust](/docs/sdks/rust/quickstart), [C#](/docs/sdks/c-sharp/quickstart), or [TypeScript](/docs/sdks/typescript/quickstart). +The next step would be to create a client that interacts with this module. You can use any of SpacetimDB's supported client languages to do this. Take a look at the quick start guide for your client language of choice: [Rust](/docs/sdks/rust/quickstart), [C#](/docs/sdks/c-sharp/quickstart), or [TypeScript](/docs/sdks/typescript/quickstart). If you are planning to use SpacetimeDB with the Unity game engine, you can skip right to the [Unity Comprehensive Tutorial](/docs/unity/part-1). diff --git a/docs/modules/rust/quickstart.md b/docs/modules/rust/quickstart.md index 9bbb4b3b..9572ed0b 100644 --- a/docs/modules/rust/quickstart.md +++ b/docs/modules/rust/quickstart.md @@ -182,7 +182,7 @@ You could extend the validation in `validate_message` in similar ways to `valida ## Set users' online status -Whenever a client connects, the module will run a special reducer, annotated with `#[reducer(client_connected)]`, if it's defined. By convention, it's named `client_connected`. We'll use it to create a `User` record for the client if it doesn't yet exist, and to set its online status. +Whenever a client connects, the database will run a special reducer, annotated with `#[reducer(client_connected)]`, if it's defined. By convention, it's named `client_connected`. We'll use it to create a `User` record for the client if it doesn't yet exist, and to set its online status. We'll use `ctx.db.user().identity().find(ctx.sender)` to look up a `User` row for `ctx.sender`, if one exists. If we find one, we'll use `ctx.db.user().identity().update(..)` to overwrite it with a row that has `online: true`. If not, we'll use `ctx.db.user().insert(..)` to insert a new row for our new user. All three of these methods are generated by the `#[table(..)]` macro, with rows and behavior based on the row attributes. `ctx.db.user().find(..)` returns an `Option`, because of the unique constraint from the `#[primary_key]` attribute. This means there will be either zero or one matching rows. If we used `try_insert` here it would return a `Result<(), UniqueConstraintViolation>` because of the same unique constraint. However, because we're already checking if there is a user with the given sender identity we know that inserting into this table will not fail. Therefore, we use `insert`, which automatically unwraps the result, simplifying the code. If we want to overwrite a `User` row, we need to do so explicitly using `ctx.db.user().identity().update(..)`. @@ -190,7 +190,7 @@ To `server/src/lib.rs`, add the definition of the connect reducer: ```rust #[reducer(client_connected)] -// Called when a client connects to the SpacetimeDB +// Called when a client connects to a SpacetimeDB database server pub fn client_connected(ctx: &ReducerContext) { if let Some(user) = ctx.db.user().identity().find(ctx.sender) { // If this is a returning user, i.e. we already have a `User` with this `Identity`, @@ -208,11 +208,11 @@ pub fn client_connected(ctx: &ReducerContext) { } ``` -Similarly, whenever a client disconnects, the module will run the `#[reducer(client_disconnected)]` reducer if it's defined. By convention, it's named `client_disconnected`. We'll use it to un-set the `online` status of the `User` for the disconnected client. +Similarly, whenever a client disconnects, the database will run the `#[reducer(client_disconnected)]` reducer if it's defined. By convention, it's named `client_disconnected`. We'll use it to un-set the `online` status of the `User` for the disconnected client. ```rust #[reducer(client_disconnected)] -// Called when a client disconnects from SpacetimeDB +// Called when a client disconnects from SpacetimeDB database server pub fn identity_disconnected(ctx: &ReducerContext) { if let Some(user) = ctx.db.user().identity().find(ctx.sender) { ctx.db.user().identity().update(User { online: false, ..user }); @@ -275,6 +275,6 @@ spacetime sql quickstart-chat "SELECT * FROM message" You can find the full code for this module [in the SpacetimeDB module examples](https://github.com/clockworklabs/SpacetimeDB/tree/master/modules/quickstart-chat). -You've just set up your first database in SpacetimeDB! The next step would be to create a client module that interacts with this module. You can use any of SpacetimDB's supported client languages to do this. Take a look at the quickstart guide for your client language of choice: [Rust](/docs/sdks/rust/quickstart), [C#](/docs/sdks/c-sharp/quickstart), or [TypeScript](/docs/sdks/typescript/quickstart). +You've just set up your first database in SpacetimeDB! The next step would be to create a client that interacts with this module. You can use any of SpacetimDB's supported client languages to do this. Take a look at the quickstart guide for your client language of choice: [Rust](/docs/sdks/rust/quickstart), [C#](/docs/sdks/c-sharp/quickstart), or [TypeScript](/docs/sdks/typescript/quickstart). If you are planning to use SpacetimeDB with the Unity game engine, you can skip right to the [Unity Comprehensive Tutorial](/docs/unity/part-1). diff --git a/docs/sdks/c-sharp/index.md b/docs/sdks/c-sharp/index.md index 16fd2068..3fd4c9b0 100644 --- a/docs/sdks/c-sharp/index.md +++ b/docs/sdks/c-sharp/index.md @@ -53,11 +53,11 @@ A connection to a remote database is represented by the `DbConnection` class. Th | Name | Description | |------------------------------------------------------------------------|-------------------------------------------------------------------------------| -| [Connect to a module](#connect-to-a-module) | Construct a `DbConnection` instance. | +| [Connect to a database](#connect-to-a-database) | Construct a `DbConnection` instance. | | [Advance the connection](#advance-the-connection-and-process-messages) | Poll the `DbConnection` or run it in the background. | | [Access tables and reducers](#access-tables-and-reducers) | Access the client cache, request reducer invocations, and register callbacks. | -## Connect to a module +## Connect to a database ```csharp class DbConnection @@ -66,12 +66,12 @@ class DbConnection } ``` -Construct a `DbConnection` by calling `DbConnection.Builder()`, chaining configuration methods, and finally calling `.Build()`. At a minimum, you must specify `WithUri` to provide the URI of the SpacetimeDB instance, and `WithModuleName` to specify the module's name or identity. +Construct a `DbConnection` by calling `DbConnection.Builder()`, chaining configuration methods, and finally calling `.Build()`. At a minimum, you must specify `WithUri` to provide the URI of the SpacetimeDB instance, and `WithModuleName` to specify the database's name or identity. | Name | Description | |---------------------------------------------------------|--------------------------------------------------------------------------------------------| | [WithUri method](#method-withuri) | Set the URI of the SpacetimeDB instance hosting the remote database. | -| [WithModuleName method](#method-withmodulename) | Set the name or identity of the remote module. | +| [WithModuleName method](#method-withmodulename) | Set the name or identity of the remote database. | | [OnConnect callback](#callback-onconnect) | Register a callback to run when the connection is successfully established. | | [OnConnectError callback](#callback-onconnecterror) | Register a callback to run if the connection is rejected or the host is unreachable. | | [OnDisconnect callback](#callback-ondisconnect) | Register a callback to run when the connection ends. | @@ -87,7 +87,7 @@ class DbConnectionBuilder } ``` -Configure the URI of the SpacetimeDB instance or cluster which hosts the remote module. +Configure the URI of the SpacetimeDB instance or cluster which hosts the remote module and database. ### Method `WithModuleName` @@ -98,7 +98,7 @@ class DbConnectionBuilder } ``` -Configure the SpacetimeDB domain name or `Identity` of the remote module which identifies it within the SpacetimeDB instance or cluster. +Configure the SpacetimeDB domain name or `Identity` of the remote database which identifies it within the SpacetimeDB instance or cluster. ### Callback `OnConnect` @@ -109,7 +109,7 @@ class DbConnectionBuilder } ``` -Chain a call to `.OnConnect(callback)` to your builder to register a callback to run when your new `DbConnection` successfully initiates its connection to the remote module. The callback accepts three arguments: a reference to the `DbConnection`, the `Identity` by which SpacetimeDB identifies this connection, and a private access token which can be saved and later passed to [`WithToken`](#method-withtoken) to authenticate the same user in future connections. +Chain a call to `.OnConnect(callback)` to your builder to register a callback to run when your new `DbConnection` successfully initiates its connection to the remote database. The callback accepts three arguments: a reference to the `DbConnection`, the `Identity` by which SpacetimeDB identifies this connection, and a private access token which can be saved and later passed to [`WithToken`](#method-withtoken) to authenticate the same user in future connections. ### Callback `OnConnectError` @@ -133,7 +133,7 @@ class DbConnectionBuilder } ``` -Chain a call to `.OnDisconnect(callback)` to your builder to register a callback to run when your `DbConnection` disconnects from the remote module, either as a result of a call to [`Disconnect`](#method-disconnect) or due to an error. +Chain a call to `.OnDisconnect(callback)` to your builder to register a callback to run when your `DbConnection` disconnects from the remote database, either as a result of a call to [`Disconnect`](#method-disconnect) or due to an error. ### Method `WithToken` @@ -203,7 +203,7 @@ class DbConnection } ``` -The `Reducers` field of the `DbConnection` provides access to reducers exposed by the remote module. See [Observe and invoke reducers](#observe-and-invoke-reducers). +The `Reducers` field of the `DbConnection` provides access to reducers exposed by the module of the remote database. See [Observe and invoke reducers](#observe-and-invoke-reducers). ## Interface `IDbContext` @@ -522,7 +522,7 @@ record Event } ``` -Event when we are notified that a reducer ran in the remote module. The [`ReducerEvent`](#record-reducerevent) contains metadata about the reducer run, including its arguments and termination [`Status`](#record-status). +Event when we are notified that a reducer ran in the remote database. The [`ReducerEvent`](#record-reducerevent) contains metadata about the reducer run, including its arguments and termination [`Status`](#record-status). This event is passed to row callbacks resulting from modifications by the reducer. @@ -574,7 +574,7 @@ record Event } ``` -Event when we are notified of a transaction in the remote module which we cannot associate with a known reducer. This may be an ad-hoc SQL query or a reducer for which we do not have bindings. +Event when we are notified of a transaction in the remote database which we cannot associate with a known reducer. This may be an ad-hoc SQL query or a reducer for which we do not have bindings. This event is passed to [row callbacks](#callback-oninsert) resulting from modifications by the transaction. @@ -741,7 +741,7 @@ class ErrorContext { } ``` -The `Reducers` property of the context provides access to reducers exposed by the remote module. See [Observe and invoke reducers](#observe-and-invoke-reducers). +The `Reducers` property of the context provides access to reducers exposed by the remote database. See [Observe and invoke reducers](#observe-and-invoke-reducers). ## Access the client cache diff --git a/docs/sdks/c-sharp/quickstart.md b/docs/sdks/c-sharp/quickstart.md index aba4b77c..44065195 100644 --- a/docs/sdks/c-sharp/quickstart.md +++ b/docs/sdks/c-sharp/quickstart.md @@ -90,7 +90,7 @@ We'll work outside-in, first defining our `Main` function at a high level, then 1. Initialize the `AuthToken` module, which loads and stores our authentication token to/from local storage. 2. Connect to the database. 3. Register a number of callbacks to run in response to various database events. -4. Start our processing thread which connects to the SpacetimeDB module, updates the SpacetimeDB client and processes commands that come in from the input loop running in the main thread. +4. Start our processing thread which connects to the SpacetimeDB database, updates the SpacetimeDB client and processes commands that come in from the input loop running in the main thread. 5. Start the input loop, which reads commands from standard input and sends them to the processing thread. 6. When the input loop exits, stop the processing thread and wait for it to exit. @@ -121,13 +121,13 @@ void Main() ## Connect to database -Before we connect, we'll store the SpacetimeDB host name and our module name in constants `HOST` and `DB_NAME`. +Before we connect, we'll store the SpacetimeDB hostname and our database name in constants `HOST` and `DB_NAME`. A connection to a SpacetimeDB database is represented by a `DbConnection`. We configure `DbConnection`s using the builder pattern, by calling `DbConnection.Builder()`, chaining method calls to set various connection parameters and register callbacks, then we cap it off with a call to `.Build()` to begin the connection. In our case, we'll supply the following options: -1. A `WithUri` call, to specify the URI of the SpacetimeDB host where our module is running. +1. A `WithUri` call, to specify the URI of the SpacetimeDB host where our database is running. 2. A `WithModuleName` call, to specify the name or `Identity` of our database. Make sure to pass the same name here as you supplied to `spacetime publish`. 3. A `WithToken` call, to supply a token to authenticate with. 4. An `OnConnect` callback, to run when the remote database acknowledges and accepts our connection. @@ -137,10 +137,10 @@ In our case, we'll supply the following options: To `Program.cs`, add: ```csharp -/// The URI of the SpacetimeDB instance hosting our chat module. +/// The URI of the SpacetimeDB instance hosting our chat database and module. const string HOST = "http://localhost:3000"; -/// The module name we chose when we published our module. +/// The database name we chose when we published our module. const string DBNAME = "quickstart-chat"; /// Load credentials from a file and connect to the database. @@ -453,9 +453,9 @@ void PrintMessagesInOrder(RemoteTables tables) Since the input loop will be blocking, we'll run our processing code in a separate thread. -This thread will loop until the thread is signaled to exit, calling the update function `FrameTick` on the `DbConnection` to process any updates received from the module, and `ProcessCommand` to process any commands received from the input loop. +This thread will loop until the thread is signaled to exit, calling the update function `FrameTick` on the `DbConnection` to process any updates received from the database, and `ProcessCommand` to process any commands received from the input loop. -Afterward, close the connection to the module. +Afterward, close the connection to the database. To `Program.cs`, add: @@ -488,9 +488,9 @@ The input loop will read commands from standard input and send them to the proce Supported Commands: -1. Send a message: `message`, send the message to the module by calling `Reducer.SendMessage` which is automatically generated by `spacetime generate`. +1. Send a message: `message`, send the message to the database by calling `Reducer.SendMessage` which is automatically generated by `spacetime generate`. -2. Set name: `name`, will send the new name to the module by calling `Reducer.SetName` which is automatically generated by `spacetime generate`. +2. Set name: `name`, will send the new name to the database by calling `Reducer.SetName` which is automatically generated by `spacetime generate`. To `Program.cs`, add: diff --git a/docs/sdks/index.md b/docs/sdks/index.md index 46078cb9..ad9c082b 100644 --- a/docs/sdks/index.md +++ b/docs/sdks/index.md @@ -12,7 +12,7 @@ The SpacetimeDB Client SDKs offer the following key functionalities: ### Connection Management -The SDKs handle the process of connecting and disconnecting from the SpacetimeDB server, simplifying this process for the client applications. +The SDKs handle the process of connecting and disconnecting from SpacetimeDB database servers, simplifying this process for the client applications. ### Authentication @@ -32,7 +32,7 @@ The SpacetimeDB Client SDKs offer powerful callback functionality that allow cli #### Connection and Subscription Callbacks -Clients can also register callbacks that trigger when the connection to the server is established or lost, or when a subscription is updated. This allows clients to react to changes in the connection status. +Clients can also register callbacks that trigger when the connection to the database server is established or lost, or when a subscription is updated. This allows clients to react to changes in the connection status. #### Row Update Callbacks diff --git a/docs/sdks/rust/index.md b/docs/sdks/rust/index.md index a6dd23bb..4c180f52 100644 --- a/docs/sdks/rust/index.md +++ b/docs/sdks/rust/index.md @@ -53,11 +53,11 @@ A connection to a remote database is represented by the `module_bindings::DbConn | Name | Description | |------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------| -| [Connect to a module](#connect-to-a-module) | Construct a `DbConnection`. | +| [Connect to a database](#connect-to-a-database) | Construct a `DbConnection`. | | [Advance the connection](#advance-the-connection-and-process-messages) | Poll the `DbConnection`, or set up a background worker to run it. | | [Access tables and reducers](#access-tables-and-reducers) | Access subscribed rows in the client cache, request reducer invocations, and register callbacks. | -### Connect to a module +### Connect to a database ```rust impl DbConnection { @@ -65,7 +65,7 @@ impl DbConnection { } ``` -Construct a `DbConnection` by calling `DbConnection::builder()` and chaining configuration methods, then calling `.build()`. You must at least specify `with_uri`, to supply the URI of the SpacetimeDB to which you published your module, and `with_module_name`, to supply the human-readable SpacetimeDB domain name or the raw `Identity` which identifies the module. +Construct a `DbConnection` by calling `DbConnection::builder()` and chaining configuration methods, then calling `.build()`. You must at least specify `with_uri`, to supply the URI of the SpacetimeDB to which you published your module, and `with_module_name`, to supply the human-readable SpacetimeDB domain name or the raw `Identity` which identifies the database. | Name | Description | |-----------------------------------------------------------|--------------------------------------------------------------------------------------| @@ -85,7 +85,7 @@ impl DbConnectionBuilder { } ``` -Configure the URI of the SpacetimeDB instance or cluster which hosts the remote module. +Configure the URI of the SpacetimeDB instance or cluster which hosts the remote database containing the module. #### Method `with_module_name` @@ -95,7 +95,7 @@ impl DbConnectionBuilder { } ``` -Configure the SpacetimeDB domain name or `Identity` of the remote module which identifies it within the SpacetimeDB instance or cluster. +Configure the SpacetimeDB domain name or `Identity` of the remote database which identifies it within the SpacetimeDB instance or cluster. #### Callback `on_connect` @@ -105,7 +105,7 @@ impl DbConnectionBuilder { } ``` -Chain a call to `.on_connect(callback)` to your builder to register a callback to run when your new `DbConnection` successfully initiates its connection to the remote module. The callback accepts three arguments: a reference to the `DbConnection`, the `Identity` by which SpacetimeDB identifies this connection, and a private access token which can be saved and later passed to [`with_token`](#method-with_token) to authenticate the same user in future connections. +Chain a call to `.on_connect(callback)` to your builder to register a callback to run when your new `DbConnection` successfully initiates its connection to the remote database. The callback accepts three arguments: a reference to the `DbConnection`, the `Identity` by which SpacetimeDB identifies this connection, and a private access token which can be saved and later passed to [`with_token`](#method-with_token) to authenticate the same user in future connections. This interface may change in an upcoming release as we rework SpacetimeDB's authentication model. @@ -135,7 +135,7 @@ impl DbConnectionBuilder { } ``` -Chain a call to `.on_disconnect(callback)` to your builder to register a callback to run when your `DbConnection` disconnects from the remote module, either as a result of a call to [`disconnect`](#method-disconnect) or due to an error. +Chain a call to `.on_disconnect(callback)` to your builder to register a callback to run when your `DbConnection` disconnects from the remote database, either as a result of a call to [`disconnect`](#method-disconnect) or due to an error. #### Method `with_token` @@ -553,7 +553,7 @@ spacetimedb_sdk::Event spacetimedb_sdk::Event::Reducer(spacetimedb_sdk::ReducerEvent) ``` -Event when we are notified that a reducer ran in the remote module. The [`ReducerEvent`](#struct-reducerevent) contains metadata about the reducer run, including its arguments and termination [`Status`](#enum-status). +Event when we are notified that a reducer ran in the remote database. The [`ReducerEvent`](#struct-reducerevent) contains metadata about the reducer run, including its arguments and termination [`Status`](#enum-status). This event is passed to row callbacks resulting from modifications by the reducer. @@ -589,7 +589,7 @@ This event is passed to [row `on_delete` callbacks](#callback-on_delete) resulti #### Variant `UnknownTransaction` -Event when we are notified of a transaction in the remote module which we cannot associate with a known reducer. This may be an ad-hoc SQL query or a reducer for which we do not have bindings. +Event when we are notified of a transaction in the remote database which we cannot associate with a known reducer. This may be an ad-hoc SQL query or a reducer for which we do not have bindings. This event is passed to [row callbacks](#callback-on_insert) resulting from modifications by the transaction. diff --git a/docs/sdks/rust/quickstart.md b/docs/sdks/rust/quickstart.md index ea1080ac..21f4d947 100644 --- a/docs/sdks/rust/quickstart.md +++ b/docs/sdks/rust/quickstart.md @@ -22,7 +22,7 @@ cargo new client `client/Cargo.toml` should be initialized without any dependencies. We'll need two: -- [`spacetimedb-sdk`](https://crates.io/crates/spacetimedb-sdk), which defines client-side interfaces for interacting with a remote SpacetimeDB module. +- [`spacetimedb-sdk`](https://crates.io/crates/spacetimedb-sdk), which defines client-side interfaces for interacting with a remote SpacetimeDB database. - [`hex`](https://crates.io/crates/hex), which we'll use to print unnamed users' identities as hexadecimal strings. Below the `[dependencies]` line in `client/Cargo.toml`, add: @@ -131,12 +131,12 @@ In our case, we'll supply the following options: 3. An `on_disconnect` callback, to run when our connection ends. 4. A `with_token` call, to supply a token to authenticate with. 5. A `with_module_name` call, to specify the name or `Identity` of our database. Make sure to pass the same name here as you supplied to `spacetime publish`. -6. A `with_uri` call, to specify the URI of the SpacetimeDB host where our module is running. +6. A `with_uri` call, to specify the URI of the SpacetimeDB host where our database is running. To `client/src/main.rs`, add: ```rust -/// The URI of the SpacetimeDB instance hosting our chat module. +/// The URI of the SpacetimeDB instance hosting our chat database and module. const HOST: &str = "http://localhost:3000"; /// The database name we chose when we published our module. diff --git a/docs/sdks/typescript/index.md b/docs/sdks/typescript/index.md index 532fe951..ef55ed1e 100644 --- a/docs/sdks/typescript/index.md +++ b/docs/sdks/typescript/index.md @@ -94,11 +94,11 @@ A connection to a remote database is represented by the `DbConnection` type. Thi | Name | Description | |-----------------------------------------------------------|--------------------------------------------------------------------------------------------------| -| [Connect to a module](#connect-to-a-module) | Construct a `DbConnection`. | +| [Connect to a database](#connect-to-a-database) | Construct a `DbConnection`. | | [Access tables and reducers](#access-tables-and-reducers) | Access subscribed rows in the client cache, request reducer invocations, and register callbacks. | -### Connect to a module +### Connect to a database ```typescript class DbConnection { @@ -106,7 +106,7 @@ class DbConnection { } ``` -Construct a `DbConnection` by calling `DbConnection.builder()` and chaining configuration methods, then calling `.build()`. You must at least specify `withUri`, to supply the URI of the SpacetimeDB to which you published your module, and `withModuleName`, to supply the human-readable SpacetimeDB domain name or the raw `Identity` which identifies the module. +Construct a `DbConnection` by calling `DbConnection.builder()` and chaining configuration methods, then calling `.build()`. You must at least specify `withUri`, to supply the URI of the SpacetimeDB to which you published your module, and `withModuleName`, to supply the human-readable SpacetimeDB domain name or the raw `Identity` which identifies the database. | Name | Description | |-------------------------------------------------------|--------------------------------------------------------------------------------------| @@ -126,7 +126,7 @@ class DbConnectionBuilder { } ``` -Configure the URI of the SpacetimeDB instance or cluster which hosts the remote module. +Configure the URI of the SpacetimeDB instance or cluster which hosts the remote database. #### Method `withModuleName` @@ -137,7 +137,7 @@ class DbConnectionBuilder { ``` -Configure the SpacetimeDB domain name or hex string encoded `Identity` of the remote module which identifies it within the SpacetimeDB instance or cluster. +Configure the SpacetimeDB domain name or hex string encoded `Identity` of the remote database which identifies it within the SpacetimeDB instance or cluster. #### Callback `onConnect` @@ -149,7 +149,7 @@ class DbConnectionBuilder { } ``` -Chain a call to `.onConnect(callback)` to your builder to register a callback to run when your new `DbConnection` successfully initiates its connection to the remote module. The callback accepts three arguments: a reference to the `DbConnection`, the `Identity` by which SpacetimeDB identifies this connection, and a private access token which can be saved and later passed to [`withToken`](#method-withtoken) to authenticate the same user in future connections. +Chain a call to `.onConnect(callback)` to your builder to register a callback to run when your new `DbConnection` successfully initiates its connection to the remote database. The callback accepts three arguments: a reference to the `DbConnection`, the `Identity` by which SpacetimeDB identifies this connection, and a private access token which can be saved and later passed to [`withToken`](#method-withtoken) to authenticate the same user in future connections. #### Callback `onConnectError` @@ -173,7 +173,7 @@ class DbConnectionBuilder { } ``` -Chain a call to `.onDisconnect(callback)` to your builder to register a callback to run when your `DbConnection` disconnects from the remote module, either as a result of a call to [`disconnect`](#method-disconnect) or due to an error. +Chain a call to `.onDisconnect(callback)` to your builder to register a callback to run when your `DbConnection` disconnects from the remote database, either as a result of a call to [`disconnect`](#method-disconnect) or due to an error. #### Method `withToken` @@ -499,7 +499,7 @@ type Event = { tag: 'Reducer'; value: ReducerEvent } ``` -Event when we are notified that a reducer ran in the remote module. The [`ReducerEvent`](#type-reducerevent) contains metadata about the reducer run, including its arguments and termination status(#type-updatestatus). +Event when we are notified that a reducer ran in the remote database. The [`ReducerEvent`](#type-reducerevent) contains metadata about the reducer run, including its arguments and termination status(#type-updatestatus). This event is passed to row callbacks resulting from modifications by the reducer. @@ -540,7 +540,7 @@ This event is passed to [row `onDelete` callbacks](#callback-ondelete) resulting { tag: 'UnknownTransaction' } ``` -Event when we are notified of a transaction in the remote module which we cannot associate with a known reducer. This may be an ad-hoc SQL query or a reducer for which we do not have bindings. +Event when we are notified of a transaction in the remote database which we cannot associate with a known reducer. This may be an ad-hoc SQL query or a reducer for which we do not have bindings. This event is passed to [row callbacks](#callback-oninsert) resulting from modifications by the transaction. diff --git a/docs/sdks/typescript/quickstart.md b/docs/sdks/typescript/quickstart.md index e567dc3d..1e1151ff 100644 --- a/docs/sdks/typescript/quickstart.md +++ b/docs/sdks/typescript/quickstart.md @@ -7,7 +7,7 @@ Please note that TypeScript is supported as a client language only. **Before you - [Rust](/docs/modules/rust/quickstart) - [C#](/docs/modules/c-sharp/quickstart) -By the end of this introduciton, you will have created a basic single page web app which connects to the `quickstart-chat` module created in the above module quickstart guides. +By the end of this introduciton, you will have created a basic single page web app which connects to the `quickstart-chat` database created in the above module quickstart guides. ## Project structure @@ -393,7 +393,7 @@ import { Identity } from '@clockworklabs/spacetimedb-sdk'; ## Create your SpacetimeDB client -Now that we've imported the `DbConnection` type, we can use it to connect our app to our module. +Now that we've imported the `DbConnection` type, we can use it to connect our app to our database. Add the following to your `App` function, just below `const [newMessage, setNewMessage] = useState('');`: @@ -459,11 +459,11 @@ Add the following to your `App` function, just below `const [newMessage, setNewM }, []); ``` -Here we are configuring our SpacetimeDB connection by specifying the server URI, module name, and a few callbacks including the `onConnect` callback. When `onConnect` is called after connecting, we store the connection state, our `Identity`, and our SpacetimeDB credentials in our React state. If there is an error connecting, we print that error to the console as well. +Here we are configuring our SpacetimeDB connection by specifying the server URI, database name, and a few callbacks including the `onConnect` callback. When `onConnect` is called after connecting, we store the connection state, our `Identity`, and our SpacetimeDB credentials in our React state. If there is an error connecting, we print that error to the console as well. We are also using `localStorage` to store our SpacetimeDB credentials. This way, we can reconnect to SpacetimeDB with the same `Identity` and token if we refresh the page. The first time we connect, we won't have any credentials stored, so we pass `undefined` to the `withToken` method. This will cause SpacetimeDB to generate new credentials for us. -If you chose a different name for your module, replace `quickstart-chat` with that name, or republish your module as `quickstart-chat`. +If you chose a different name for your database, replace `quickstart-chat` with that name, or republish your module as `quickstart-chat`. In the `onConnect` function we are also subscribing to the `message` and `user` tables. When we subscribe, SpacetimeDB will run our subscription queries and store the result in a local "client cache". This cache will be updated in real-time as the data in the table changes on the server. The `onApplied` callback is called after SpacetimeDB has synchronized our subscribed data with the client cache. @@ -630,7 +630,7 @@ Try opening a few incognito windows to see what it's like with multiple users! ### Notify about new users -We can also register `onInsert` and `onDelete` callbacks for the purpose of handling events, not just state. For example, we might want to show a notification any time a new user connects to the module. +We can also register `onInsert` and `onDelete` callbacks for the purpose of handling events, not just state. For example, we might want to show a notification any time a new user connects to the database. Note that these callbacks can fire in two contexts: diff --git a/docs/unity/part-2.md b/docs/unity/part-2.md index 5a74b8ae..ebfc7a69 100644 --- a/docs/unity/part-2.md +++ b/docs/unity/part-2.md @@ -382,7 +382,7 @@ You should see something like the following output: ### Connecting our Client :::server-rust -Next let's connect our client to our module. Let's start by modifying our `debug` reducer. Rename the reducer to be called `connect` and add `client_connected` in parentheses after `spacetimedb::reducer`. The end result should look like this: +Next let's connect our client to our database. Let's start by modifying our `debug` reducer. Rename the reducer to be called `connect` and add `client_connected` in parentheses after `spacetimedb::reducer`. The end result should look like this: ```rust #[spacetimedb::reducer(client_connected)] @@ -392,16 +392,16 @@ pub fn connect(ctx: &ReducerContext) -> Result<(), String> { } ``` -The `client_connected` argument to the `spacetimedb::reducer` macro indicates to SpacetimeDB that this is a special reducer. This reducer is only ever called by SpacetimeDB itself when a client connects to your module. +The `client_connected` argument to the `spacetimedb::reducer` macro indicates to SpacetimeDB that this is a special reducer. This reducer is only ever called by SpacetimeDB itself when a client connects to your database. > SpacetimeDB gives you the ability to define custom reducers that automatically trigger when certain events occur. > > - `init` - Called the first time you publish your module and anytime you clear the database with `spacetime publish --delete-data`. -> - `client_connected` - Called when a user connects to the SpacetimeDB module. Their identity can be found in the `sender` value of the `ReducerContext`. -> - `client_disconnected` - Called when a user disconnects from the SpacetimeDB module. +> - `client_connected` - Called when a user connects to the SpacetimeDB database. Their identity can be found in the `sender` value of the `ReducerContext`. +> - `client_disconnected` - Called when a user disconnects from the SpacetimeDB database. ::: :::server-csharp -Next let's connect our client to our module. Let's start by modifying our `Debug` reducer. Rename the reducer to be called `Connect` and add `ReducerKind.ClientConnected` in parentheses after `SpacetimeDB.Reducer`. The end result should look like this: +Next let's connect our client to our database. Let's start by modifying our `Debug` reducer. Rename the reducer to be called `Connect` and add `ReducerKind.ClientConnected` in parentheses after `SpacetimeDB.Reducer`. The end result should look like this: ```csharp [Reducer(ReducerKind.ClientConnected)] @@ -411,13 +411,13 @@ public static void Connect(ReducerContext ctx) } ``` -The `ReducerKind.ClientConnected` argument to the `SpacetimeDB.Reducer` attribute indicates to SpacetimeDB that this is a special reducer. This reducer is only ever called by SpacetimeDB itself when a client connects to your module. +The `ReducerKind.ClientConnected` argument to the `SpacetimeDB.Reducer` attribute indicates to SpacetimeDB that this is a special reducer. This reducer is only ever called by SpacetimeDB itself when a client connects to your database. > SpacetimeDB gives you the ability to define custom reducers that automatically trigger when certain events occur. > > - `ReducerKind.Init` - Called the first time you publish your module and anytime you clear the database with `spacetime publish --delete-data`. -> - `ReducerKind.ClientConnected` - Called when a user connects to the SpacetimeDB module. Their identity can be found in the `Sender` value of the `ReducerContext`. -> - `ReducerKind.ClientDisconnected` - Called when a user disconnects from the SpacetimeDB module. +> - `ReducerKind.ClientConnected` - Called when a user connects to the SpacetimeDB database. Their identity can be found in the `Sender` value of the `ReducerContext`. +> - `ReducerKind.ClientDisconnected` - Called when a user disconnects from the SpacetimeDB database. ::: Publish your module again by running: @@ -462,7 +462,7 @@ This will generate a set of files in the `client-unity/Assets/autogen` directory └── SpacetimeDBClient.g.cs ``` -This will also generate a file in the `client-unity/Assets/autogen/SpacetimeDBClient.g.cs` directory with a type aware `DbConnection` class. We will use this class to connect to your module from Unity. +This will also generate a file in the `client-unity/Assets/autogen/SpacetimeDBClient.g.cs` directory with a type aware `DbConnection` class. We will use this class to connect to your database from Unity. > IMPORTANT! At this point there will be an error in your Unity project. Due to a [known issue](https://docs.unity3d.com/6000.0/Documentation/Manual/csharp-compiler.html) with Unity and C# 9 you need to insert the following code into your Unity project. > @@ -475,7 +475,7 @@ This will also generate a file in the `client-unity/Assets/autogen/SpacetimeDBCl > > Add this snippet to the bottom of your `GameManager.cs` file in your Unity project. This will hopefully be resolved in Unity soon. -### Connecting to the Module +### Connecting to the Database At this point we can set up Unity to connect your Unity client to the server. Replace your imports at the top of the `GameManager.cs` file with: diff --git a/docs/unity/part-3.md b/docs/unity/part-3.md index b31cac11..4dfb8e24 100644 --- a/docs/unity/part-3.md +++ b/docs/unity/part-3.md @@ -7,7 +7,7 @@ This progressive tutorial is continued from [part 2](/docs/unity/part-2). ### Spawning Food :::server-rust -Let's start by spawning food into the map. The first thing we need to do is create a new, special reducer called the `init` reducer. SpacetimeDB calls the `init` reducer automatically when first publish your module, and also after any time you run with `publish --delete-data`. It gives you an opportunity to initialize the state of your module before any clients connect. +Let's start by spawning food into the map. The first thing we need to do is create a new, special reducer called the `init` reducer. SpacetimeDB calls the `init` reducer automatically when first publish your module, and also after any time you run with `publish --delete-data`. It gives you an opportunity to initialize the state of your database before any clients connect. Add this new reducer above our `connect` reducer. @@ -78,7 +78,7 @@ pub fn spawn_food(ctx: &ReducerContext) -> Result<(), String> { ``` ::: :::server-csharp -Let's start by spawning food into the map. The first thing we need to do is create a new, special reducer called the `Init` reducer. SpacetimeDB calls the `Init` reducer automatically when you first publish your module, and also after any time you run with `publish --delete-data`. It gives you an opportunity to initialize the state of your module before any clients connect. +Let's start by spawning food into the map. The first thing we need to do is create a new, special reducer called the `Init` reducer. SpacetimeDB calls the `Init` reducer automatically when you first publish your module, and also after any time you run with `publish --delete-data`. It gives you an opportunity to initialize the state of your database before any clients connect. Add this new reducer above our `Connect` reducer. @@ -508,7 +508,7 @@ public static Entity SpawnCircleAt(ReducerContext ctx, uint player_id, uint mass The `EnterGame` reducer takes one argument, the player's `name`. We can use this name to display as a label for the player in the match, by storing the name on the player's row. We are also spawning some circles for the player to control now that they are entering the game. To do this, we choose a random position within the bounds of the arena and create a new entity and corresponding circle row. ::: -Let's also modify our `disconnect` reducer to remove the circles from the arena when the player disconnects from the server. +Let's also modify our `disconnect` reducer to remove the circles from the arena when the player disconnects from the database server. :::server-rust ```rust diff --git a/docs/unity/part-4.md b/docs/unity/part-4.md index 9ec81bd5..7e77fc83 100644 --- a/docs/unity/part-4.md +++ b/docs/unity/part-4.md @@ -579,10 +579,10 @@ We didn't even have to update the client, because our client's `OnDelete` callba Notice that the food automatically respawns as you vaccuum them up. This is because our scheduled reducer is automatically replacing the food 2 times per second, to ensure that there is always 600 food on the map. ## Connecting to Maincloud -- Publish to Maincloud `spacetime publish -s maincloud --delete-data` - - `` This name should be unique and cannot contain any special characters other than internal hyphens (`-`). +- Publish to Maincloud `spacetime publish -s maincloud --delete-data` + - `` This name should be unique and cannot contain any special characters other than internal hyphens (`-`). - Update the URL in the Unity project to: `https://maincloud.spacetimedb.com` -- Update the module name in the Unity project to ``. +- Update the module name in the Unity project to ``. - Clear the PlayerPrefs in Start() within `GameManager.cs` - Your `GameManager.cs` should look something like this: ```csharp @@ -600,7 +600,7 @@ private void Start() } ``` -To delete your Maincloud module, you can run: `spacetime delete -s maincloud ` +To delete your Maincloud database, you can run: `spacetime delete -s maincloud ` # Conclusion @@ -611,7 +611,7 @@ So far you've learned how to configure a new Unity project to work with Spacetim So far you've learned how to configure a new Unity project to work with SpacetimeDB, how to develop, build, and publish a SpacetimeDB server module. Within the module, you've learned how to create tables, update tables, and write reducers. You've learned about special reducers like `ClientConnected` and `Init` and how to created scheduled reducers. You learned how we can used scheduled reducers to implement a physics simulation right within your module. ::: -You've also learned how view module logs and connect your client to your server module, call reducers from the client and synchronize the data with client. Finally you learned how to use that synchronized data to draw game objects on the screen, so that we can interact with them and play a game! +You've also learned how view module logs and connect your client to your database server, call reducers from the client and synchronize the data with client. Finally you learned how to use that synchronized data to draw game objects on the screen, so that we can interact with them and play a game! And all of that completely from scratch! From bf86c345943ef471f5a6afde9871215a7b358d87 Mon Sep 17 00:00:00 2001 From: Tamaro Skaljic <49238587+tamaro-skaljic@users.noreply.github.com> Date: Wed, 16 Apr 2025 22:10:08 +0200 Subject: [PATCH 158/164] Remove experimental label of C# #246 (#305) --- docs/modules/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/modules/index.md b/docs/modules/index.md index 78d60d9c..08d72a92 100644 --- a/docs/modules/index.md +++ b/docs/modules/index.md @@ -15,7 +15,7 @@ Rust is the only fully supported language for server modules. Rust is a great op ### C# -We have C# support available in experimental status. C# can be a good choice for developers who are already using Unity or .net for their client applications. +We have C# support available. C# can be a good choice for developers who are already using Unity or .net for their client applications. - [C# Module Reference](/docs/modules/c-sharp) - [C# Module Quickstart Guide](/docs/modules/c-sharp/quickstart) From 7f23dbed90fd9e9ddf726c9025171a0f6563523f Mon Sep 17 00:00:00 2001 From: Tamaro Skaljic <49238587+tamaro-skaljic@users.noreply.github.com> Date: Wed, 16 Apr 2025 22:48:59 +0200 Subject: [PATCH 159/164] rename http/index.md to "Authorization" #235 (#308) --- docs/http/{index.md => authorization.md} | 0 docs/http/database.md | 18 +++++++++--------- docs/http/identity.md | 6 +++--- docs/nav.js | 2 +- nav.ts | 2 +- 5 files changed, 14 insertions(+), 14 deletions(-) rename docs/http/{index.md => authorization.md} (100%) diff --git a/docs/http/index.md b/docs/http/authorization.md similarity index 100% rename from docs/http/index.md rename to docs/http/authorization.md diff --git a/docs/http/database.md b/docs/http/database.md index de495511..56273f6b 100644 --- a/docs/http/database.md +++ b/docs/http/database.md @@ -30,7 +30,7 @@ Accessible through the CLI as `spacetime publish`. | Name | Value | | --------------- | --------------------------------------------------------------------- | -| `Authorization` | A Spacetime token [as Bearer auth](/docs/http#authorization-headers). | +| `Authorization` | A Spacetime token [as Bearer auth](/docs/http/authorization#authorization-headers). | #### Data @@ -63,7 +63,7 @@ Accessible through the CLI as `spacetime publish`. | Name | Value | | --------------- | --------------------------------------------------------------------- | -| `Authorization` | A Spacetime token [as Bearer auth](/docs/http#authorization-headers). | +| `Authorization` | A Spacetime token [as Bearer auth](/docs/http/authorization#authorization-headers). | #### Data @@ -123,7 +123,7 @@ Accessible through the CLI as `spacetime delete `. | Name | Value | | --------------- | --------------------------------------------------------------------- | -| `Authorization` | A Spacetime token [as Bearer auth](/docs/http#authorization-headers). | +| `Authorization` | A Spacetime token [as Bearer auth](/docs/http/authorization#authorization-headers). | ## `GET /v1/database/:name_or_identity/names` @@ -147,7 +147,7 @@ Add a new name for this database. | Name | Value | | --------------- | --------------------------------------------------------------------- | -| `Authorization` | A Spacetime token [as Bearer auth](/docs/http#authorization-headers). | +| `Authorization` | A Spacetime token [as Bearer auth](/docs/http/authorization#authorization-headers). | #### Data @@ -180,7 +180,7 @@ Set the list of names for this database. | Name | Value | | --------------- | --------------------------------------------------------------------- | -| `Authorization` | A Spacetime token [as Bearer auth](/docs/http#authorization-headers). | +| `Authorization` | A Spacetime token [as Bearer auth](/docs/http/authorization#authorization-headers). | #### Data @@ -233,7 +233,7 @@ The SpacetimeDB text WebSocket protocol, `v1.json.spacetimedb`, encodes messages | Name | Value | | --------------- | --------------------------------------------------------------------- | -| `Authorization` | A Spacetime token [as Bearer auth](/docs/http#authorization-headers). | +| `Authorization` | A Spacetime token [as Bearer auth](/docs/http/authorization#authorization-headers). | ## `POST /v1/database/:name_or_identity/call/:reducer` @@ -249,7 +249,7 @@ Invoke a reducer in a database. | Name | Value | | --------------- | --------------------------------------------------------------------- | -| `Authorization` | A Spacetime token [as Bearer auth](/docs/http#authorization-headers). | +| `Authorization` | A Spacetime token [as Bearer auth](/docs/http/authorization#authorization-headers). | #### Data @@ -409,7 +409,7 @@ Accessible through the CLI as `spacetime logs `. | Name | Value | | --------------- | --------------------------------------------------------------------- | -| `Authorization` | A Spacetime token [as Bearer auth](/docs/http#authorization-headers). | +| `Authorization` | A Spacetime token [as Bearer auth](/docs/http/authorization#authorization-headers). | #### Returns @@ -425,7 +425,7 @@ Accessible through the CLI as `spacetime sql `. | Name | Value | | --------------- | --------------------------------------------------------------------- | -| `Authorization` | A Spacetime token [as Bearer auth](/docs/http#authorization-headers). | +| `Authorization` | A Spacetime token [as Bearer auth](/docs/http/authorization#authorization-headers). | #### Data diff --git a/docs/http/identity.md b/docs/http/identity.md index f3b68b28..222ac1e9 100644 --- a/docs/http/identity.md +++ b/docs/http/identity.md @@ -35,7 +35,7 @@ Generate a short-lived access token which can be used in untrusted contexts, e.g | Name | Value | | --------------- | --------------------------------------------------------------- | -| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http). | +| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http/authorization). | #### Returns @@ -77,7 +77,7 @@ Associate an email with a Spacetime identity. | Name | Value | | --------------- | --------------------------------------------------------------- | -| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http). | +| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http/authorization). | ## `GET /v1/identity/:identity/databases` @@ -115,7 +115,7 @@ Verify the validity of an identity/token pair. | Name | Value | | --------------- | --------------------------------------------------------------- | -| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http). | +| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http/authorization). | #### Returns diff --git a/docs/nav.js b/docs/nav.js index 9e5af152..7f8c0e6d 100644 --- a/docs/nav.js +++ b/docs/nav.js @@ -44,7 +44,7 @@ const nav = { section('How To'), page('Incremental Migrations', 'how-to/incremental-migrations', 'how-to/incremental-migrations.md'), section('HTTP API'), - page('HTTP', 'http', 'http/index.md'), + page('Authorization', 'http/authorization', 'http/authorization.md'), page('`/identity`', 'http/identity', 'http/identity.md'), page('`/database`', 'http/database', 'http/database.md'), section('Internals'), diff --git a/nav.ts b/nav.ts index 4ffa931e..62745c81 100644 --- a/nav.ts +++ b/nav.ts @@ -97,7 +97,7 @@ const nav: Nav = { page('Incremental Migrations', 'how-to/incremental-migrations', 'how-to/incremental-migrations.md'), section('HTTP API'), - page('HTTP', 'http', 'http/index.md'), + page('Authorization', 'http/authorization', 'http/authorization.md'), page('`/identity`', 'http/identity', 'http/identity.md'), page('`/database`', 'http/database', 'http/database.md'), From 50b72988a36924d241a609d82c45234af9177e28 Mon Sep 17 00:00:00 2001 From: Zeke Foppa <196249+bfops@users.noreply.github.com> Date: Thu, 17 Apr 2025 10:36:54 -0700 Subject: [PATCH 160/164] Revert CLI reference to match what the CLI outputs (#317) [bfops/revert-docs]: revert Co-authored-by: Zeke Foppa --- docs/cli-reference.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 4da30b2c..69ebbbd5 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -54,7 +54,7 @@ This document contains the help content for the `spacetime` command-line program * `logout` — * `init` — Initializes a new spacetime project. WARNING: This command is UNSTABLE and subject to breaking changes. * `build` — Builds a spacetime module. -* `server` — Manage the connection to the SpacetimeDB database server. WARNING: This command is UNSTABLE and subject to breaking changes. +* `server` — Manage the connection to the SpacetimeDB server. WARNING: This command is UNSTABLE and subject to breaking changes. * `subscribe` — Subscribe to SQL queries on the database. WARNING: This command is UNSTABLE and subject to breaking changes. * `start` — Start a local SpacetimeDB instance * `version` — Manage installed spacetime versions @@ -83,7 +83,7 @@ Run `spacetime help publish` for more detailed information. ###### Options: -* `-c`, `--delete-data` — When publishing to an existing database identity, first DESTROY all data associated with the database +* `-c`, `--delete-data` — When publishing to an existing database identity, first DESTROY all data associated with the module * `--build-options ` — Options to pass to the build command, for example --build-options='--lint-dir=' Default value: `` @@ -391,7 +391,7 @@ Builds a spacetime module. ## spacetime server -Manage the connection to the SpacetimeDB database server. WARNING: This command is UNSTABLE and subject to breaking changes. +Manage the connection to the SpacetimeDB server. WARNING: This command is UNSTABLE and subject to breaking changes. **Usage:** `spacetime server server ` From d32e61b32bda7650853c3553e67cecec65242a61 Mon Sep 17 00:00:00 2001 From: Zeke Foppa <196249+bfops@users.noreply.github.com> Date: Thu, 17 Apr 2025 10:57:11 -0700 Subject: [PATCH 161/164] CI - Check the CLI docs (#318) * [bfops/cli-ci]: CI - Check the CLI docs * [bfops/cli-ci]: fix * [bfops/cli-ci]: fix permission warnings * [bfops/cli-ci]: review * [bfops/cli-ci]: review --------- Co-authored-by: Zeke Foppa --- .github/workflows/check-cli-reference.yml | 48 +++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 .github/workflows/check-cli-reference.yml diff --git a/.github/workflows/check-cli-reference.yml b/.github/workflows/check-cli-reference.yml new file mode 100644 index 00000000..de4f8597 --- /dev/null +++ b/.github/workflows/check-cli-reference.yml @@ -0,0 +1,48 @@ +on: + pull_request: + workflow_dispatch: + inputs: + ref: + description: 'SpacetimeDB ref' + required: false + default: '' +permissions: read-all + +name: Check CLI docs + +jobs: + cli_docs: + runs-on: ubuntu-latest + steps: + - name: Find Git ref + shell: bash + run: | + echo "GIT_REF=${{ github.event.inputs.ref || 'master' }}" >>"$GITHUB_ENV" + - name: Checkout sources + uses: actions/checkout@v4 + with: + repository: clockworklabs/SpacetimeDB + ref: ${{ env.GIT_REF }} + - uses: dsherret/rust-toolchain-file@v1 + - name: Checkout docs + uses: actions/checkout@v4 + with: + path: spacetime-docs + - name: Check for docs change + run: | + cargo run --features markdown-docs -p spacetimedb-cli > ../spacetime-docs/docs/cli-reference.md + cd spacetime-docs + # This is needed because our website doesn't render markdown quite properly. + # See the README in spacetime-docs for more details. + sed -i'' -E 's!^(##) `(.*)`$!\1 \2!' docs/cli-reference.md + sed -i'' -E 's!^(######) \*\*(.*)\*\*$!\1 \2!' docs/cli-reference.md + git status + if git diff --exit-code HEAD; then + echo "No docs changes detected" + else + echo "It looks like the CLI docs have changed." + echo "These docs are expected to match exactly the helptext generated by the CLI in SpacetimeDB (${{env.GIT_REF}})." + echo "Once a corresponding change has merged in SpacetimeDB, re-run this check." + echo "See https://github.com/clockworklabs/spacetime-docs/#cli-reference-section for more info on how to generate these docs from SpacetimeDB." + exit 1 + fi From 61461beb2f523fcdc964a657bf0ea5b1901f9cb6 Mon Sep 17 00:00:00 2001 From: = Date: Fri, 18 Apr 2025 11:55:26 -0400 Subject: [PATCH 162/164] Updated llms.md for RLS/1.1 --- llms.md | 366 +++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 338 insertions(+), 28 deletions(-) diff --git a/llms.md b/llms.md index bcdf38b4..d214b731 100644 --- a/llms.md +++ b/llms.md @@ -807,46 +807,162 @@ Refer to the [official Rust Module SDK documentation on docs.rs](https://docs.rs Scheduled reducer calls originate from the SpacetimeDB scheduler itself, not from an external client connection. Therefore, within a scheduled reducer, `ctx.sender` will be the module's own identity, and `ctx.connection_id` will be `None`. ::: -#### Row-Level Security (Client Visibility Filters) +#### Row-Level Security (RLS) -(Unstable Feature) +Row Level Security (RLS) allows module authors to restrict which rows of a public table each client can access. +These access rules are expressed in SQL and evaluated automatically for queries and subscriptions. -SpacetimeDB allows defining row-level security rules using the `#[spacetimedb::client_visibility_filter]` attribute. This attribute is applied to a `const` binding of type `Filter` and defines an SQL-like query that determines which rows of a table are visible to clients making subscription requests. +:::info Version-Specific Status +Row-Level Security (RLS) was introduced as an **unstable** feature in **SpacetimeDB v1.1.0**. +It requires explicit opt-in via feature flags or pragmas. +::: + +**Enabling RLS** + +RLS is currently **unstable** and must be explicitly enabled in your module. -* The query uses `:sender` to refer to the identity of the subscribing client. -* Multiple filters on the same table are combined with `OR` logic. -* Query errors (syntax, type errors, unknown tables) are reported during `spacetime publish`. +To enable RLS, activate the `unstable` feature in your project's `Cargo.toml`: + +```toml +spacetimedb = { version = "...", features = ["unstable"] } +``` + +**How It Works** + +RLS rules are expressed in SQL and declared as constants of type `Filter`. ```rust -use spacetimedb::{client_visibility_filter, Filter, table, Identity}; +use spacetimedb::{client_visibility_filter, Filter}; + +/// A client can only see their account +#[client_visibility_filter] +const ACCOUNT_FILTER: Filter = Filter::Sql( + "SELECT * FROM account WHERE identity = :sender" +); +``` + +A module will fail to publish if any of its RLS rules are invalid or malformed. + +**`:sender`** + +You can use the special `:sender` parameter in your rules for user specific access control. +This parameter is automatically bound to the requesting client's [Identity](#identity). + +Note that module owners have unrestricted access to all tables regardless of RLS. + +**Semantic Constraints** + +RLS rules are similar to subscriptions in that logically they act as filters on a particular table. +Also like subscriptions, arbitrary column projections are **not** allowed. +Joins **are** allowed, but each rule must return rows from one and only one table. + +**Multiple Rules Per Table** + +Multiple rules may be declared for the same table and will be evaluated as a logical `OR`. +This means clients will be able to see to any row that matches at least one of the rules. + +**Example** + +```rust +use spacetimedb::{client_visibility_filter, Filter}; + +/// A client can only see their account +#[client_visibility_filter] +const ACCOUNT_FILTER: Filter = Filter::Sql( + "SELECT * FROM account WHERE identity = :sender" +); + +/// An admin can see all accounts +#[client_visibility_filter] +const ACCOUNT_FILTER_FOR_ADMINS: Filter = Filter::Sql( + "SELECT account.* FROM account JOIN admin WHERE admin.identity = :sender" +); +``` + +**Recursive Application** + +RLS rules can reference other tables with RLS rules, and they will be applied recursively. +This ensures that data is never leaked through indirect access patterns. + +**Example** + +```rust +use spacetimedb::{client_visibility_filter, Filter}; + +/// A client can only see their account +#[client_visibility_filter] +const ACCOUNT_FILTER: Filter = Filter::Sql( + "SELECT * FROM account WHERE identity = :sender" +); -#[table(name = "location_state")] -struct LocationState { #[primary_key] entity_id: u64, chunk_index: u32 } -#[table(name = "user_state")] -struct UserState { #[primary_key] identity: Identity, entity_id: u64 } +/// An admin can see all accounts +#[client_visibility_filter] +const ACCOUNT_FILTER_FOR_ADMINS: Filter = Filter::Sql( + "SELECT account.* FROM account JOIN admin WHERE admin.identity = :sender" +); -/// Players can only see entities located in the same chunk as their own entity. +/// Explicitly filtering by client identity in this rule is not necessary, +/// since the above RLS rules on `account` will be applied automatically. +/// Hence a client can only see their player, but an admin can see all players. #[client_visibility_filter] -const PLAYERS_SEE_ENTITIES_IN_SAME_CHUNK: Filter = Filter::Sql(" - SELECT * FROM LocationState WHERE chunk_index IN ( - SELECT chunk_index FROM LocationState WHERE entity_id IN ( - SELECT entity_id FROM UserState WHERE identity = :sender - ) - ) +const PLAYER_FILTER: Filter = Filter::Sql( + "SELECT p.* FROM account a JOIN player p ON a.id = p.id" +); +``` + +And while self-joins are allowed, in general RLS rules cannot be self-referential, +as this would result in infinite recursion. + +**Example: Self-Join** + +```rust +use spacetimedb::{client_visibility_filter, Filter}; + +/// A client can only see players on their same level +#[client_visibility_filter] +const PLAYER_FILTER: Filter = Filter::Sql(" + SELECT q.* + FROM account a + JOIN player p ON u.id = p.id + JOIN player q on p.level = q.level + WHERE a.identity = :sender "); ``` -:::info Version-Specific Status and Usage +**Example: Recursive Rules** -* **SpacetimeDB 1.0:** The Row-Level Security feature was not fully implemented or enforced in version 1.0. Modules developed for SpacetimeDB 1.0 should **not** use this feature. -* **SpacetimeDB 1.1:** The feature is available but considered **unstable** in version 1.1. To use it, you must explicitly opt-in by enabling the `unstable` feature flag for the `spacetimedb` crate in your module's `Cargo.toml`: - ```toml - [dependencies] - spacetimedb = { version = "1.1", features = ["unstable"] } - # ... other dependencies - ``` - Modules developed for 1.1 can use row-level security only if this feature flag is enabled. -::: +This module will fail to publish because each rule depends on the other one. + +```rust +use spacetimedb::{client_visibility_filter, Filter}; + +/// An account must have a corresponding player +#[client_visibility_filter] +const ACCOUNT_FILTER: Filter = Filter::Sql( + "SELECT a.* FROM account a JOIN player p ON a.id = p.id WHERE a.identity = :sender" +); + +/// A player must have a corresponding account +#[client_visibility_filter] +const PLAYER_FILTER: Filter = Filter::Sql( + "SELECT p.* FROM account a JOIN player p ON a.id = p.id WHERE a.identity = :sender" +); +``` + +**Usage in Subscriptions** + +RLS rules automatically apply to subscriptions so that if a client subscribes to a table with RLS filters, +the subscription will only return rows that the client is allowed to see. + +While the constraints and limitations outlined in the [SQL reference docs](/docs/sql/index.md#subscriptions) do not apply to RLS rules, +they do apply to the subscriptions that use them. +For example, it is valid for an RLS rule to have more joins than are supported by subscriptions. +However a client will not be able to subscribe to the table for which that rule is defined. + +**Best Practices** + +1. Use `:sender` for client specific filtering. +2. Follow the [SQL best practices](/docs/sql/index.md#best-practices-for-performance-and-scalability) for optimizing your RLS rules. ### Client SDK (Rust) @@ -1596,6 +1712,200 @@ Throwing an unhandled exception within a C# reducer will cause the transaction t It's generally good practice to validate input and state early in the reducer and `throw` specific exceptions for handled error conditions. +#### Row-Level Security (RLS) + +Row Level Security (RLS) allows module authors to restrict which rows of a public table each client can access. +These access rules are expressed in SQL and evaluated automatically for queries and subscriptions. + +:::info Version-Specific Status +Row-Level Security (RLS) was introduced as an **unstable** feature in **SpacetimeDB v1.1.0**. +It requires explicit opt-in via feature flags or pragmas. +::: + +**Enabling RLS** + +RLS is currently **unstable** and must be explicitly enabled in your module. + +To enable RLS, include the following preprocessor directive at the top of your module files: + +```cs +#pragma warning disable STDB_UNSTABLE +``` + +**How It Works** + +RLS rules are expressed in SQL and declared as public static readonly fields of type `Filter`. + +```cs +using SpacetimeDB; + +#pragma warning disable STDB_UNSTABLE + +public partial class Module +{ + /// + /// A client can only see their account. + /// + [SpacetimeDB.ClientVisibilityFilter] + public static readonly Filter ACCOUNT_FILTER = new Filter.Sql( + "SELECT * FROM account WHERE identity = :sender" + ); +} +``` + +A module will fail to publish if any of its RLS rules are invalid or malformed. + +**`:sender`** + +You can use the special `:sender` parameter in your rules for user specific access control. +This parameter is automatically bound to the requesting client's [Identity](#identity). + +Note that module owners have unrestricted access to all tables regardless of RLS. + +**Semantic Constraints** + +RLS rules are similar to subscriptions in that logically they act as filters on a particular table. +Also like subscriptions, arbitrary column projections are **not** allowed. +Joins **are** allowed, but each rule must return rows from one and only one table. + +**Multiple Rules Per Table** + +Multiple rules may be declared for the same table and will be evaluated as a logical `OR`. +This means clients will be able to see to any row that matches at least one of the rules. + +**Example** + +```cs +using SpacetimeDB; + +#pragma warning disable STDB_UNSTABLE + +public partial class Module +{ + /// + /// A client can only see their account. + /// + [SpacetimeDB.ClientVisibilityFilter] + public static readonly Filter ACCOUNT_FILTER = new Filter.Sql( + "SELECT * FROM account WHERE identity = :sender" + ); + + /// + /// An admin can see all accounts. + /// + [SpacetimeDB.ClientVisibilityFilter] + public static readonly Filter ACCOUNT_FILTER_FOR_ADMINS = new Filter.Sql( + "SELECT account.* FROM account JOIN admin WHERE admin.identity = :sender" + ); +} +``` + +**Recursive Application** + +RLS rules can reference other tables with RLS rules, and they will be applied recursively. +This ensures that data is never leaked through indirect access patterns. + +**Example** + +```cs +using SpacetimeDB; + +public partial class Module +{ + /// + /// A client can only see their account. + /// + [SpacetimeDB.ClientVisibilityFilter] + public static readonly Filter ACCOUNT_FILTER = new Filter.Sql( + "SELECT * FROM account WHERE identity = :sender" + ); + + /// + /// An admin can see all accounts. + /// + [SpacetimeDB.ClientVisibilityFilter] + public static readonly Filter ACCOUNT_FILTER_FOR_ADMINS = new Filter.Sql( + "SELECT account.* FROM account JOIN admin WHERE admin.identity = :sender" + ); + + /// + /// Explicitly filtering by client identity in this rule is not necessary, + /// since the above RLS rules on `account` will be applied automatically. + /// Hence a client can only see their player, but an admin can see all players. + /// + [SpacetimeDB.ClientVisibilityFilter] + public static readonly Filter PLAYER_FILTER = new Filter.Sql( + "SELECT p.* FROM account a JOIN player p ON a.id = p.id" + ); +} +``` + +And while self-joins are allowed, in general RLS rules cannot be self-referential, +as this would result in infinite recursion. + +**Example: Self-Join** + +```cs +using SpacetimeDB; + +public partial class Module +{ + /// + /// A client can only see players on their same level. + /// + [SpacetimeDB.ClientVisibilityFilter] + public static readonly Filter PLAYER_FILTER = new Filter.Sql(@" + SELECT q.* + FROM account a + JOIN player p ON u.id = p.id + JOIN player q on p.level = q.level + WHERE a.identity = :sender + "); +} +``` + +**Example: Recursive Rules** + +This module will fail to publish because each rule depends on the other one. + +```cs +using SpacetimeDB; + +public partial class Module +{ + /// + /// An account must have a corresponding player. + /// + [SpacetimeDB.ClientVisibilityFilter] + public static readonly Filter ACCOUNT_FILTER = new Filter.Sql( + "SELECT a.* FROM account a JOIN player p ON a.id = p.id WHERE a.identity = :sender" + ); + + /// + /// A player must have a corresponding account. + /// + [SpacetimeDB.ClientVisibilityFilter] + public static readonly Filter ACCOUNT_FILTER = new Filter.Sql( + "SELECT p.* FROM account a JOIN player p ON a.id = p.id WHERE a.identity = :sender" + ); +} +``` + +**Usage in Subscriptions** + +RLS rules automatically apply to subscriptions so that if a client subscribes to a table with RLS filters, +the subscription will only return rows that the client is allowed to see. + +While the constraints and limitations outlined in the [SQL reference docs](/docs/sql/index.md#subscriptions) do not apply to RLS rules, +they do apply to the subscriptions that use them. +For example, it is valid for an RLS rule to have more joins than are supported by subscriptions. +However a client will not be able to subscribe to the table for which that rule is defined. + +**Best Practices** + +1. Use `:sender` for client specific filtering. +2. Follow the [SQL best practices](/docs/sql/index.md#best-practices-for-performance-and-scalability) for optimizing your RLS rules. + ### Client SDK (C#) This section details how to build native C# client applications (including Unity games) that interact with a SpacetimeDB module. From 5cbad30cdfb365b31378d57cc14c2a8f1eeacfbd Mon Sep 17 00:00:00 2001 From: = Date: Fri, 18 Apr 2025 14:34:57 -0400 Subject: [PATCH 163/164] Fixed further issues with llms.md based on testing vibe coding, and added some small cursor rules --- .cursor/rules/spacetimedb.md | 4 + llms.md | 238 +++++++++++++++++++++++------------ 2 files changed, 162 insertions(+), 80 deletions(-) create mode 100644 .cursor/rules/spacetimedb.md diff --git a/.cursor/rules/spacetimedb.md b/.cursor/rules/spacetimedb.md new file mode 100644 index 00000000..2bf12e8d --- /dev/null +++ b/.cursor/rules/spacetimedb.md @@ -0,0 +1,4 @@ +- Before implementing SpacetimeDB code, reference the LLMs text and ensure you are using the proper syntax. +- If you aren't certain about the SpacetimeDB implementation, ask the user +- You're executing commands in Powershell, so make sure if you want to execute multiple commands to use ; instead of && +- All your commands that you execute start from the project directory, so make sure you take that into account when you're navigating directories. \ No newline at end of file diff --git a/llms.md b/llms.md index d214b731..50ed1cdb 100644 --- a/llms.md +++ b/llms.md @@ -809,8 +809,16 @@ Scheduled reducer calls originate from the SpacetimeDB scheduler itself, not fro #### Row-Level Security (RLS) -Row Level Security (RLS) allows module authors to restrict which rows of a public table each client can access. -These access rules are expressed in SQL and evaluated automatically for queries and subscriptions. +Row Level Security (RLS) allows module authors to grant clients access to specific rows +of tables that are *not* marked as `public`. By default, tables without the `public` +attribute are private and inaccessible to clients. RLS provides a mechanism to selectively +expose certain rows from these private tables based on rules evaluated for each client. + +Tables marked `public` are always fully visible to any client that subscribes to them, +and RLS rules do not apply to (and cannot restrict access to) `public` tables. + +These access-granting rules are expressed in SQL and evaluated automatically for queries +and subscriptions made by clients against private tables with associated RLS rules. :::info Version-Specific Status Row-Level Security (RLS) was introduced as an **unstable** feature in **SpacetimeDB v1.1.0**. @@ -824,19 +832,32 @@ RLS is currently **unstable** and must be explicitly enabled in your module. To enable RLS, activate the `unstable` feature in your project's `Cargo.toml`: ```toml -spacetimedb = { version = "...", features = ["unstable"] } +spacetimedb = { version = "1.1.0", features = ["unstable"] } # at least version 1.1.0 ``` **How It Works** -RLS rules are expressed in SQL and declared as constants of type `Filter`. +RLS rules are attached to private tables (tables without `#[table(..., public)]`) +and are expressed in SQL using constants of type `Filter`. ```rust -use spacetimedb::{client_visibility_filter, Filter}; +use spacetimedb::{client_visibility_filter, Filter, table, Identity}; + +// Define a private table +#[table(name = account)] // No 'public' flag +struct Account { + #[primary_key] + identity: Identity, + email: String, + balance: u32, +} -/// A client can only see their account +/// RLS Rule: Allow a client to see *only* their own account record. #[client_visibility_filter] -const ACCOUNT_FILTER: Filter = Filter::Sql( +const ACCOUNT_VISIBILITY: Filter = Filter::Sql( + // This query is evaluated per client request. + // :sender is automatically bound to the requesting client's identity. + // Only rows matching this filter are returned to the client from the private 'account' table. "SELECT * FROM account WHERE identity = :sender" ); ``` @@ -845,124 +866,152 @@ A module will fail to publish if any of its RLS rules are invalid or malformed. **`:sender`** -You can use the special `:sender` parameter in your rules for user specific access control. +You can use the special `:sender` parameter in your rules for user-specific access control. This parameter is automatically bound to the requesting client's [Identity](#identity). -Note that module owners have unrestricted access to all tables regardless of RLS. +Note that module owners have unrestricted access to all tables, including private ones, +regardless of RLS rules. **Semantic Constraints** -RLS rules are similar to subscriptions in that logically they act as filters on a particular table. -Also like subscriptions, arbitrary column projections are **not** allowed. -Joins **are** allowed, but each rule must return rows from one and only one table. +RLS rules act as filters defining which rows of a private table are visible to a client. +Like subscriptions, arbitrary column projections are **not** allowed. +Joins **are** allowed (e.g., to check permissions in another table), but each rule must +ultimately return rows from the single private table it applies to. **Multiple Rules Per Table** -Multiple rules may be declared for the same table and will be evaluated as a logical `OR`. -This means clients will be able to see to any row that matches at least one of the rules. +Multiple RLS rules may be declared for the same private table. They are evaluated as a +logical `OR`, meaning clients can see any row that matches at least one rule. -**Example** +**Example (Building on previous Account table)** ```rust -use spacetimedb::{client_visibility_filter, Filter}; +# use spacetimedb::{client_visibility_filter, Filter, table, Identity}; +# #[table(name = account)] struct Account { #[primary_key] identity: Identity, email: String, balance: u32 } +// Assume an 'admin' table exists to track administrator identities +#[table(name = admin)] struct Admin { #[primary_key] identity: Identity } -/// A client can only see their account +/// RLS Rule 1: A client can see their own account. #[client_visibility_filter] -const ACCOUNT_FILTER: Filter = Filter::Sql( +const ACCOUNT_OWNER_VISIBILITY: Filter = Filter::Sql( "SELECT * FROM account WHERE identity = :sender" ); -/// An admin can see all accounts +/// RLS Rule 2: An admin client can see *all* accounts. #[client_visibility_filter] -const ACCOUNT_FILTER_FOR_ADMINS: Filter = Filter::Sql( +const ACCOUNT_ADMIN_VISIBILITY: Filter = Filter::Sql( + // This join checks if the requesting client (:sender) exists in the admin table. + // If they do, the join succeeds, and all rows from 'account' are potentially visible. "SELECT account.* FROM account JOIN admin WHERE admin.identity = :sender" ); + +// Result: A non-admin client sees only their own account row. +// An admin client sees all account rows because they match the second rule. ``` **Recursive Application** -RLS rules can reference other tables with RLS rules, and they will be applied recursively. -This ensures that data is never leaked through indirect access patterns. +RLS rules can reference other tables that might *also* have RLS rules. These rules are applied recursively. +For instance, if Rule A depends on Table B, and Table B has its own RLS rules, a client only gets results +from Rule A if they also have permission to see the relevant rows in Table B according to Table B's rules. +This ensures data from private tables is not inadvertently leaked through indirect access patterns. -**Example** +**Example (Building on previous Account/Admin tables)** ```rust -use spacetimedb::{client_visibility_filter, Filter}; - -/// A client can only see their account +# use spacetimedb::{client_visibility_filter, Filter, table, Identity}; +# #[table(name = account)] struct Account { #[primary_key] identity: Identity, email: String, balance: u32 } +# #[table(name = admin)] struct Admin { #[primary_key] identity: Identity } +// Define a private player table linked to account +#[table(name = player)] // Private table +struct Player { #[primary_key] id: Identity, level: u32 } + +# /// RLS Rule 1: A client can see their own account. +# #[client_visibility_filter] const ACCOUNT_OWNER_VISIBILITY: Filter = Filter::Sql( "SELECT * FROM account WHERE identity = :sender" ); +# /// RLS Rule 2: An admin client can see *all* accounts. +# #[client_visibility_filter] const ACCOUNT_ADMIN_VISIBILITY: Filter = Filter::Sql( "SELECT account.* FROM account JOIN admin WHERE admin.identity = :sender" ); + +/// RLS Rule for Player table: Players are visible if the associated account is visible. #[client_visibility_filter] -const ACCOUNT_FILTER: Filter = Filter::Sql( - "SELECT * FROM account WHERE identity = :sender" -); - -/// An admin can see all accounts -#[client_visibility_filter] -const ACCOUNT_FILTER_FOR_ADMINS: Filter = Filter::Sql( - "SELECT account.* FROM account JOIN admin WHERE admin.identity = :sender" -); - -/// Explicitly filtering by client identity in this rule is not necessary, -/// since the above RLS rules on `account` will be applied automatically. -/// Hence a client can only see their player, but an admin can see all players. -#[client_visibility_filter] -const PLAYER_FILTER: Filter = Filter::Sql( - "SELECT p.* FROM account a JOIN player p ON a.id = p.id" +const PLAYER_VISIBILITY: Filter = Filter::Sql( + // This rule joins Player with Account. + // Crucially, the client running this query must *also* satisfy the RLS rules + // defined for the `account` table for the specific account row being joined. + // Therefore, non-admins see only their own player, admins see all players. + "SELECT p.* FROM account a JOIN player p ON a.identity = p.id" ); ``` -And while self-joins are allowed, in general RLS rules cannot be self-referential, -as this would result in infinite recursion. +Self-joins are allowed within RLS rules. However, RLS rules cannot be mutually recursive +(e.g., Rule A depends on Table B, and Rule B depends on Table A), as this would cause +infinite recursion during evaluation. -**Example: Self-Join** +**Example: Self-Join (Valid)** ```rust -use spacetimedb::{client_visibility_filter, Filter}; +# use spacetimedb::{client_visibility_filter, Filter, table, Identity}; +# #[table(name = player)] struct Player { #[primary_key] id: Identity, level: u32 } +# // Dummy account table for join context +# #[table(name = account)] struct Account { #[primary_key] identity: Identity } -/// A client can only see players on their same level +/// RLS Rule: A client can see other players at the same level as their own player. #[client_visibility_filter] -const PLAYER_FILTER: Filter = Filter::Sql(" +const PLAYER_SAME_LEVEL_VISIBILITY: Filter = Filter::Sql(" SELECT q.* - FROM account a - JOIN player p ON u.id = p.id - JOIN player q on p.level = q.level - WHERE a.identity = :sender + FROM account a -- Find the requester's account + JOIN player p ON a.identity = p.id -- Find the requester's player + JOIN player q on p.level = q.level -- Find other players (q) at the same level + WHERE a.identity = :sender -- Ensure we start with the requester "); ``` -**Example: Recursive Rules** +**Example: Mutually Recursive Rules (Invalid)** -This module will fail to publish because each rule depends on the other one. +This module would fail to publish because the `ACCOUNT_NEEDS_PLAYER` rule depends on the +`player` table, while the `PLAYER_NEEDS_ACCOUNT` rule depends on the `account` table. ```rust -use spacetimedb::{client_visibility_filter, Filter}; +use spacetimedb::{client_visibility_filter, Filter, table, Identity}; + +#[table(name = account)] struct Account { #[primary_key] id: u64, identity: Identity } +#[table(name = player)] struct Player { #[primary_key] id: u64 } -/// An account must have a corresponding player +/// RLS: An account is visible only if a corresponding player exists. #[client_visibility_filter] -const ACCOUNT_FILTER: Filter = Filter::Sql( +const ACCOUNT_NEEDS_PLAYER: Filter = Filter::Sql( "SELECT a.* FROM account a JOIN player p ON a.id = p.id WHERE a.identity = :sender" ); -/// A player must have a corresponding account +/// RLS: A player is visible only if a corresponding account exists. #[client_visibility_filter] -const PLAYER_FILTER: Filter = Filter::Sql( +const PLAYER_NEEDS_ACCOUNT: Filter = Filter::Sql( + // This rule requires access to 'account', which itself requires access to 'player' -> recursion! "SELECT p.* FROM account a JOIN player p ON a.id = p.id WHERE a.identity = :sender" ); ``` **Usage in Subscriptions** -RLS rules automatically apply to subscriptions so that if a client subscribes to a table with RLS filters, -the subscription will only return rows that the client is allowed to see. +When a client subscribes to a table that has RLS rules defined (implicitly, a private table), +the server automatically applies those rules. The subscription results (both initial +and subsequent updates) will only contain rows that the specific client is allowed to +see based on the RLS rules evaluating successfully for that client. -While the constraints and limitations outlined in the [SQL reference docs](/docs/sql/index.md#subscriptions) do not apply to RLS rules, -they do apply to the subscriptions that use them. -For example, it is valid for an RLS rule to have more joins than are supported by subscriptions. -However a client will not be able to subscribe to the table for which that rule is defined. +While the SQL constraints and limitations outlined in the [SQL reference docs](/docs/sql/index.md#subscriptions) +(like limitations on complex joins or aggregations) do not apply directly to the definition +of RLS rules themselves, these constraints *do* apply to client subscriptions that *use* those rules. +For example, an RLS rule might use a complex join not normally supported in subscriptions. +If a client tries to subscribe directly to the table governed by that complex RLS rule, +the subscription itself might fail, even if the RLS rule is valid for direct queries. **Best Practices** -1. Use `:sender` for client specific filtering. -2. Follow the [SQL best practices](/docs/sql/index.md#best-practices-for-performance-and-scalability) for optimizing your RLS rules. +1. Define RLS rules primarily for tables that should be private by default. +2. Use `:sender` for client-specific filtering within your rules. +3. Keep RLS rules as simple as possible while enforcing desired access. +4. Be mindful of potential performance implications of complex joins in RLS rules, especially when combined with subscriptions. +5. Follow the general [SQL best practices](/docs/sql/index.md#best-practices-for-performance-and-scalability) for optimizing your RLS rules. ### Client SDK (Rust) @@ -1635,7 +1684,7 @@ public static partial class Module [Reducer] public static void SendMessage(ReducerContext ctx, SendMessageSchedule scheduleArgs) { - // Security check! + // Security check is important! if (!ctx.Sender.Equals(ctx.Identity)) { throw new Exception("Reducer SendMessage may not be invoked by clients, only via scheduling."); @@ -1734,20 +1783,35 @@ To enable RLS, include the following preprocessor directive at the top of your m **How It Works** -RLS rules are expressed in SQL and declared as public static readonly fields of type `Filter`. +RLS rules are attached to private tables (tables without `Public = true`) and are +expressed in SQL using public static readonly fields of type `Filter` annotated with +`[SpacetimeDB.ClientVisibilityFilter]`. ```cs using SpacetimeDB; #pragma warning disable STDB_UNSTABLE +// Define a private table +[Table(Name = "account")] // No Public = true +public partial class Account +{ + [PrimaryKey] public Identity Identity; + public string Email = ""; + public uint Balance; +} + public partial class Module { /// - /// A client can only see their account. + /// RLS Rule: Allow a client to see *only* their own account record. + /// This rule applies to the private 'account' table. /// [SpacetimeDB.ClientVisibilityFilter] - public static readonly Filter ACCOUNT_FILTER = new Filter.Sql( + public static readonly Filter ACCOUNT_VISIBILITY = new Filter.Sql( + // This query is evaluated per client request. + // :sender is automatically bound to the requesting client's identity. + // Only rows matching this filter are returned to the client from the private 'account' table. "SELECT * FROM account WHERE identity = :sender" ); } @@ -1760,7 +1824,8 @@ A module will fail to publish if any of its RLS rules are invalid or malformed. You can use the special `:sender` parameter in your rules for user specific access control. This parameter is automatically bound to the requesting client's [Identity](#identity). -Note that module owners have unrestricted access to all tables regardless of RLS. +Note that module owners have unrestricted access to all tables, including private ones, +regardless of RLS rules. **Semantic Constraints** @@ -1771,7 +1836,7 @@ Joins **are** allowed, but each rule must return rows from one and only one tabl **Multiple Rules Per Table** Multiple rules may be declared for the same table and will be evaluated as a logical `OR`. -This means clients will be able to see to any row that matches at least one of the rules. +This means clients will be able to see to any row that matches at least one rule. **Example** @@ -2057,11 +2122,11 @@ private void SubscribeToTables(DbConnection conn) { Console.WriteLine("Subscribing to tables..."); conn.SubscriptionBuilder() - .OnApplied(OnSubscriptionApplied) + .OnApplied(on_subscription_applied) .OnError((errCtx, err) => { Console.WriteLine($"Subscription failed: {err.Message}"); }) - // Example: Subscribe to all rows from 'Player' and 'Message' tables + // Example: Subscribe to all rows from 'player' and 'message' tables .Subscribe(new string[] { "SELECT * FROM Player", "SELECT * FROM Message" }); } @@ -2462,6 +2527,15 @@ In TypeScript, the first argument (`ctx: EventContext | undefined`) to row callb Call reducers via `conn.reducers`. Register callbacks via `conn.reducers.onReducerName(...)` to observe outcomes. +* **Invoking:** Access generated reducer functions via `conn.reducers.reducerName(arg1, arg2, ...)`. Calling these functions sends the request to the server. +* **Reducer Callbacks:** Register callbacks using `conn.reducers.onReducerName((ctx: ReducerEventContext, arg1, ...) => { ... })` to react to the *outcome* of reducer calls initiated by *any* client (including your own). +* **ReducerEventContext (`ctx`)**: Contains information about the completed reducer call: + * `ctx.event.reducer`: The specific reducer variant record and its arguments. + * `ctx.event.status`: An object indicating the outcome. Check `ctx.event.status.tag` which will be a string like `"Committed"` or `"Failed"`. If failed, the reason is typically in `ctx.event.status.value`. + * `ctx.event.callerIdentity`: The `Identity` of the client that originally invoked the reducer. + * `ctx.event.message`: Contains the failure message if `ctx.event.status.tag === "Failed"`. + * `ctx.event.timestamp`, etc. + ```typescript // Part of the ChatClient class private registerReducerCallbacks() { @@ -2475,13 +2549,17 @@ private registerReducerCallbacks() { } private handleSendMessageResult(ctx: ReducerEventContext, messageText: string) { - const wasOurCall = ctx.reducerEvent.callerIdentity.isEqual(this.identity); - if (!wasOurCall) return; // Only care about our own calls here + // Check if this callback corresponds to a call made by this client instance + const wasOurCall = ctx.event.reducer.callerIdentity.isEqual(this.identity); + if (!wasOurCall) return; // Optional: Only react to your own calls - if (ctx.reducerEvent.status === Status.Committed) { + // Check the status tag + if (ctx.event.status.tag === "Committed") { console.log(`Our message "${messageText}" sent successfully.`); - } else if (ctx.reducerEvent.status.isFailed()) { - console.error(`Failed to send "${messageText}": ${ctx.reducerEvent.status.getFailedMessage()}`); + } else if (ctx.event.status.tag === "Failed") { + // Access the error message via status.value or event.message + const errorMessage = ctx.event.status.value || ctx.event.message || "Unknown error"; + console.error(`Failed to send "${messageText}": ${errorMessage}`); } } From 406f0fa04edb03291b10d3f33dbc158318ca2ae9 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Thu, 5 Jun 2025 13:30:56 +0200 Subject: [PATCH 164/164] Corrected erroneous assertion that RLS rules should be applied to private tables --- llms.md | 59 +++++++++++++++++++++++++++++++-------------------------- 1 file changed, 32 insertions(+), 27 deletions(-) diff --git a/llms.md b/llms.md index 50ed1cdb..5a952845 100644 --- a/llms.md +++ b/llms.md @@ -809,13 +809,16 @@ Scheduled reducer calls originate from the SpacetimeDB scheduler itself, not fro #### Row-Level Security (RLS) -Row Level Security (RLS) allows module authors to grant clients access to specific rows -of tables that are *not* marked as `public`. By default, tables without the `public` -attribute are private and inaccessible to clients. RLS provides a mechanism to selectively -expose certain rows from these private tables based on rules evaluated for each client. +Row Level Security (RLS) allows module authors to restrict client access to specific rows +of tables that are marked as `public`. By default, tables *without* the `public` +attribute are private and completely inaccessible to clients. Tables *with* the `public` +attribute are, by default, fully visible to any client that subscribes to them. RLS provides +a mechanism to selectively restrict access to certain rows of these `public` tables based +on rules evaluated for each client. -Tables marked `public` are always fully visible to any client that subscribes to them, -and RLS rules do not apply to (and cannot restrict access to) `public` tables. +Private tables (those *without* the `public` attribute) are always completely inaccessible +to clients, and RLS rules do not apply to them. RLS rules are defined for `public` tables +to filter which rows are visible. These access-granting rules are expressed in SQL and evaluated automatically for queries and subscriptions made by clients against private tables with associated RLS rules. @@ -837,14 +840,14 @@ spacetimedb = { version = "1.1.0", features = ["unstable"] } # at least version **How It Works** -RLS rules are attached to private tables (tables without `#[table(..., public)]`) +RLS rules are attached to `public` tables (tables with `#[table(..., public)]`) and are expressed in SQL using constants of type `Filter`. ```rust use spacetimedb::{client_visibility_filter, Filter, table, Identity}; -// Define a private table -#[table(name = account)] // No 'public' flag +// Define a public table for RLS +#[table(name = account, public)] // Now a public table struct Account { #[primary_key] identity: Identity, @@ -857,7 +860,8 @@ struct Account { const ACCOUNT_VISIBILITY: Filter = Filter::Sql( // This query is evaluated per client request. // :sender is automatically bound to the requesting client's identity. - // Only rows matching this filter are returned to the client from the private 'account' table. + // Only rows matching this filter are returned to the client from the public 'account' table, + // overriding its default full visibility for matching clients. "SELECT * FROM account WHERE identity = :sender" ); ``` @@ -869,19 +873,19 @@ A module will fail to publish if any of its RLS rules are invalid or malformed. You can use the special `:sender` parameter in your rules for user-specific access control. This parameter is automatically bound to the requesting client's [Identity](#identity). -Note that module owners have unrestricted access to all tables, including private ones, -regardless of RLS rules. +Note that module owners have unrestricted access to all tables, including all rows of +`public` tables (bypassing RLS rules) and `private` tables. **Semantic Constraints** -RLS rules act as filters defining which rows of a private table are visible to a client. +RLS rules act as filters defining which rows of a `public` table are visible to a client. Like subscriptions, arbitrary column projections are **not** allowed. Joins **are** allowed (e.g., to check permissions in another table), but each rule must -ultimately return rows from the single private table it applies to. +ultimately return rows from the single public table it applies to. **Multiple Rules Per Table** -Multiple RLS rules may be declared for the same private table. They are evaluated as a +Multiple RLS rules may be declared for the same `public` table. They are evaluated as a logical `OR`, meaning clients can see any row that matches at least one rule. **Example (Building on previous Account table)** @@ -915,7 +919,7 @@ const ACCOUNT_ADMIN_VISIBILITY: Filter = Filter::Sql( RLS rules can reference other tables that might *also* have RLS rules. These rules are applied recursively. For instance, if Rule A depends on Table B, and Table B has its own RLS rules, a client only gets results from Rule A if they also have permission to see the relevant rows in Table B according to Table B's rules. -This ensures data from private tables is not inadvertently leaked through indirect access patterns. +This ensures that the intended row visibility on `public` tables is maintained even through indirect access patterns. **Example (Building on previous Account/Admin tables)** @@ -993,7 +997,7 @@ const PLAYER_NEEDS_ACCOUNT: Filter = Filter::Sql( **Usage in Subscriptions** -When a client subscribes to a table that has RLS rules defined (implicitly, a private table), +When a client subscribes to a `public` table that has RLS rules defined, the server automatically applies those rules. The subscription results (both initial and subsequent updates) will only contain rows that the specific client is allowed to see based on the RLS rules evaluating successfully for that client. @@ -1007,7 +1011,7 @@ the subscription itself might fail, even if the RLS rule is valid for direct que **Best Practices** -1. Define RLS rules primarily for tables that should be private by default. +1. Define RLS rules for `public` tables where you need to restrict row visibility for different clients. 2. Use `:sender` for client-specific filtering within your rules. 3. Keep RLS rules as simple as possible while enforcing desired access. 4. Be mindful of potential performance implications of complex joins in RLS rules, especially when combined with subscriptions. @@ -1783,8 +1787,8 @@ To enable RLS, include the following preprocessor directive at the top of your m **How It Works** -RLS rules are attached to private tables (tables without `Public = true`) and are -expressed in SQL using public static readonly fields of type `Filter` annotated with +RLS rules are attached to `public` tables (tables with `#[table(..., public)]`) +and are expressed in SQL using public static readonly fields of type `Filter` annotated with `[SpacetimeDB.ClientVisibilityFilter]`. ```cs @@ -1792,8 +1796,8 @@ using SpacetimeDB; #pragma warning disable STDB_UNSTABLE -// Define a private table -[Table(Name = "account")] // No Public = true +// Define a public table for RLS +[Table(Name = "account", Public = true)] // Ensures correct C# syntax for public table public partial class Account { [PrimaryKey] public Identity Identity; @@ -1805,13 +1809,14 @@ public partial class Module { /// /// RLS Rule: Allow a client to see *only* their own account record. - /// This rule applies to the private 'account' table. + /// This rule applies to the public 'account' table. /// [SpacetimeDB.ClientVisibilityFilter] public static readonly Filter ACCOUNT_VISIBILITY = new Filter.Sql( // This query is evaluated per client request. // :sender is automatically bound to the requesting client's identity. - // Only rows matching this filter are returned to the client from the private 'account' table. + // Only rows matching this filter are returned to the client from the public 'account' table, + // overriding its default full visibility for matching clients. "SELECT * FROM account WHERE identity = :sender" ); } @@ -1824,8 +1829,8 @@ A module will fail to publish if any of its RLS rules are invalid or malformed. You can use the special `:sender` parameter in your rules for user specific access control. This parameter is automatically bound to the requesting client's [Identity](#identity). -Note that module owners have unrestricted access to all tables, including private ones, -regardless of RLS rules. +Note that module owners have unrestricted access to all tables, including all rows of +`public` tables (bypassing RLS rules) and `private` tables. **Semantic Constraints** @@ -1835,7 +1840,7 @@ Joins **are** allowed, but each rule must return rows from one and only one tabl **Multiple Rules Per Table** -Multiple rules may be declared for the same table and will be evaluated as a logical `OR`. +Multiple rules may be declared for the same `public` table. They are evaluated as a logical `OR`. This means clients will be able to see to any row that matches at least one rule. **Example**