From 6731f294996436cc573afc9d9669806a0820ecf1 Mon Sep 17 00:00:00 2001 From: Nathaniel Richards Date: Mon, 18 Sep 2023 12:25:05 -0400 Subject: [PATCH 001/195] First commit --- docs/.gitignore | 5 + docs/README.md | 40 + docs/docs-config.ts | 145 +++ .../Client SDK Languages/C#/SDK Reference.md | 932 +++++++++++++ .../Client SDK Languages/C#/_category.json | 5 + docs/docs/Client SDK Languages/C#/index.md | 425 ++++++ .../Python/SDK Reference.md | 525 ++++++++ .../Python/_category.json | 5 + .../docs/Client SDK Languages/Python/index.md | 377 ++++++ .../Rust/SDK Reference.md | 1153 +++++++++++++++++ .../Client SDK Languages/Rust/_category.json | 5 + docs/docs/Client SDK Languages/Rust/index.md | 483 +++++++ .../Typescript/SDK Reference.md | 805 ++++++++++++ .../Typescript/_category.json | 5 + .../Client SDK Languages/Typescript/index.md | 500 +++++++ docs/docs/Client SDK Languages/_category.json | 1 + docs/docs/Client SDK Languages/index.md | 74 ++ docs/docs/Cloud Testnet/_category.json | 1 + docs/docs/Cloud Testnet/index.md | 34 + docs/docs/Getting Started/_category.json | 1 + docs/docs/Getting Started/index.md | 36 + docs/docs/HTTP API Reference/Databases.md | 589 +++++++++ docs/docs/HTTP API Reference/Energy.md | 76 ++ docs/docs/HTTP API Reference/Identities.md | 160 +++ docs/docs/HTTP API Reference/_category.json | 1 + docs/docs/HTTP API Reference/index.md | 51 + docs/docs/Module ABI Reference/_category.json | 1 + docs/docs/Module ABI Reference/index.md | 499 +++++++ docs/docs/Overview/_category.json | 1 + docs/docs/Overview/index.md | 114 ++ docs/docs/SATN Reference/Binary Format.md | 115 ++ docs/docs/SATN Reference/_category.json | 1 + docs/docs/SATN Reference/index.md | 163 +++ docs/docs/SQL Reference/_category.json | 1 + docs/docs/SQL Reference/index.md | 407 ++++++ .../C#/ModuleReference.md | 311 +++++ .../Server Module Languages/C#/_category.json | 6 + docs/docs/Server Module Languages/C#/index.md | 292 +++++ .../Rust/ModuleReference.md | 454 +++++++ .../Rust/_category.json | 5 + .../Server Module Languages/Rust/index.md | 272 ++++ .../Server Module Languages/_category.json | 1 + docs/docs/Server Module Languages/index.md | 30 + .../Part 2 - Resources And Scheduling.md | 255 ++++ .../Unity Tutorial/Part 3 - BitCraft Mini.md | 102 ++ docs/docs/Unity Tutorial/_category.json | 1 + docs/docs/Unity Tutorial/index.md | 917 +++++++++++++ .../WebSocket API Reference/_category.json | 1 + docs/docs/WebSocket API Reference/index.md | 322 +++++ docs/package.json | 25 + docs/spacetime-docs.json | 17 + docs/src/index.ts | 200 +++ docs/src/types.ts | 23 + docs/tsconfig.json | 13 + docs/yarn.lock | 56 + 55 files changed, 11039 insertions(+) create mode 100644 docs/.gitignore create mode 100644 docs/README.md create mode 100644 docs/docs-config.ts create mode 100644 docs/docs/Client SDK Languages/C#/SDK Reference.md create mode 100644 docs/docs/Client SDK Languages/C#/_category.json create mode 100644 docs/docs/Client SDK Languages/C#/index.md create mode 100644 docs/docs/Client SDK Languages/Python/SDK Reference.md create mode 100644 docs/docs/Client SDK Languages/Python/_category.json create mode 100644 docs/docs/Client SDK Languages/Python/index.md create mode 100644 docs/docs/Client SDK Languages/Rust/SDK Reference.md create mode 100644 docs/docs/Client SDK Languages/Rust/_category.json create mode 100644 docs/docs/Client SDK Languages/Rust/index.md create mode 100644 docs/docs/Client SDK Languages/Typescript/SDK Reference.md create mode 100644 docs/docs/Client SDK Languages/Typescript/_category.json create mode 100644 docs/docs/Client SDK Languages/Typescript/index.md create mode 100644 docs/docs/Client SDK Languages/_category.json create mode 100644 docs/docs/Client SDK Languages/index.md create mode 100644 docs/docs/Cloud Testnet/_category.json create mode 100644 docs/docs/Cloud Testnet/index.md create mode 100644 docs/docs/Getting Started/_category.json create mode 100644 docs/docs/Getting Started/index.md create mode 100644 docs/docs/HTTP API Reference/Databases.md create mode 100644 docs/docs/HTTP API Reference/Energy.md create mode 100644 docs/docs/HTTP API Reference/Identities.md create mode 100644 docs/docs/HTTP API Reference/_category.json create mode 100644 docs/docs/HTTP API Reference/index.md create mode 100644 docs/docs/Module ABI Reference/_category.json create mode 100644 docs/docs/Module ABI Reference/index.md create mode 100644 docs/docs/Overview/_category.json create mode 100644 docs/docs/Overview/index.md create mode 100644 docs/docs/SATN Reference/Binary Format.md create mode 100644 docs/docs/SATN Reference/_category.json create mode 100644 docs/docs/SATN Reference/index.md create mode 100644 docs/docs/SQL Reference/_category.json create mode 100644 docs/docs/SQL Reference/index.md create mode 100644 docs/docs/Server Module Languages/C#/ModuleReference.md create mode 100644 docs/docs/Server Module Languages/C#/_category.json create mode 100644 docs/docs/Server Module Languages/C#/index.md create mode 100644 docs/docs/Server Module Languages/Rust/ModuleReference.md create mode 100644 docs/docs/Server Module Languages/Rust/_category.json create mode 100644 docs/docs/Server Module Languages/Rust/index.md create mode 100644 docs/docs/Server Module Languages/_category.json create mode 100644 docs/docs/Server Module Languages/index.md create mode 100644 docs/docs/Unity Tutorial/Part 2 - Resources And Scheduling.md create mode 100644 docs/docs/Unity Tutorial/Part 3 - BitCraft Mini.md create mode 100644 docs/docs/Unity Tutorial/_category.json create mode 100644 docs/docs/Unity Tutorial/index.md create mode 100644 docs/docs/WebSocket API Reference/_category.json create mode 100644 docs/docs/WebSocket API Reference/index.md create mode 100644 docs/package.json create mode 100644 docs/spacetime-docs.json create mode 100644 docs/src/index.ts create mode 100644 docs/src/types.ts create mode 100644 docs/tsconfig.json create mode 100644 docs/yarn.lock diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 00000000000..55f71abdcee --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,5 @@ +**/.vscode +.idea +*.log +node_modules +dist \ No newline at end of file diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000000..cfabc943ae3 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,40 @@ +# Spacetime Docs CLI + +## How to use: + +1. run `yarn install` +2. run `yarn build` +3. run `npm i -g .` +4. run `spacetime-docs -h` in your terminal + +## Specify Docs Out Directory + +Create a `spacetime-docs.json` file in your project root and specify the `docPath` property. + +```json +{ + "docPath": "./docs" +} +``` + +## Specify The Sidebar Order + +In the `spacetime-docs.json` file in your project root add: + +> This will respect the order when generating the docs. + +```json + "order": [ + "Overview", + "Getting Started", + "Cloud Testnet", + "Unity Tutorial", + "Server Module Languages", + "Client SDK Languages", + "Module ABI Reference", + "HTTP API Reference", + "WebScoket API Reference", + "SATN Reference", + "SQL Reference" + ] +``` diff --git a/docs/docs-config.ts b/docs/docs-config.ts new file mode 100644 index 00000000000..92c23a1af75 --- /dev/null +++ b/docs/docs-config.ts @@ -0,0 +1,145 @@ +export const docConfig = { + "sections": [ + { + "title": "Overview", + "identifier": "overview", + "comingSoon": false, + "hasPages": false, + "editUrl": "/Overview", + "jumpLinks": [], + "pages": [] + }, + { + "title": "Getting Started", + "identifier": "getting-started", + "comingSoon": false, + "hasPages": false, + "editUrl": "/Getting Started", + "jumpLinks": [], + "pages": [] + }, + { + "title": "Cloud Testnet", + "identifier": "cloud-testnet", + "comingSoon": false, + "hasPages": false, + "editUrl": "/Cloud Testnet", + "jumpLinks": [], + "pages": [] + }, + { + "title": "Unity Tutorial", + "identifier": "unity-tutorial", + "comingSoon": false, + "hasPages": false, + "editUrl": "/Unity Tutorial", + "jumpLinks": [], + "pages": [] + }, + { + "title": "Server Module Languages", + "identifier": "server-module languages", + "comingSoon": false, + "hasPages": true, + "editUrl": "/Server Module Languages", + "jumpLinks": [], + "pages": [ + { + "title": "C#", + "identifier": "c#", + "comingSoon": false, + "hasPages": false, + "editUrl": "/Server Module Languages/C#", + "jumpLinks": [] + }, + { + "title": "Rust", + "identifier": "rust", + "comingSoon": false, + "hasPages": false, + "editUrl": "/Server Module Languages/Rust", + "jumpLinks": [] + } + ] + }, + { + "title": "Client SDK Languages", + "identifier": "client-sdk languages", + "comingSoon": false, + "hasPages": true, + "editUrl": "/Client SDK Languages", + "jumpLinks": [], + "pages": [ + { + "title": "C#", + "identifier": "c#", + "comingSoon": false, + "hasPages": false, + "editUrl": "/Client SDK Languages/C#", + "jumpLinks": [] + }, + { + "title": "Python", + "identifier": "python", + "comingSoon": false, + "hasPages": false, + "editUrl": "/Client SDK Languages/Python", + "jumpLinks": [] + }, + { + "title": "Rust", + "identifier": "rust", + "comingSoon": false, + "hasPages": false, + "editUrl": "/Client SDK Languages/Rust", + "jumpLinks": [] + }, + { + "title": "Typescript", + "identifier": "typescript", + "comingSoon": false, + "hasPages": false, + "editUrl": "/Client SDK Languages/Typescript", + "jumpLinks": [] + } + ] + }, + { + "title": "Module ABI Reference", + "identifier": "module-abi reference", + "comingSoon": false, + "hasPages": false, + "editUrl": "/Module ABI Reference", + "jumpLinks": [], + "pages": [] + }, + { + "title": "HTTP API Reference", + "identifier": "http-api reference", + "comingSoon": false, + "hasPages": false, + "editUrl": "/HTTP API Reference", + "jumpLinks": [], + "pages": [] + }, + { + "title": "SATN Reference", + "identifier": "satn-reference", + "comingSoon": false, + "hasPages": false, + "editUrl": "/SATN Reference", + "jumpLinks": [], + "pages": [] + }, + { + "title": "SQL Reference", + "identifier": "sql-reference", + "comingSoon": false, + "hasPages": false, + "editUrl": "/SQL Reference", + "jumpLinks": [], + "pages": [] + } + ], + "rootEditURL": "https://github.com/clockworklabs/spacetime-docs" +}; \ No newline at end of file diff --git a/docs/docs/Client SDK Languages/C#/SDK Reference.md b/docs/docs/Client SDK Languages/C#/SDK Reference.md new file mode 100644 index 00000000000..3284e6fefc8 --- /dev/null +++ b/docs/docs/Client SDK Languages/C#/SDK Reference.md @@ -0,0 +1,932 @@ +# 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) + - [Static Method `SpacetimeDBClient.CreateInstance`](#static-method-spacetimedbclientcreateinstance) + - [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) + - [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}.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 + +### Using the `dotnet` CLI tool + +If you would like to create a console application using .NET, you can create a new project using `dotnet new console` and add the SpacetimeDB SDK to your dependencies: + +```bash +dotnet add package spacetimedbsdk +``` + +(See also the [CSharp Quickstart](./CSharpSDKQuickStart) for an in-depth example of such a console application.) + +### Using Unity + +To install the SpacetimeDB SDK into a Unity project, download the SpacetimeDB SDK from the following link. + +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).) + +## 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 C# interface files using the Spacetime CLI. From your project directory, run: + +```bash +mkdir -p module_bindings +spacetime generate --lang cs --out-dir module_bindings --project-path PATH-TO-MODULE-DIRECTORY +``` + +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 +namespace SpacetimeDB { + +public class SpacetimeDBClient { + public static SpacetimeDBClient instance; +} + +} +``` + +This is the global instance of a SpacetimeDB client in a particular .NET/Unity process. Much of the SDK is accessible through this instance. + +### Class `NetworkManager` + +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 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. + +### Method `SpacetimeDBClient.Connect` + +```cs +namespace SpacetimeDB { + +class SpacetimeDBClient { + public void Connect( + string? token, + string host, + string addressOrName, + bool sslEnabled = true + ); +} + +} +``` + + + +Connect to a database named `addressOrName` accessible over the internet at the URI `host`. + +| 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`. | + +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). + +```cs +using SpacetimeDB; +using SpacetimeDB.Types; + +const string DBNAME = "chat"; + +// Connect to a local DB with a fresh identity +SpacetimeDBClient.instance.Connect(null, "localhost:3000", DBNAME, false); + +// Connect to cloud with a fresh identity +SpacetimeDBClient.instance.Connect(null, "dev.spacetimedb.net", DBNAME, true); + +// 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) { + AuthToken.SaveToken(authToken); + localIdentity = identity; +} +``` + +(You should probably also store the returned `Identity` somewhere; see the [`onIdentityReceived`](#event-spacetimedbclientonidentityreceived) event.) + +### Event `SpacetimeDBClient.onIdentityReceived` + +```cs +namespace SpacetimeDB { + +class SpacetimeDBClient { + 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. + +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. + +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`. + +```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) { + AuthToken.SaveToken(authToken); + localIdentity = identity; +} +``` + +### Event `SpacetimeDBClient.onConnect` + +```cs +namespace SpacetimeDB { + +class SpacetimeDBClient { + public event Action onConnect; +} + +} +``` + +Allows registering delegates to be invoked upon authentication with the database. + +Once this occurs, the SDK is prepared for calls to [`SpacetimeDBClient.Subscribe`](#method-spacetimedbclientsubscribe). + +## Subscribe to queries + +### Method `SpacetimeDBClient.Subscribe` + +```cs +namespace SpacetimeDB { + +class SpacetimeDBClient { + public void Subscribe(List queries); +} + +} +``` + +| Argument | Type | Meaning | +| --------- | -------------- | ---------------------------- | +| `queries` | `List` | SQL queries to subscribe to. | + +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. + +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. + +```cs +using SpacetimeDB; +using SpacetimeDB.Types; + +void Main() +{ + AuthToken.Init(); + SpacetimeDBClient.CreateInstance(new ConsoleLogger()); + + SpacetimeDBClient.instance.onConnect += OnConnect; + + // Our module contains a table named "Loot" + Loot.OnInsert += Loot_OnInsert; + + SpacetimeDBClient.instance.Connect(/* ... */); +} + +void OnConnect() +{ + SpacetimeDBClient.instance.Subscribe(new List { + "SELECT * FROM Loot" + }); +} + +void Loot_OnInsert( + Loot loot, + ReducerEvent? event +) { + Console.Log($"Loaded loot {loot.itemType} at coordinates {loot.position}"); +} +``` + +### Event `SpacetimeDBClient.onSubscriptionApplied` + +```cs +namespace SpacetimeDB { + +class SpacetimeDBClient { + public event Action onSubscriptionApplied; +} + +} +``` + +Register a delegate to be invoked when a subscription is registered with the database. + +```cs +using SpacetimeDB; + +void OnSubscriptionApplied() +{ + Console.WriteLine("Now listening on queries."); +} + +void Main() +{ + // ...initialize... + SpacetimeDBClient.instance.onSubscriptionApplied += OnSubscriptionApplied; +} +``` + +## 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). + +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. + +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. + +### Class `{TABLE}` + +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. + +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}.Count()`](#static-method-tablecount) counts the number of subscribed rows in the client cache. + +Static Events: + +- [`{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. + +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. + +#### Static Method `{TABLE}.Iter` + +```cs +namespace SpacetimeDB.Types { + +class TABLE { + public static System.Collections.Generic.IEnumerable Iter(); +} + +} +``` + +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. + +```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(/* ... */); +``` + +#### Static Method `{TABLE}.FilterBy{COLUMN}` + +```cs +namespace SpacetimeDB.Types { + +class TABLE { + // If the column has no #[unique] or #[primarykey] constraint + public static System.Collections.Generic.IEnumerable
FilterBySender(COLUMNTYPE value); + + // If the column has a #[unique] or #[primarykey] constraint + public static TABLE? FilterBySender(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 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}>`. + +#### Static Method `{TABLE}.Count` + +```cs +namespace SpacetimeDB.Types { + +class TABLE { + public static int Count(); +} + +} +``` + +Return the number of subscribed rows in the table, or 0 if there is no active connection. + +```cs +using SpacetimeDB; +using SpacetimeDB.Types; + +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(/* ... */); +``` + +#### Static Event `{TABLE}.OnInsert` + +```cs +namespace SpacetimeDB.Types { + +class TABLE { + public delegate void InsertEventHandler( + TABLE insertedValue, + ReducerEvent? dbEvent + ); + public static event InsertEventHandler OnInsert; +} + +} +``` + +Register a delegate for when a subscribed row is newly inserted into the database. + +The delegate takes two arguments: + +- 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. + +```cs +using SpacetimeDB; +using SpacetimeDB.Types; + +/* initialize, subscribe to table User... */ + +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}."); + } +}; +``` + +#### Static Event `{TABLE}.OnBeforeDelete` + +```cs +namespace SpacetimeDB.Types { + +class TABLE { + public delegate void DeleteEventHandler( + TABLE deletedValue, + ReducerEvent dbEvent + ); + public static event DeleteEventHandler OnBeforeDelete; +} + +} +``` + +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 delegate takes two arguments: + +- 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 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. + +```cs +using SpacetimeDB; +using SpacetimeDB.Types; + +/* initialize, subscribe to table User... */ + +User.OnBeforeDelete += (User user, ReducerEvent reducerEvent) => { + Console.WriteLine($"User '{user.Name}' deleted by reducer {reducerEvent.Reducer}."); +}; +``` + +#### Static Event `{TABLE}.OnDelete` + +```cs +namespace SpacetimeDB.Types { + +class TABLE { + public delegate void DeleteEventHandler( + TABLE deletedValue, + SpacetimeDB.ReducerEvent dbEvent + ); + public static event DeleteEventHandler OnDelete; +} + +} +``` + +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: + +- 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. + +```cs +using SpacetimeDB; +using SpacetimeDB.Types; + +/* initialize, subscribe to table User... */ + +User.OnBeforeDelete += (User user, ReducerEvent reducerEvent) => { + Console.WriteLine($"User '{user.Name}' deleted by reducer {reducerEvent.Reducer}."); +}; +``` + +#### Static Event `{TABLE}.OnUpdate` + +```cs +namespace SpacetimeDB.Types { + +class TABLE { + public delegate void UpdateEventHandler( + TABLE oldValue, + TABLE newValue, + ReducerEvent dbEvent + ); + public static event UpdateEventHandler OnUpdate; +} + +} +``` + +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. + +The delegate takes three arguments: + +- 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. + +```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}."); +}; +``` + +## Observe and invoke reducers + +"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. + +`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). + +### Class `Reducer` + +```cs +namespace SpacetimeDB.Types { + +class Reducer {} + +} +``` + +This class contains a static method and event for each reducer defined in a module. + +#### Static Method `Reducer.{REDUCER}` + +```cs +namespace SpacetimeDB.Types { +class Reducer { + +/* void {REDUCER_NAME}(...ARGS...) */ + +} +} +``` + +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`. + +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. + +For reducers which accept a `ReducerContext` as their first argument, the `ReducerContext` is not included in the generated function's argument list. + +For example, if we define a reducer in Rust as follows: + +```rust +#[spacetimedb(reducer)] +pub fn set_name( + ctx: ReducerContext, + user_id: u64, + name: String +) -> Result<(), Error>; +``` + +The following C# static method will be generated: + +```cs +namespace SpacetimeDB.Types { +class Reducer { + +public static void SendMessage(UInt64 userId, string name); + +} +} +``` + +#### Static Event `Reducer.On{REDUCER}` + +```cs +namespace SpacetimeDB.Types { +class Reducer { + +public delegate void /*{REDUCER}*/Handler(ReducerEvent reducerEvent, /* {ARGS...} */); + +public static event /*{REDUCER}*/Handler On/*{REDUCER}*/Event; + +} +} +``` + +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. + +For example, if we define a reducer in Rust as follows: + +```rust +#[spacetimedb(reducer)] +pub fn set_name( + ctx: ReducerContext, + user_id: u64, + name: String +) -> Result<(), Error>; +``` + +The following C# static method will be generated: + +```cs +namespace SpacetimeDB.Types { +class Reducer { + +public delegate void SetNameHandler( + ReducerEvent reducerEvent, + UInt64 userId, + string name +); +public static event SetNameHandler OnSetNameEvent; + +} +} +``` + +Which can be used as follows: + +```cs +/* initialize, wait for onSubscriptionApplied... */ + +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); +``` + +### Class `ReducerEvent` + +`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`. + +For example, the example project shown in the Rust Module quickstart will generate the following (abridged) code. + +```cs +namespace SpacetimeDB.Types { + +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; } +} + +} +``` + +#### Enum `Status` + +```cs +namespace ClientApi { +public sealed partial class Event { +public static partial class Types { + +public enum Status { + Committed = 0, + Failed = 1, + OutOfEnergy = 2, +} + +} +} +} +``` + +An enum whose variants represent possible reducer completion statuses of a reducer invocation. + +##### Variant `Status.Committed` + +The reducer finished successfully, and its row changes were committed to the database. + +##### Variant `Status.Failed` + +The reducer failed, either by panicking or returning a `Err`. + +##### Variant `Status.OutOfEnergy` + +The reducer was canceled because the module owner had insufficient energy to allow it to run to completion. + +## Identity management + +### Class `AuthToken` + +The AuthToken helper class handles creating and saving SpacetimeDB identity tokens in the filesystem. + +#### Static Method `AuthToken.Init` + +```cs +namespace SpacetimeDB { + +class AuthToken { + public static void Init( + string configFolder = ".spacetime_csharp_sdk", + string configFile = "settings.ini", + string? configRoot = null + ); +} + +} +``` + +Creates a file `$"{configRoot}/{configFolder}/{configFile}"` to store tokens. +If no arguments are passed, the default is `"%HOME%/.spacetime_csharp_sdk/settings.ini"`. + +| 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. | + +#### Static Property `AuthToken.Token` + +```cs +namespace SpacetimeDB { + +class AuthToken { + public static string? Token { get; } +} + +} +``` + +The auth token stored on the filesystem, if one exists. + +#### Static Method `AuthToken.SaveToken` + +```cs +namespace SpacetimeDB { + +class AuthToken { + public static void SaveToken(string token); +} + +} +``` + +Save a token to the filesystem. + +### Class `Identity` + +```cs +namespace SpacetimeDB { + +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. + +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. + +## 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. + +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). + +Outside of unity, all you need to do is the following: + +```cs +using SpacetimeDB; +using SpacetimeDB.Types; +SpacetimeDBClient.CreateInstance(new ConsoleLogger()); +``` + +### Interface `ISpacetimeDBLogger` + +```cs +namespace SpacetimeDB +{ + +public interface ISpacetimeDBLogger +{ + void Log(string message); + void LogError(string message); + void LogWarning(string message); + void LogException(Exception e); +} + +} +``` + +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. + +### Class `ConsoleLogger` + +```cs +namespace SpacetimeDB { + +public class ConsoleLogger : ISpacetimeDBLogger {} + +} +``` + +An `ISpacetimeDBLogger` implementation for regular .NET applications, using `Console.Write` when logs are received. + +### Class `UnityDebugLogger` + +```cs +namespace SpacetimeDB { + +public class UnityDebugLogger : ISpacetimeDBLogger {} + +} +``` + +An `ISpacetimeDBLogger` implementation for Unity, using the Unity `Debug.Log` api. diff --git a/docs/docs/Client SDK Languages/C#/_category.json b/docs/docs/Client SDK Languages/C#/_category.json new file mode 100644 index 00000000000..60238f8ed6f --- /dev/null +++ b/docs/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/docs/Client SDK Languages/C#/index.md b/docs/docs/Client SDK Languages/C#/index.md new file mode 100644 index 00000000000..b64ca13d5ab --- /dev/null +++ b/docs/docs/Client SDK Languages/C#/index.md @@ -0,0 +1,425 @@ +# 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#. + +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. + +## 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: + +```bash +cd quickstart-chat +``` + +Within it, create a new C# console application project called `client` using either Visual Studio or the .NET CLI: + +```bash +dotnet new console -o client +``` + +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 + +```bash +dotnet add package spacetimedbsdk +``` + +## 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/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: + +``` +module_bindings +├── Message.cs +├── ReducerEvent.cs +├── SendMessageReducer.cs +├── SetNameReducer.cs +└── User.cs +``` + +## Add imports to Program.cs + +Open `client/Program.cs` and add the following imports: + +```csharp +using SpacetimeDB; +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`: + +```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(); +``` + +## 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 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. +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. + +```csharp +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 + var thread = new Thread(ProcessThread); + thread.Start(); + + InputLoop(); + + // this signals the ProcessThread to stop + cancel_token.Cancel(); + thread.Join(); +} +``` + +## Register callbacks + +We need to handle several sorts of events: + +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. + +```csharp +void RegisterCallbacks() +{ + SpacetimeDBClient.instance.onConnect += OnConnect; + SpacetimeDBClient.instance.onIdentityReceived += OnIdentityReceived; + SpacetimeDBClient.instance.onSubscriptionApplied += OnSubscriptionApplied; + + User.OnInsert += User_OnInsert; + User.OnUpdate += User_OnUpdate; + + Message.OnInsert += Message_OnInsert; + + Reducer.OnSetNameEvent += Reducer_OnSetNameEvent; + Reducer.OnSendMessageEvent += Reducer_OnSendMessageEvent; +} +``` + +### 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 `OnInsert` and `OnDelete` methods, which are automatically generated 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.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. + +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); + +void User_OnInsert(User insertedValue, ReducerEvent? dbEvent) +{ + if(insertedValue.Online) + { + Console.WriteLine($"{UserNameOrIdentity(insertedValue)} is online"); + } +} +``` + +### 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. + +`OnUpdate` callbacks take three arguments: the old row, the new row, and a `ReducerEvent`. + +In our module, users can be updated for three reasons: + +1. They've set their name using the `SetName` 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. + +```csharp +void User_OnUpdate(User oldValue, User newValue, ReducerEvent dbEvent) +{ + if(oldValue.Name != newValue.Name) + { + Console.WriteLine($"{UserNameOrIdentity(oldValue)} renamed to {newValue.Name}"); + } + if(oldValue.Online != newValue.Online) + { + if(newValue.Online) + { + Console.WriteLine($"{UserNameOrIdentity(newValue)} connected."); + } + else + { + Console.WriteLine($"{UserNameOrIdentity(newValue)} disconnected."); + } + } +} +``` + +### 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 `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. + +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 senderName = "unknown"; + if(sender != null) + { + senderName = UserNameOrIdentity(sender); + } + + Console.WriteLine($"{senderName}: {message.Text}"); +} + +void Message_OnInsert(Message insertedValue, ReducerEvent? dbEvent) +{ + if(dbEvent != null) + { + PrintMessage(insertedValue); + } +} +``` + +### 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 `OnReducerEvent` method of the `Reducer` namespace, which is automatically implemented for each reducer by `spacetime generate`. + +Each reducer callback takes one fixed argument: + +The ReducerEvent that triggered the callback. It contains several fields. The ones we care about are: + +1. 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. + +It also takes a variable amount of additional arguments that match the reducer's arguments. + +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 `SetName` invocations using our `User.OnUpdate` 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 `Reducer_OnSetNameEvent` as a `Reducer.OnSetNameEvent` 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. + +```csharp +void Reducer_OnSetNameEvent(ReducerEvent reducerEvent, string name) +{ + if(reducerEvent.Identity == local_identity && reducerEvent.Status == ClientApi.Event.Types.Status.Failed) + { + Console.Write($"Failed to change name to {name}"); + } +} +``` + +### 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. + +```csharp +void Reducer_OnSendMessageEvent(ReducerEvent reducerEvent, string text) +{ + if (reducerEvent.Identity == local_identity && reducerEvent.Status == ClientApi.Event.Types.Status.Failed) + { + Console.Write($"Failed to send message {text}"); + } +} +``` + +## Connect callback + +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. + +```csharp +void OnConnect() +{ + SpacetimeDBClient.instance.Subscribe(new List { "SELECT * FROM User", "SELECT * FROM Message" }); +} +``` + +## 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. + +```csharp +void OnIdentityReceived(string authToken, Identity identity) +{ + local_identity = identity; + AuthToken.SaveToken(authToken); +} +``` + +## OnSubscriptionApplied callback + +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. + +```csharp +void PrintMessagesInOrder() +{ + foreach (Message message in Message.Iter().OrderBy(item => item.Sent)) + { + PrintMessage(message); + } +} + +void OnSubscriptionApplied() +{ + Console.WriteLine("Connected"); + PrintMessagesInOrder(); +} +``` + + + +## 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. + +`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. + +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. + +3. Finally, Close the connection to the module. + +```csharp +const string HOST = "localhost:3000"; +const string DBNAME = "chat"; +const bool SSL_ENABLED = false; + +void ProcessThread() +{ + SpacetimeDBClient.instance.Connect(AuthToken.Token, HOST, DBNAME, SSL_ENABLED); + + // loop until cancellation token + while (!cancel_token.IsCancellationRequested) + { + SpacetimeDBClient.instance.Update(); + + ProcessCommands(); + + Thread.Sleep(100); + } + + SpacetimeDBClient.instance.Close(); +} +``` + +## Input loop and ProcessCommands + +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. + +Supported Commands: + +1. Send a message: `message`, send the message to the module 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`. + +```csharp +void InputLoop() +{ + while (true) + { + var input = Console.ReadLine(); + if(input == null) + { + break; + } + + if(input.StartsWith("/name ")) + { + input_queue.Enqueue(("name", input.Substring(6))); + continue; + } + else + { + input_queue.Enqueue(("message", input)); + } + } +} + +void ProcessCommands() +{ + // process input queue commands + while (input_queue.TryDequeue(out var command)) + { + switch (command.Item1) + { + case "message": + Reducer.SendMessage(command.Item2); + break; + case "name": + Reducer.SetName(command.Item2); + break; + } + } +} +``` + +## Run the client + +Finally we just need to add a call to `Main` in `Program.cs`: + +```csharp +Main(); +``` + +Now we can run the client, by hitting start in Visual Studio or running the following command in the `client` directory: + +```bash +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. diff --git a/docs/docs/Client SDK Languages/Python/SDK Reference.md b/docs/docs/Client SDK Languages/Python/SDK Reference.md new file mode 100644 index 00000000000..8cd4b4ca3c0 --- /dev/null +++ b/docs/docs/Client SDK Languages/Python/SDK Reference.md @@ -0,0 +1,525 @@ +# 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 [`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 client connected to a database. + +### 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 | 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 | + +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, 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. +- `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/DeploymentOverview.md) + +```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], 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. | + +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/docs/Client SDK Languages/Python/_category.json b/docs/docs/Client SDK Languages/Python/_category.json new file mode 100644 index 00000000000..4e08cfa1cb3 --- /dev/null +++ b/docs/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/docs/Client SDK Languages/Python/index.md b/docs/docs/Client SDK Languages/Python/index.md new file mode 100644 index 00000000000..526304524da --- /dev/null +++ b/docs/docs/Client SDK Languages/Python/index.md @@ -0,0 +1,377 @@ +# 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/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. + +## 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 src/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 three 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. + +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, status, message, name): + if sender == 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, status, message, msg): + if sender == 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 two 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. + +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/docs/docs/Client SDK Languages/Rust/SDK Reference.md b/docs/docs/Client SDK Languages/Rust/SDK Reference.md new file mode 100644 index 00000000000..c61a06f3dd5 --- /dev/null +++ b/docs/docs/Client SDK Languages/Rust/SDK Reference.md @@ -0,0 +1,1153 @@ +# The SpacetimeDB Rust client SDK + +The SpacetimeDB client SDK for Rust contains all the tools you need to build native clients for SpacetimeDB modules using Rust. + +## Install the SDK + +First, create a new project using `cargo new` and add the SpacetimeDB SDK to your dependencies: + +```bash +cargo add spacetimedb +``` + +## Generate module bindings + +Each SpacetimeDB client depends on some bindings specific to your module. Create a `module_bindings` directory in your project's `src` directory and generate the Rust interface files using the Spacetime CLI. From your project directory, run: + +```bash +mkdir -p src/module_bindings +spacetime generate --lang rust \ + --out-dir src/module_bindings \ + --project-path PATH-TO-MODULE-DIRECTORY +``` + +Replace `PATH-TO-MODULE-DIRECTORY` with the path to your SpacetimeDB module. + +Declare a `mod` for the bindings in your client's `src/main.rs`: + +```rust +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`. | +| 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::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. | +| 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` + +```rust +module_bindings::connect( + spacetimedb_uri: impl TryInto, + db_name: &str, + credentials: Option, +) -> anyhow::Result<()> +``` + +Connect to a database named `db_name` accessible over the internet at the URI `spacetimedb_uri`. + +| 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` + +```rust +spacetimedb_sdk::disconnect() +``` + +Gracefully close the current WebSocket connection. + +If there is no active connection, this operation does nothing. + +```rust +connect(SPACETIMEDB_URI, MODULE_NAME, credentials) + .expect("Connection failed"); + +run_app(); + +disconnect(); +``` + +### 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!" +``` + +### Function `once_on_disconnect` + +```rust +spacetimedb_sdk::once_on_disconnect( + callback: impl FnOnce() + Send + 'static, +) -> DisconnectCallbackId +``` + +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. + +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. + +```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. +``` + +### Function `remove_on_disconnect` + +```rust +spacetimedb_sdk::remove_on_disconnect( + id: DisconnectCallbackId, +) +``` + +Unregister a previously-registered [`on_disconnect`](#function-on_disconnect) callback. + +| Argument | Type | Meaning | +| -------- | ---------------------- | ------------------------------------------ | +| `id` | `DisconnectCallbackId` | 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_disconnect(|| unreachable!()); + +remove_on_disconnect(id); + +disconnect(); + +// No `unreachable` panic. +``` + +## Subscribe to queries + +### Function `subscribe` + +```rust +spacetimedb_sdk::subscribe(queries: &[&str]) -> anyhow::Result<()> +``` + +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. + +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. + +```rust +subscribe(&["SELECT * FROM User;", "SELECT * FROM Message;"]) + .expect("Called `subscribe` before `connect`"); +``` + +### Function `subscribe_owned` + +```rust +spacetimedb_sdk::subscribe_owned(queries: Vec) -> 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. + +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. + +`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. + +```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` + +```rust +spacetimedb_sdk::on_subscription_applied( + callback: impl FnMut() + Send + 'static, +) -> SubscriptionCallbackId +``` + +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. +``` + +### Function `once_on_subscription_applied` + +```rust +spacetimedb_sdk::once_on_subscription_applied( + callback: impl FnOnce() + Send + 'static, +) -> SubscriptionCallbackId +``` + +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. + +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. + +```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. +``` + +### 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)); + +// Will print "Subscription applied!" + +remove_on_subscription_applied(id); + +subscribe(&["SELECT * FROM User;", "SELECT * FROM Message;"]) + .expect("Called `subscribe` before `connect`"); + +// Nothing printed this time. +``` + +## Identify a client + +### Type `Identity` + +```rust +spacetimedb_sdk::identity::Identity +``` + +A unique public identifier for a client connected to a database. + +### Type `Token` + +```rust +spacetimedb_sdk::identity::Token +``` + +A private access token for a client connected to a database. + +### Type `Credentials` + +```rust +spacetimedb_sdk::identity::Credentials +``` + +Credentials, including a private access token, sufficient to authenticate a client connected to a database. + +| Field | Type | +| ---------- | ---------------------------- | +| `identity` | [`Identity`](#type-identity) | +| `token` | [`Token`](#type-token) | + +### Function `identity` + +```rust +spacetimedb_sdk::identity::identity() -> Result +``` + +Read the current connection's public [`Identity`](#type-identity). + +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 identity is {:?}", identity()); + +// Prints "My identity is Ok(Identity { bytes: [...several u8s...] })" +``` + +### Function `token` + +```rust +spacetimedb_sdk::identity::token() -> Result +``` + +Read the current connection's private [`Token`](#type-token). + +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` + +```rust +spacetimedb_sdk::identity::credentials() -> Result +``` + +Read the current connection's [`Credentials`](#type-credentials), including a public [`Identity`](#type-identity) and a private [`Token`](#type-token). + +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 credentials are {:?}", credentials()); + +// Prints "My credentials are Ok(Credentials { +// identity: Identity { bytes: [...several u8s...] }, +// token: Token { string: "...several Base64 digits..."}, +// })" +``` + +### Function `on_connect` + +```rust +spacetimedb_sdk::identity::on_connect( + callback: impl FnMut(&Credentials) + 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. + +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. + +```rust +on_connect( + |creds| println!("Successfully connected! My credentials are: {:?}", creds) +); + +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`. +``` + +### Function `once_on_connect` + +```rust +spacetimedb_sdk::identity::once_on_connect( + callback: impl FnOnce(&Credentials) + 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. | + +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 [`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` + +```rust +spacetimedb_sdk::identity::remove_on_connect(id: ConnectCallbackId) +``` + +Unregister a previously-registered [`on_connect`](#function-on_connect) or [`once_on_connect`](#function-once_on_connect) callback. + +| Argument | Type | Meaning | +| -------- | ------------------- | ------------------------------------------ | +| `id` | `ConnectCallbackId` | 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_connect(|_creds| unreachable!()); + +remove_on_connect(id); + +connect(SPACETIMEDB_URI, DB_NAME, None) + .expect("Failed to connect"); + +sleep(Duration::from_secs(1)); + +// No `unreachable` panic. +``` + +### Function `load_credentials` + +```rust +spacetimedb_sdk::identity::load_credentials( + dirname: &str, +) -> Result> +``` + +Load a saved [`Credentials`](#type-credentials) from a file within `~/dirname`, if one exists. + +| Argument | Type | Meaning | +| --------- | ------ | ----------------------------------------------------- | +| `dirname` | `&str` | Name of a sub-directory in the user's home directory. | + +`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. + +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). + +```rust +const CREDENTIALS_DIR = ".my-module"; + +let creds = load_credentials(CREDENTIALS_DIR) + .expect("Error while loading credentials"); + +connect(SPACETIMEDB_URI, DB_NAME, creds) + .expect("Failed to connect"); +``` + +### Function `save_credentials` + +```rust +spacetimedb_sdk::identity::save_credentials( + dirname: &str, + credentials: &Credentials, +) -> Result<()> +``` + +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. + +```rust +const CREDENTIALS_DIR = ".my-module"; + +let creds = load_credentials(CREDENTIALS_DIRectory) + .expect("Error while loading credentials"); + +on_connect(|creds| { + if let Err(e) = save_credentials(CREDENTIALS_DIR, creds) { + eprintln!("Error while saving credentials: {:?}", e); + } +}); + +connect(SPACETIMEDB_URI, DB_NAME, creds) + .expect("Failed to connect"); +``` + +## View subscribed rows of tables + +### Type `{TABLE}` + +```rust +module_bindings::{TABLE} +``` + +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}, +) -> {FILTER_RESULT}<{TABLE}> +``` + +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`. + +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 an `Option<{TABLE}>`, where `{TABLE}` is the [table struct](#type-table). +- For non-unique columns, the `filter_by` method returns an `impl Iterator`. + +### Trait `TableType` + +```rust +spacetimedb_sdk::table::TableType +``` + +Every [generated table struct](#type-table) implements the trait `TableType`. + +#### Method `count` + +```rust +TableType::count() -> usize +``` + +Return the number of subscribed rows in the table, or 0 if there is no active connection. + +This method acquires a global lock. + +```rust +connect(SPACETIMEDB_URI, DB_NAME, None) + .expect("Failed to connect"); + +on_subscription_applied(|| println!("There are {} users", User::count())); + +subscribe(&["SELECT * FROM User;"]) + .unwrap(); + +sleep(Duration::from_secs(1)); + +// Will the number of `User` rows in the database. +``` + +#### Method `iter` + +```rust +TableType::iter() -> impl Iterator +``` + +Iterate over all the subscribed rows in the table. + +This method acquires a global lock, but the iterator does not hold it. + +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. + +```rust +connect(SPACETIMEDB_URI, DB_NAME, None) + .expect("Failed to connect"); + +on_subscription_applied(|| for user in User::iter() { + println!("{:?}", user); +}); + +subscribe(&["SELECT * FROM User;"]) + .unwrap(); + +sleep(Duration::from_secs(1)); + +// Will print a line for each `User` row in the database. +``` + +#### Method `filter` + +```rust +TableType::filter( + predicate: impl FnMut(&Self) -> bool, +) -> impl Iterator +``` + +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. + +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` + +```rust +TableType::find( + predicate: impl FnMut(&Self) -> bool, +) -> Option +``` + +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 `filter_by_{COLUMN}` methods](#method-filter_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)); + +// Will tell us whether Tyler Cloutier is registered in the database. +``` + +#### Method `on_insert` + +```rust +TableType::on_insert( + callback: impl FnMut(&Self, Option<&ReducerEvent>) + Send + 'static, +) -> InsertCallbackId +``` + +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); + } +}); + +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` + +```rust +TableType::remove_on_insert(id: InsertCallbackId) +``` + +Unregister a previously-registered [`on_insert`](#method-on_insert) callback. + +| 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. + +```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(); + +sleep(Duration::from_secs(1)); + +// No `unreachable` panic. +``` + +#### Method `on_delete` + +```rust +TableType::on_delete( + callback: impl FnMut(&Self, Option<&ReducerEvent>) + Send + 'static, +) -> DeleteCallbackId +``` + +Register an `on_delete` callback for when a subscribed row is removed from the database. + +| Argument | Type | Meaning | +| ---------- | ----------------------------------------------------------- | ----------------------------------------------------- | +| `callback` | `impl FnMut(&Self, Option<&ReducerEvent>) + Send + 'static` | Callback to run whenever a subscribed row is deleted. | + +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. + +```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()); + +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) +``` + +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. +``` + +### 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 +``` + +`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. + +### Type `{REDUCER}Args` + +```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. + +### Function `{REDUCER}` + +```rust +module_bindings::{REDUCER}({ARGS...}) +``` + +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, 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, &Status, {&ARGS...}) + Send + 'static` | Callback to run whenever the reducer is invoked. | + +The callback always accepts two 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. + +In addition, the callback accepts a reference to each of the reducer's arguments. + +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. + +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. + +### Function `once_on_{REDUCER}` + +```rust +module_bindings::once_on_{REDUCER}( + callback: impl FnOnce(&Identity, &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`. + +| Argument | Type | Meaning | +| ---------- | -------------------------------------------------------------- | ----------------------------------------------------- | +| `callback` | `impl FnOnce(&Identity, &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`. + +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. + +### Function `remove_on_{REDUCER}` + +```rust +module_bindings::remove_on_{REDUCER}(id: ReducerCallbackId<{REDUCER}Args>) +``` + +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. + +### Type `Status` + +```rust +spacetimedb_sdk::reducer::Status +``` + +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. diff --git a/docs/docs/Client SDK Languages/Rust/_category.json b/docs/docs/Client SDK Languages/Rust/_category.json new file mode 100644 index 00000000000..6280366ccfe --- /dev/null +++ b/docs/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/docs/Client SDK Languages/Rust/index.md b/docs/docs/Client SDK Languages/Rust/index.md new file mode 100644 index 00000000000..c44ab49d9e4 --- /dev/null +++ b/docs/docs/Client SDK Languages/Rust/index.md @@ -0,0 +1,483 @@ +# 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. + +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. + +## 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: + +```bash +cd quickstart-chat +``` + +Within it, create a `client` crate, our client application, which users run locally: + +```bash +cargo new client +``` + +## Depend on `spacetimedb-sdk` and `hex` + +`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. +- [`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: + +```toml +spacetimedb-sdk = "0.6" +hex = "0.4" +``` + +Make sure you depend on the same version of `spacetimedb-sdk` as is reported by the SpacetimeDB CLI tool's `spacetime version`! + +## Clear `client/src/main.rs` + +`client/src/main.rs` should be initialized with a trivial "Hello world" program. Clear it out so we can write our chat client. + +In your `quickstart-chat` directory, run: + +```bash +rm client/src/main.rs +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. + +In your `quickstart-chat` directory, run: + +```bash +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: + +``` +module_bindings +├── message.rs +├── mod.rs +├── send_message_reducer.rs +├── set_name_reducer.rs +└── user.rs +``` + +We need to declare the module in our client crate, and we'll want to import its definitions. + +To `client/src/main.rs`, add: + +```rust +mod module_bindings; +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. + +To `client/src/main.rs`, add: + +```rust +use spacetimedb_sdk::{ + disconnect, + identity::{load_credentials, once_on_connect, save_credentials, Credentials, Identity}, + on_disconnect, on_subscription_applied, + reducer::Status, + subscribe, + table::{TableType, TableWithPrimaryKey}, +}; +``` + +## 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 five things: + +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: + +```rust +fn main() { + register_callbacks(); + connect_to_db(); + subscribe_to_tables(); + user_input_loop(); +} +``` + +## 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. +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); + + // When a new user joins, print a notification. + User::on_insert(on_user_inserted); + + // When a user's status changes, print a notification. + User::on_update(on_user_updated); + + // When a new message is received, print it. + Message::on_insert(on_message_inserted); + + // When we receive the message backlog, print it in timestamp order. + on_subscription_applied(on_sub_applied); + + // When we fail to set our name, print a warning. + on_set_name(on_name_set); + + // When we fail to send a message, print a warning. + on_send_message(on_message_sent); + + // When our connection closes, inform the user and exit. + on_disconnect(on_disconnected); +} +``` + +### Save credentials + +Each client 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. + +To `client/src/main.rs`, add: + +```rust +/// Our `on_connect` callback: save our credentials to a file. +fn on_connected(creds: &Credentials) { + if let Err(e) = save_credentials(CREDS_DIR, creds) { + eprintln!("Failed to save credentials: {:?}", e); + } +} + +const CREDS_DIR: &str = ".spacetime_chat"; +``` + +### Notify about new users + +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`. + +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. + +`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. + +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. + +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>) { + if user.online { + println!("User {} connected.", user_name_or_identity(user)); + } +} + +fn user_name_or_identity(user: &User) -> String { + user.name + .clone() + .unwrap_or_else(|| identity_leading_hex(&user.identity)) +} + +fn identity_leading_hex(id: &Identity) -> String { + hex::encode(&id.bytes()[0..8]) +} +``` + +### Notify about updated users + +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. + +`on_update` callbacks take three arguments: the old row, the new row, and an `Option<&ReducerEvent>`. + +In our module, users can be updated 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. + +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>) { + if old.name != new.name { + println!( + "User {} renamed to {}.", + user_name_or_identity(old), + user_name_or_identity(new) + ); + } + if old.online && !new.online { + println!("User {} disconnected.", user_name_or_identity(new)); + } + if !old.online && new.online { + println!("User {} connected.", user_name_or_identity(new)); + } +} +``` + +### 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. + +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`. + +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 `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 print_message(message: &Message) { + let sender = User::filter_by_identity(message.sender.clone()) + .map(|u| user_name_or_identity(&u)) + .unwrap_or_else(|| "unknown".to_string()); + println!("{}: {}", sender, message.text); +} +``` + +### Print 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() { + let mut messages = Message::iter().collect::>(); + messages.sort_by_key(|m| m.sent); + for message in messages { + print_message(&message); + } +} +``` + +### 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 `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: + +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`. + +In addition, it takes a reference to each of the arguments passed to the reducer itself. + +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 `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: &Identity, status: &Status, name: &String) { + if let Status::Failed(err) = status { + 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: &Identity, status: &Status, text: &String) { + if let Status::Failed(err) = status { + 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 +/// The URL of the SpacetimeDB instance hosting our chat module. +const SPACETIMEDB_URI: &str = "http://localhost:3000"; + +/// The module name we chose when we published our module. +const DB_NAME: &str = ""; + +/// 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"); +} +``` + +## 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 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 `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(); +} +``` + +## 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. + +`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`. + +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() { + for line in std::io::stdin().lines() { + let Ok(line) = line else { + panic!("Failed to read from stdin."); + }; + if let Some(name) = line.strip_prefix("/name ") { + set_name(name.to_string()); + } else { + send_message(line); + } + } +} +``` + +## Run it + +Change your directory to the client app, then compile and run it. From the `quickstart-chat` directory, run: + +```bash +cd client +cargo run +``` + +You should see something like: + +``` +User d9e25c51996dea2f connected. +``` + +Now try sending a message. Type `Hello, world!` and press enter. You should see something like: + +``` +d9e25c51996dea2f: Hello, world! +``` + +Next, set your name. Type `/name `, replacing `` with your name. You should see something like: + +``` +User d9e25c51996dea2f renamed to . +``` + +Then send another message. Type `Hello after naming myself.` and press enter. You should see: + +``` +: 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: + +``` +User connected. +: Hello, world! +: Hello after naming myself. +``` + +## 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). + +Check out the [Rust SDK Reference](/docs/client-languages/rust/rust-sdk-reference) 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). + +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. + +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). + +Or, you could extend the module and the client together, perhaps: + +- 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. diff --git a/docs/docs/Client SDK Languages/Typescript/SDK Reference.md b/docs/docs/Client SDK Languages/Typescript/SDK Reference.md new file mode 100644 index 00000000000..657115d7e46 --- /dev/null +++ b/docs/docs/Client SDK Languages/Typescript/SDK Reference.md @@ -0,0 +1,805 @@ +# The SpacetimeDB Typescript client SDK + +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 + +First, create a new client project, and add the following to your `tsconfig.json` file: + +```json +{ + "compilerOptions": { + //You can use any target higher than this one + //https://www.typescriptlang.org/tsconfig#target + "target": "es2015" + } +} +``` + +Then add the SpacetimeDB SDK to your dependencies: + +```bash +cd client +npm install @clockworklabs/spacetimedb-sdk +``` + +You should have this folder layout starting from the root of your project: + +```bash +quickstart-chat +├── client +│ ├── node_modules +│ ├── public +│ └── src +└── server + └── src +``` + +### Tip for utilities/scripts + +If want to create a quick script to test your module bindings from the command line, you can use https://www.npmjs.com/package/tsx to execute TypeScript files. + +Then you create a `script.ts` file and add the imports, code and execute with: + +```bash +npx tsx src/script.ts +``` + +## Generate module bindings + +Each SpacetimeDB client depends on some bindings specific to your module. Create a `module_bindings` directory in your project's `src` directory and generate the Typescript interface files using the Spacetime CLI. From your project directory, run: + +```bash +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 +``` + +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); +``` + +> 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. + +## 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. | +| [`{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` + +The database client connection to a SpacetimeDB server. + +Defined in [spacetimedb-sdk.spacetimedb](https://github.com/clockworklabs/spacetimedb-typescript-sdk/blob/main/src/spacetimedb.ts): + +| 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. | + +## Constructors + +### `SpacetimeDBClient` constructor + +Creates a new `SpacetimeDBClient` database client and set the initial parameters. + +```ts +new SpacetimeDBClient(host: string, name_or_address: string, auth_token?: string, protocol?: "binary" | "json") +``` + +#### Parameters + +| 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. | + +#### Example + +```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 +); +``` + +## Properties + +### `SpacetimeDBClient` identity + +The user's public [Identity](#class-identity). + +``` +identity: Identity | undefined +``` + +--- + +### `SpacetimeDBClient` live + +Whether the client is connected. + +```ts +live: boolean; +``` + +--- + +### `SpacetimeDBClient` token + +The user's private authentication token. + +``` +token: string | undefined +``` + +#### Parameters + +| Name | Type | Description | +| :------------ | :----------------------------------------------------- | :------------------------------ | +| `reducerName` | `string` | The name of the reducer to call | +| `serializer` | [`Serializer`](../interfaces/serializer.Serializer.md) | - | + +--- + +### `SpacetimeDBClient` connect + +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. + +```ts +connect(host: string?, name_or_address: string?, auth_token: string?): Promise +``` + +#### Parameters + +| 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). | + +#### Returns + +`Promise`<`void`\> + +#### 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); +``` + +--- + +### `SpacetimeDBClient` disconnect + +Close the current connection. + +```ts +disconnect(): void +``` + +#### Example + +```ts +var spacetimeDBClient = new SpacetimeDBClient( + "ws://localhost:3000", + "database_name" +); + +spacetimeDBClient.disconnect(); +``` + +--- + +### `SpacetimeDBClient` subscribe + +Subscribe to a set of queries, to be notified when rows which match those queries are altered. + +> 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. + +```ts +subscribe(queryOrQueries: string | string[]): void +``` + +#### Parameters + +| Name | Type | Description | +| :--------------- | :--------------------- | :------------------------------- | +| `queryOrQueries` | `string` \| `string`[] | A `SQL` query or list of queries | + +#### Example + +```ts +spacetimeDBClient.subscribe(["SELECT * FROM User", "SELECT * FROM Message"]); +``` + +## Events + +### `SpacetimeDBClient` onConnect + +Register a callback to be invoked upon authentication with the database. + +```ts +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 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` | + +#### Example + +```ts +spacetimeDBClient.onConnect((token, identity) => { + console.log("Connected to SpacetimeDB"); + console.log("Token", token); + console.log("Identity", identity); +}); +``` + +--- + +### `SpacetimeDBClient` onError + +Register a callback to be invoked upon an error. + +```ts +onError(callback: (...args: any[]) => void): void +``` + +#### Parameters + +| Name | Type | +| :--------- | :----------------------------- | +| `callback` | (...`args`: `any`[]) => `void` | + +#### Example + +```ts +spacetimeDBClient.onError((...args: any[]) => { + console.error("ERROR", args); +}); +``` + +### Class `Identity` + +A unique public identifier for a client connected to a database. + +Defined in [spacetimedb-sdk.identity](https://github.com/clockworklabs/spacetimedb-typescript-sdk/blob/main/src/identity.ts): + +| 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. | + +## Constructors + +### `Identity` constructor + +```ts +new Identity(data: Uint8Array) +``` + +#### Parameters + +| Name | Type | +| :----- | :----------- | +| `data` | `Uint8Array` | + +## Methods + +### `Identity` isEqual + +Compare two identities for equality. + +```ts +isEqual(other: Identity): boolean +``` + +#### Parameters + +| Name | Type | +| :------ | :---------------------------- | +| `other` | [`Identity`](#class-identity) | + +#### Returns + +`boolean` + +--- + +### `Identity` toHexString + +Print an `Identity` as a hexadecimal string. + +```ts +toHexString(): string +``` + +#### Returns + +`string` + +--- + +### `Identity` fromString + +Static method; parse an Identity from a hexadecimal string. + +```ts +Identity.fromString(str: string): Identity +``` + +#### Parameters + +| Name | Type | +| :---- | :------- | +| `str` | `string` | + +#### Returns + +[`Identity`](#class-identity) + +### 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`. + +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; returned subscribed rows with a given value in a particular 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 + +### {Table} name + +• **name**: `string` + +The name of the `Class`. + +--- + +### {Table} tableName + +The name of the table in the database. + +▪ `Static` **tableName**: `string` = `"Person"` + +## Methods + +### {Table} all + +Return all the subscribed rows in the table. + +```ts +{Table}.all(): {Table}[] +``` + +#### Returns + +`{Table}[]` + +#### Example + +```ts +var spacetimeDBClient = new SpacetimeDBClient( + "ws://localhost:3000", + "database_name" +); + +spacetimeDBClient.onConnect((token, identity) => { + spacetimeDBClient.subscribe(["SELECT * FROM Person"]); + + setTimeout(() => { + console.log(Person.all()); // Prints all the `Person` rows in the database. + }, 5000); +}); +``` + +--- + +### {Table} count + +Return the number of subscribed rows in the table, or 0 if there is no active connection. + +```ts +{Table}.count(): number +``` + +#### Returns + +`number` + +#### Example + +```ts +var spacetimeDBClient = new SpacetimeDBClient( + "ws://localhost:3000", + "database_name" +); + +spacetimeDBClient.onConnect((token, identity) => { + spacetimeDBClient.subscribe(["SELECT * FROM Person"]); + + setTimeout(() => { + console.log(Person.count()); + }, 5000); +}); +``` + +--- + +### {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. + +These methods are named `filterBy{COLUMN}`, where `{COLUMN}` is the column name converted to `camelCase`. + +```ts +{Table}.filterBy{COLUMN}(value): {Table}[] +``` + +#### Parameters + +| Name | Type | +| :------ | :-------------------------- | +| `value` | The type of the `{COLUMN}`. | + +#### Returns + +`{Table}[]` + +#### Example + +```ts +var spacetimeDBClient = new SpacetimeDBClient( + "ws://localhost:3000", + "database_name" +); + +spacetimeDBClient.onConnect((token, identity) => { + spacetimeDBClient.subscribe(["SELECT * FROM Person"]); + + setTimeout(() => { + console.log(Person.filterByName("John")); // prints all the `Person` rows named John. + }, 5000); +}); +``` + +--- + +### {Table} fromValue + +Deserialize an `AlgebraicType` into this `{Table}`. + +```ts + {Table}.fromValue(value: AlgebraicValue): {Table} +``` + +#### Parameters + +| Name | Type | +| :------ | :--------------- | +| `value` | `AlgebraicValue` | + +#### Returns + +`{Table}` + +--- + +### {Table} getAlgebraicType + +Serialize `this` into an `AlgebraicType`. + +#### Example + +```ts +{Table}.getAlgebraicType(): AlgebraicType +``` + +#### Returns + +`AlgebraicType` + +--- + +### {Table} onInsert + +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 +``` + +#### Parameters + +| Name | Type | Description | +| :--------- | :---------------------------------------------------------------------------- | :----------------------------------------------------- | +| `callback` | (`value`: `{Table}`, `reducerEvent`: `undefined` \| `ReducerEvent`) => `void` | Callback to run whenever a subscribed row is inserted. | + +#### Example + +```ts +var spacetimeDBClient = new SpacetimeDBClient( + "ws://localhost:3000", + "database_name" +); +spacetimeDBClient.onConnect((token, identity) => { + 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); + } +}); +``` + +--- + +### {Table} removeOnInsert + +Unregister a previously-registered [`onInsert`](#table-oninsert) callback. + +```ts +{Table}.removeOnInsert(callback: (value: Person, reducerEvent: ReducerEvent | undefined) => void): void +``` + +#### Parameters + +| Name | Type | +| :--------- | :---------------------------------------------------------------------------- | +| `callback` | (`value`: `{Table}`, `reducerEvent`: `undefined` \| `ReducerEvent`) => `void` | + +--- + +### {Table} onUpdate + +Register an `onUpdate` callback to run when an existing row is modified by primary key. + +```ts +{Table}.onUpdate(callback: (oldValue: {Table}, newValue: {Table}, reducerEvent: ReducerEvent | undefined) => void): void +``` + +`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. | + +#### Example + +```ts +var spacetimeDBClient = new SpacetimeDBClient( + "ws://localhost:3000", + "database_name" +); +spacetimeDBClient.onConnect((token, identity) => { + spacetimeDBClient.subscribe(["SELECT * FROM Person"]); +}); + +Person.onUpdate((oldPerson, newPerson, reducerEvent) => { + console.log("Person updated by reducer", reducerEvent, oldPerson, newPerson); +}); +``` + +--- + +### {Table} removeOnUpdate + +Unregister a previously-registered [`onUpdate`](#table-onUpdate) callback. + +```ts +{Table}.removeOnUpdate(callback: (oldValue: {Table}, newValue: {Table}, reducerEvent: ReducerEvent | undefined) => void): void +``` + +#### Parameters + +| Name | Type | +| :--------- | :------------------------------------------------------------------------------------------------------ | +| `callback` | (`oldValue`: `{Table}`, `newValue`: `{Table}`, `reducerEvent`: `undefined` \| `ReducerEvent`) => `void` | + +--- + +### {Table} onDelete + +Register an `onDelete` callback for when a subscribed row is removed from the database. + +```ts +{Table}.onDelete(callback: (value: {Table}, reducerEvent: ReducerEvent | undefined) => void): void +``` + +#### Parameters + +| Name | Type | Description | +| :--------- | :---------------------------------------------------------------------------- | :---------------------------------------------------- | +| `callback` | (`value`: `{Table}`, `reducerEvent`: `undefined` \| `ReducerEvent`) => `void` | Callback to run whenever a subscribed row is removed. | + +#### Example + +```ts +var spacetimeDBClient = new SpacetimeDBClient( + "ws://localhost:3000", + "database_name" +); +spacetimeDBClient.onConnect((token, identity) => { + 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 + ); + } +}); +``` + +--- + +### {Table} removeOnDelete + +Unregister a previously-registered [`onDelete`](#table-onDelete) callback. + +```ts +{Table}.removeOnDelete(callback: (value: {Table}, reducerEvent: ReducerEvent | undefined) => void): void +``` + +#### Parameters + +| Name | Type | +| :--------- | :---------------------------------------------------------------------------- | +| `callback` | (`value`: `{Table}`, `reducerEvent`: `undefined` \| `ReducerEvent`) => `void` | + +### Class `{Reducer}` + +`spacetime generate` defines an `{Reducer}` class in the `module_bindings` folder for each reducer defined by a module. + +The class's name will be the reducer's name converted to `PascalCase`. + +| 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. | + +## Static methods + +### {Reducer} call + +Executes the reducer. + +```ts +{Reducer}.call(): void +``` + +#### Example + +```ts +SayHelloReducer.call(); +``` + +## Events + +### {Reducer} on + +Register a callback to run each time the reducer is invoked. + +```ts +{Reducer}.on(callback: (reducerEvent: ReducerEvent, reducerArgs: any[]) => void): void +``` + +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); +}); +``` diff --git a/docs/docs/Client SDK Languages/Typescript/_category.json b/docs/docs/Client SDK Languages/Typescript/_category.json new file mode 100644 index 00000000000..590d44a25ba --- /dev/null +++ b/docs/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/docs/Client SDK Languages/Typescript/index.md b/docs/docs/Client SDK Languages/Typescript/index.md new file mode 100644 index 00000000000..ae893af5fa2 --- /dev/null +++ b/docs/docs/Client SDK Languages/Typescript/index.md @@ -0,0 +1,500 @@ +# Typescript 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 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.** + +## 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: + +```bash +cd quickstart-chat +``` + +Within it, create a `client` react app: + +```bash +npx create-react-app client --template typescript +``` + +We also need to install the `spacetime-client-sdk` package: + +```bash +cd client +npm install @clockworklabs/spacetimedb-sdk +``` + +## Basic layout + +We are going to start by creating a basic layout for our app. The page contains 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"; + +export type MessageType = { + name: string; + message: string; +}; + +function App() { + const [newName, setNewName] = useState(""); + const [settingName, setSettingName] = useState(false); + const [name, setName] = useState(""); + const [systemMessage, setSystemMessage] = useState(""); + const [messages, setMessages] = useState([]); + + const [newMessage, setNewMessage] = useState(""); + + const onSubmitNewName = (e: React.FormEvent) => { + e.preventDefault(); + setSettingName(false); + // Fill in app logic here + }; + + const onMessageSubmit = (e: React.FormEvent) => { + e.preventDefault(); + // Fill in app logic here + setNewMessage(""); + }; + + return ( +
+
+

Profile

+ {!settingName ? ( + <> +

{name}

+ + + ) : ( +
+ setNewName(e.target.value)} + /> + + + )} +
+
+

Messages

+ {messages.length < 1 &&

No messages

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

+ {message.name} +

+

{message.message}

+
+ ))} +
+
+
+

System

+
+

{systemMessage}

+
+
+
+
+

New Message

+ + + +
+
+ ); +} + +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 +``` + +Take a look inside `client/src/module_bindings`. The CLI should have generated four files: + +``` +module_bindings +├── message.ts +├── send_message_reducer.ts +├── set_name_reducer.ts +└── 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. + +```typescript +import { SpacetimeDBClient, Identity } 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"; +console.log(Message, User, SendMessageReducer, SetNameReducer); +``` + +## Create your SpacetimeDB client + +First, we need to create a SpacetimeDB client and connect to the module. Create your client at the top of the `App` function. + +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. + +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`. + +Add this before the `App` function declaration: + +```typescript +let token = localStorage.getItem("auth_token") || undefined; +var spacetimeDBClient = new SpacetimeDBClient( + "ws://localhost:3000", + "chat", + token +); +``` + +Inside the `App` function, add a few refs: + +```typescript +let local_identity = useRef(undefined); +let initialized = useRef(false); +const client = useRef(spacetimeDBClient); +``` + +## Register callbacks and connect + +We need to handle several sorts of events: + +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. + +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. + +### onConnect Callback + +On connect SpacetimeDB will provide us with our client credentials. + +Each client 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. + +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. + +We want to store our local client identity in a stateful variable and also save our `token` to local storage for future connections. + +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) => { + console.log("Connected to SpacetimeDB"); + + local_identity.current = identity; + + localStorage.setItem("auth_token", token); + + client.current.subscribe(["SELECT * FROM User", "SELECT * FROM Message"]); +}); +``` + +### 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`. + +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. + +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. + +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`. + +To the body of `App`, add: + +```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); + } +} + +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 sender_identity = User.filterByIdentity(message.sender); + let display_name = sender_identity + ? userNameOrIdentity(sender_identity) + : "unknown"; + + return { + name: display_name, + message: message.text, + }; + }); + + setMessages(messagesType); +} + +client.current.on("initialStateSync", () => { + setAllMessagesInOrder(); + var user = User.filterByIdentity(local_identity?.current?.toUint8Array()!); + setName(userNameOrIdentity(user!)); +}); +``` + +### Message.onInsert callback - Update messages + +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. + +To the body of `App`, add: + +```typescript +Message.onInsert((message, reducerEvent) => { + if (reducerEvent !== undefined) { + setAllMessagesInOrder(); + } +}); +``` + +### User.onInsert callback - 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 `onInsert` and `onDelete` methods of the trait `TableType`, 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.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 | 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. + +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. + +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); +} + +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. + +`onUpdate` callbacks take three arguments: the old row, the new row, and a `ReducerEvent`. + +In our module, users can be updated 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 update the `system` message in each of these cases. + +To the body of `App`, add: + +```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.`); + } + + if (user.name !== oldUser.name) { + appendToSystemMessage( + `User ${userNameOrIdentity(oldUser)} renamed to ${userNameOrIdentity( + user + )}.` + ); + } +}); +``` + +### 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 two arguments: + +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. + +2. `ReducerArgs` which is an array containing the arguments with which the reducer was invoked. + +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 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. + +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. + +If the reducer status comes back as `committed`, we'll update the name in our app. + +To the body of `App`, add: + +```typescript +SetNameReducer.on((reducerEvent, reducerArgs) => { + 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(reducerArgs[0]); + } + } +}); +``` + +### SendMessageReducer.on callback - Handle errors + +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. + +To the body of `App`, add: + +```typescript +SendMessageReducer.on((reducerEvent, reducerArgs) => { + if ( + local_identity.current && + reducerEvent.callerIdentity.isEqual(local_identity.current) + ) { + if (reducerEvent.status === "failed") { + appendToSystemMessage(`Error sending message: ${reducerEvent.message} `); + } + } +}); +``` + +## Update the UI button callbacks + +We need to update the `onSubmitNewName` and `onMessageSubmit` callbacks to send the appropriate reducer to the module. + +`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`. + +Add the following to the `onSubmitNewName` callback: + +```typescript +SetNameReducer.call(newName); +``` + +Add the following to the `onMessageSubmit` callback: + +```typescript +SendMessageReducer.call(newMessage); +``` + +## Connecting to the module + +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. + +```typescript +useEffect(() => { + if (!initialized.current) { + client.current.connect(); + initialized.current = true; + } +}, []); +``` + +## 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. + +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) + +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). + +## Troubleshooting + +If you encounter the following error: + +``` +TS2802: Type 'IterableIterator' can only be iterated through when using the '--downlevelIteration' flag or with a '--target' of 'es2015' or higher. +``` + +You can fix it by changing your compiler target. Add the following to your `tsconfig.json` file: + +```json +{ + "compilerOptions": { + "target": "es2015" + } +} +``` diff --git a/docs/docs/Client SDK Languages/_category.json b/docs/docs/Client SDK Languages/_category.json new file mode 100644 index 00000000000..530c17aa6e9 --- /dev/null +++ b/docs/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/docs/Client SDK Languages/index.md b/docs/docs/Client SDK Languages/index.md new file mode 100644 index 00000000000..27c9284fd69 --- /dev/null +++ b/docs/docs/Client SDK Languages/index.md @@ -0,0 +1,74 @@ +# Welcome to Client SDK Languages# 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 + +- [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) + +## Key Features + +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. + +### Authentication + +The SDKs support authentication using an auth token, allowing clients to securely establish a session with the SpacetimeDB server. + +### Local Database View + +Each client can define a local view of the database via a subscription consisting of a set of queries. This local view is maintained by the server and populated into a local cache on the client side. + +### Reducer Calls + +The SDKs allow clients to call transactional functions (reducers) on the server. + +### Callback Registrations + +The SpacetimeDB Client SDKs offer powerful callback functionality that allow clients to monitor changes in their local database view. These callbacks come in two forms: + +#### 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. + +#### Row Update Callbacks + +Clients can register callbacks that trigger when any row in their local cache is updated by the server. These callbacks contain information about the reducer that triggered the change. This feature enables clients to react to changes in data that they're interested in. + +#### Reducer Call Callbacks + +Clients can also register callbacks that fire when a reducer call modifies something in the client's local view. This allows the client to know when a transactional function it has executed has had an effect on the data it cares about. + +Additionally, when a client makes a reducer call that fails, the SDK triggers the registered reducer callback on the client that initiated the failed call with the error message that was returned from the server. This allows for appropriate error handling or user notifications. + +## Choosing a Language + +When selecting a language for your client application with SpacetimeDB, a variety of factors come into play. While the functionality of the SDKs remains consistent across different languages, the choice of language will often depend on the specific needs and context of your application. Here are a few considerations: + +### Team Expertise + +The familiarity of your development team with a particular language can greatly influence your choice. You might want to choose a language that your team is most comfortable with to increase productivity and reduce development time. + +### 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. + +### Performance + +The performance characteristics of the different languages can also be a factor. If your application is performance-critical, you might opt for Rust, known for its speed and memory efficiency. + +### Platform Support + +The platform you're targeting can also influence your choice. For instance, if you're developing a game or a 3D application using the Unity engine, you'll want to choose the C# SDK, as Unity uses C# as its primary scripting language. + +### Ecosystem and Libraries + +Each language has its own ecosystem of libraries and tools that can help in developing your application. If there's a library in a particular language that you want to use, it may influence your choice. + +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. diff --git a/docs/docs/Cloud Testnet/_category.json b/docs/docs/Cloud Testnet/_category.json new file mode 100644 index 00000000000..e6fa11b9bf0 --- /dev/null +++ b/docs/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/docs/Cloud Testnet/index.md b/docs/docs/Cloud Testnet/index.md new file mode 100644 index 00000000000..abb90fb8f35 --- /dev/null +++ b/docs/docs/Cloud Testnet/index.md @@ -0,0 +1,34 @@ +# 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 set "https://testnet.spacetimedb.com" +``` + +## 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/docs/Getting Started/_category.json b/docs/docs/Getting Started/_category.json new file mode 100644 index 00000000000..a68dc36c049 --- /dev/null +++ b/docs/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/docs/Getting Started/index.md b/docs/docs/Getting Started/index.md new file mode 100644 index 00000000000..854d227c81e --- /dev/null +++ b/docs/docs/Getting Started/index.md @@ -0,0 +1,36 @@ +# Getting Started + +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 + +```bash +spacetime start +``` + +The server listens on port `3000` by default. You can change this by using the `--listen-addr` option described below. + +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: + +- [Rust](/docs/server-languages/rust/rust-module-quickstart-guide) +- [C#](/docs/server-languages/csharp/csharp-module-quickstart-guide) + +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) + +We also have a [step-by-step tutorial](/docs/unity-tutorial/unity-tutorial-part-1) for building a multiplayer game in Unity3d. diff --git a/docs/docs/HTTP API Reference/Databases.md b/docs/docs/HTTP API Reference/Databases.md new file mode 100644 index 00000000000..91e7d0a97ea --- /dev/null +++ b/docs/docs/HTTP API Reference/Databases.md @@ -0,0 +1,589 @@ +# `/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. + +## 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/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/websocket-api-reference). | +| [`/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. + +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` + +Look up a database's name by its address. + +Accessible through the CLI as `spacetime dns reverse-lookup
`. + +#### Parameters + +| Name | Value | +| ---------- | ---------------------------- | +| `:address` | The address of the database. | + +#### Returns + +Returns JSON in the form: + +```typescript +{ "names": array } +``` + +where `` is a JSON array of strings, each of which is a name which refers to the database. + +## `/database/set_name GET` + +Set the name associated with a database. + +Accessible through the CLI as `spacetime dns set-name
`. + +#### 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. | + +#### Required Headers + +| Name | Value | +| --------------- | ------------------------------------------------------------------------------------------- | +| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). | + +#### Returns + +If the name was successfully set, returns JSON in the form: + +```typescript +{ "Success": { + "domain": string, + "address": string +} } +``` + +If the top-level domain is not registered, and `register_tld` was not specified, returns JSON in the form: + +```typescript +{ "TldNotRegistered": { + "domain": 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: + +```typescript +{ "PermissionDenied": { + "domain": string +} } +``` + +> 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. + +## `/database/register_tld GET` + +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 + +| Name | Value | +| ----- | -------------------------------------- | +| `tld` | New top-level domain name to register. | + +#### Required Headers + +| Name | Value | +| --------------- | ------------------------------------------------------------------------------------------- | +| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). | + +#### Returns + +If the domain is successfully registered, returns JSON in the form: + +```typescript +{ "Success": { + "domain": string +} } +``` + +If the domain is already registered to the caller, returns JSON in the form: + +```typescript +{ "AlreadyRegistered": { + "domain": string +} } +``` + +If the domain is already registered to another identity, returns JSON in the form: + +```typescript +{ "Unauthorized": { + "domain": string +} } +``` + +## `/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-api-reference/identities#identity-post) or afterwards via [`/identity/:identity/set-email`](/docs/http-api-reference/identities#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. + +Accessible through the CLI as `spacetime publish`. + +#### Query Parameters + +| 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. | + +#### Required Headers + +| Name | Value | +| --------------- | ------------------------------------------------------------------------------------------- | +| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). | + +#### Data + +A WebAssembly module in the [binary format](https://webassembly.github.io/spec/core/binary/index.html). + +#### Returns + +If the database was successfully published, returns JSON in the form: + +```typescript +{ "Success": { + "domain": null | string, + "address": string, + "op": "created" | "updated" +} } +``` + +If the top-level domain for the requested name is not registered, returns JSON in the form: + +```typescript +{ "TldNotRegistered": { + "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: + +```typescript +{ "PermissionDenied": { + "domain": string +} } +``` + +> 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. + +Accessible through the CLI as `spacetime delete
`. + +#### Parameters + +| Name | Address | +| ---------- | ---------------------------- | +| `:address` | The address of the database. | + +#### Required Headers + +| Name | Value | +| --------------- | ------------------------------------------------------------------------------------------- | +| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). | + +## `/database/subscribe/:name_or_address GET` + +Begin a [WebSocket connection](/docs/websocket-api-reference) 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`](/docs/websocket-api-reference#binary-protocol) or [`v1.text.spacetimedb`](/docs/websocket-api-reference#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. | + +#### Optional Headers + +| Name | Value | +| --------------- | ------------------------------------------------------------------------------------------- | +| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). | + +## `/database/call/:name_or_address/:reducer POST` + +Invoke a reducer in a database. + +#### Parameters + +| Name | Value | +| ------------------ | ------------------------------------ | +| `:name_or_address` | The name or address of the database. | +| `:reducer` | The name of the reducer. | + +#### Required Headers + +| Name | Value | +| --------------- | ------------------------------------------------------------------------------------------- | +| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). | + +#### Data + +A JSON array of arguments to the reducer. + +## `/database/schema/:name_or_address GET` + +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. | + +#### Query Parameters + +| Name | Value | +| -------- | ----------------------------------------------------------- | +| `expand` | A boolean; whether to include full schemas for each entity. | + +#### 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: + +```typescript +{ + "entities": { + "Person": { + "arity": 1, + "schema": { + "elements": [ + { + "algebraic_type": { + "Builtin": { + "String": [] + } + }, + "name": { + "some": "name" + } + } + ] + }, + "type": "table" + }, + "__init__": { + "arity": 0, + "schema": { + "elements": [], + "name": "__init__" + }, + "type": "reducer" + }, + "add": { + "arity": 1, + "schema": { + "elements": [ + { + "algebraic_type": { + "Builtin": { + "String": [] + } + }, + "name": { + "some": "name" + } + } + ], + "name": "add" + }, + "type": "reducer" + }, + "say_hello": { + "arity": 0, + "schema": { + "elements": [], + "name": "say_hello" + }, + "type": "reducer" + } + }, + "typespace": [ + { + "Product": { + "elements": [ + { + "algebraic_type": { + "Builtin": { + "String": [] + } + }, + "name": { + "some": "name" + } + } + ] + } + } + ] +} +``` + +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/satn-reference/satn-reference-json-format); 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]`. + +## `/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/satn-reference/satn-reference-json-format); 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, + "identity": string, + "host_type": "wasmer", + "num_replicas": number, + "program_bytes_address": 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. | + +## `/database/logs/:name_or_address GET` + +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. | + +#### Query Parameters + +| Name | Value | +| ----------- | --------------------------------------------------------------- | +| `num_lines` | Number of most-recent log lines to retrieve. | +| `follow` | A boolean; whether to continue receiving new logs via a stream. | + +#### Required Headers + +| Name | Value | +| --------------- | ------------------------------------------------------------------------------------------- | +| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). | + +#### Returns + +Text, or streaming text if `follow` is supplied, containing log lines. + +## `/database/sql/:name_or_address POST` + +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. | + +#### Required Headers + +| Name | Value | +| --------------- | ------------------------------------------------------------------------------------------- | +| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). | + +#### Data + +SQL queries, separated by `;`. + +#### Returns + +Returns a JSON array of statement results, each of which takes the form: + +```typescript +{ + "schema": ProductType, + "rows": array +} +``` + +The `schema` will be a [JSON-encoded `ProductType`](/docs/satn-reference/satn-reference-json-format) 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`. diff --git a/docs/docs/HTTP API Reference/Energy.md b/docs/docs/HTTP API Reference/Energy.md new file mode 100644 index 00000000000..a7b6d05a2cd --- /dev/null +++ b/docs/docs/HTTP API Reference/Energy.md @@ -0,0 +1,76 @@ +# `/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 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 `. + +#### 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. | + +## `/energy/:identity POST` + +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 + +| 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-api-reference/authorization). | + +#### 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. | diff --git a/docs/docs/HTTP API Reference/Identities.md b/docs/docs/HTTP API Reference/Identities.md new file mode 100644 index 00000000000..87411759f52 --- /dev/null +++ b/docs/docs/HTTP API Reference/Identities.md @@ -0,0 +1,160 @@ +# `/identity` HTTP API + +The HTTP endpoints in `/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. | + +## `/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` + +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: + +```typescript +{ + "identity": string, + "token": string +} +``` + +## `/identity/websocket_token POST` + +Generate a short-lived access token which can be used in untrusted contexts, e.g. embedded in URLs. + +#### Required Headers + +| Name | Value | +| --------------- | ------------------------------------------------------------------------------------------- | +| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). | + +#### Returns + +Returns JSON in the form: + +```typescript +{ + "token": string +} +``` + +The `token` value is a short-lived [JSON Web Token](https://datatracker.ietf.org/doc/html/rfc7519). + +## `/identity/:identity/set-email POST` + +Associate an email with a Spacetime identity. + +Accessible through the CLI as `spacetime identity set-email `. + +#### Parameters + +| Name | Value | +| ----------- | ----------------------------------------- | +| `:identity` | The identity to associate with the email. | + +#### Query Parameters + +| Name | Value | +| ------- | ----------------- | +| `email` | An email address. | + +#### Required Headers + +| Name | Value | +| --------------- | ------------------------------------------------------------------------------------------- | +| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). | + +## `/identity/:identity/databases GET` + +List all databases owned by an identity. + +#### Parameters + +| Name | Value | +| ----------- | --------------------- | +| `:identity` | A Spacetime identity. | + +#### Returns + +Returns JSON in the form: + +```typescript +{ + "addresses": array +} +``` + +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` + +Verify the validity of an identity/token pair. + +#### Parameters + +| Name | Value | +| ----------- | ----------------------- | +| `:identity` | The identity to verify. | + +#### Required Headers + +| Name | Value | +| --------------- | ------------------------------------------------------------------------------------------- | +| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). | + +#### Returns + +Returns no data. + +If the token is valid and matches the identity, returns `204 No Content`. + +If the token is valid but does not match the identity, returns `400 Bad Request`. + +If the token is invalid, or no `Authorization` header is included in the request, returns `401 Unauthorized`. diff --git a/docs/docs/HTTP API Reference/_category.json b/docs/docs/HTTP API Reference/_category.json new file mode 100644 index 00000000000..c8ad821bd65 --- /dev/null +++ b/docs/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/docs/HTTP API Reference/index.md b/docs/docs/HTTP API Reference/index.md new file mode 100644 index 00000000000..224aaf7766b --- /dev/null +++ b/docs/docs/HTTP API Reference/index.md @@ -0,0 +1,51 @@ +# 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 + +Clients can request a new identity and token via [the `/identity POST` HTTP endpoint](/docs/http-api-reference/identities#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). + +### Encoding `Authorization` headers + +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. + +To construct an appropriate `Authorization` header value for a `token`: + +1. Prepend the string `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 +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) +} +``` + +#### C# + +```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; +} +``` diff --git a/docs/docs/Module ABI Reference/_category.json b/docs/docs/Module ABI Reference/_category.json new file mode 100644 index 00000000000..7583598ddca --- /dev/null +++ b/docs/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/docs/Module ABI Reference/index.md b/docs/docs/Module ABI Reference/index.md new file mode 100644 index 00000000000..ceccfbd1772 --- /dev/null +++ b/docs/docs/Module ABI Reference/index.md @@ -0,0 +1,499 @@ +# Module ABI Reference + +This document specifies the _low level details_ of module-host interactions (_"Module ABI"_). _**Most users**_ looking to interact with the host will want to use derived and higher level functionality like [`bindings`], `#[spacetimedb(table)]`, and `#[derive(SpacetimeType)]` rather than this low level ABI. For more on those, read the [Rust module quick start][module_quick_start] guide and the [Rust module reference][module_ref]. + +The Module ABI is defined in [`bindings_sys::raw`] and is used by modules to interact with their host and perform various operations like: + +- logging, +- transporting data, +- scheduling reducers, +- altering tables, +- inserting and deleting rows, +- querying tables. + +In the next few sections, we'll define the functions that make up the ABI and what these functions do. + +## General notes + +The functions in this ABI all use the [`C` ABI on the `wasm32` platform][wasm_c_abi]. They are specified in a Rust `extern "C" { .. }` block. For those more familiar with the `C` notation, an [appendix][c_header] is provided with equivalent definitions as would occur in a `.h` file. + +Many functions in the ABI take in- or out-pointers, e.g. `*const u8` and `*mut u8`. The WASM host itself does not have undefined behavior. However, what WASM does not consider a memory access violation could be one according to some other language's abstract machine. For example, running the following on a WASM host would violate Rust's rules around writing across allocations: + +```rust +fn main() { + let mut bytes = [0u8; 12]; + let other_bytes = [0u8; 4]; + unsafe { ffi_func_with_out_ptr_and_len(&mut bytes as *mut u8, 16); } + assert_eq!(other_bytes, [0u8; 4]); +} +``` + +When we note in this reference that traps occur or errors are returned on memory access violations, we only mean those that WASM can directly detected, and not cases like the one above. + +Should memory access violations occur, such as a buffer overrun, undefined behavior will never result, as it does not exist in WASM. However, in many cases, an error code will result. + +Some functions will treat UTF-8 strings _lossily_. That is, if the slice identified by a `(ptr, len)` contains non-UTF-8 bytes, these bytes will be replaced with `�` in the read string. + +Most functions return a `u16` value. This is how these functions indicate an error where a `0` value means that there were no errors. Such functions will instead return any data they need to through out pointers. + +## Logging + +```rust +/// The error log level. +const LOG_LEVEL_ERROR: u8 = 0; +/// The warn log level. +const LOG_LEVEL_WARN: u8 = 1; +/// The info log level. +const LOG_LEVEL_INFO: u8 = 2; +/// The debug log level. +const LOG_LEVEL_DEBUG: u8 = 3; +/// The trace log level. +const LOG_LEVEL_TRACE: u8 = 4; +/// The panic log level. +/// +/// A panic level is emitted just before +/// a fatal error causes the WASM module to trap. +const LOG_LEVEL_PANIC: u8 = 101; + +/// Log at `level` a `text` message occuring in `filename:line_number` +/// with `target` being the module path at the `log!` invocation site. +/// +/// These various pointers are interpreted lossily as UTF-8 strings. +/// The data pointed to are copied. Ownership does not transfer. +/// +/// See https://docs.rs/log/latest/log/struct.Record.html#method.target +/// for more info on `target`. +/// +/// Calls to the function cannot fail +/// irrespective of memory access violations. +/// If they occur, no message is logged. +fn _console_log( + // The level we're logging at. + // One of the `LOG_*` constants above. + level: u8, + // The module path, if any, associated with the message + // or to "blame" for the reason we're logging. + // + // This is a pointer to a buffer holding an UTF-8 encoded string. + // When the pointer is `NULL`, `target` is ignored. + target: *const u8, + // The length of the buffer pointed to by `text`. + // Unused when `target` is `NULL`. + target_len: usize, + // The file name, if any, associated with the message + // or to "blame" for the reason we're logging. + // + // This is a pointer to a buffer holding an UTF-8 encoded string. + // When the pointer is `NULL`, `filename` is ignored. + filename: *const u8, + // The length of the buffer pointed to by `text`. + // Unused when `filename` is `NULL`. + filename_len: usize, + // The line number associated with the message + // or to "blame" for the reason we're logging. + line_number: u32, + // A pointer to a buffer holding an UTF-8 encoded message to log. + text: *const u8, + // The length of the buffer pointed to by `text`. + text_len: usize, +); +``` + +## Buffer handling + +```rust +/// Returns the length of buffer `bufh` without +/// transferring ownership of the data into the function. +/// +/// The `bufh` must have previously been allocating using `_buffer_alloc`. +/// +/// Traps if the buffer does not exist. +fn _buffer_len( + // The buffer previously allocated using `_buffer_alloc`. + // Ownership of the buffer is not taken. + bufh: ManuallyDrop +) -> usize; + +/// Consumes the buffer `bufh`, +/// moving its contents to the WASM byte slice `(ptr, len)`. +/// +/// Returns an error if the buffer does not exist +/// or on any memory access violations associated with `(ptr, len)`. +fn _buffer_consume( + // The buffer to consume and move into `(ptr, len)`. + // Ownership of the buffer and its contents are taken. + // That is, `bufh` won't be usable after this call. + bufh: Buffer, + // A WASM out pointer to write the contents of `bufh` to. + ptr: *mut u8, + // The size of the buffer pointed to by `ptr`. + // This size must match that of `bufh` or a trap will occur. + len: usize +); + +/// Creates a buffer of size `data_len` in the host environment. +/// +/// The contents of the byte slice lasting `data_len` bytes +/// at the `data` WASM pointer are read +/// and written into the newly initialized buffer. +/// +/// Traps on any memory access violations. +fn _buffer_alloc(data: *const u8, data_len: usize) -> Buffer; +``` + +## Reducer scheduling + +```rust +/// Schedules a reducer to be called asynchronously at `time`. +/// +/// The reducer is named as the valid UTF-8 slice `(name, name_len)`, +/// and is passed the slice `(args, args_len)` as its argument. +/// +/// A generated schedule id is assigned to the reducer. +/// This id is written to the pointer `out`. +/// +/// Errors on any memory access violations, +/// if `(name, name_len)` does not point to valid UTF-8, +/// or if the `time` delay exceeds `64^6 - 1` milliseconds from now. +fn _schedule_reducer( + // A pointer to a buffer + // with a valid UTF-8 string of `name_len` many bytes. + name: *const u8, + // The number of bytes in the `name` buffer. + name_len: usize, + // A pointer to a byte buffer of `args_len` many bytes. + args: *const u8, + // The number of bytes in the `args` buffer. + args_len: usize, + // When to call the reducer. + time: u64, + // The schedule ID is written to this out pointer on a successful call. + out: *mut u64, +); + +/// Unschedules a reducer +/// using the same `id` generated as when it was scheduled. +/// +/// This assumes that the reducer hasn't already been executed. +fn _cancel_reducer(id: u64); +``` + +## Altering tables + +```rust +/// Creates an index with the name `index_name` and type `index_type`, +/// on a product of the given columns in `col_ids` +/// in the table identified by `table_id`. +/// +/// Here `index_name` points to a UTF-8 slice in WASM memory +/// and `col_ids` points to a byte slice in WASM memory +/// with each element being a column. +/// +/// Currently only single-column-indices are supported +/// and they may only be of the btree index type. +/// In the former case, the function will panic, +/// and in latter, an error is returned. +/// +/// Returns an error on any memory access violations, +/// if `(index_name, index_name_len)` is not valid UTF-8, +/// or when a table with the provided `table_id` doesn't exist. +/// +/// Traps if `index_type /= 0` or if `col_len /= 1`. +fn _create_index( + // A pointer to a buffer holding an UTF-8 encoded index name. + index_name: *const u8, + // The length of the buffer pointed to by `index_name`. + index_name_len: usize, + // The ID of the table to create the index for. + table_id: u32, + // The type of the index. + // Must be `0` currently, that is, a btree-index. + index_type: u8, + // A pointer to a buffer holding a byte slice + // where each element is the position + // of a column to include in the index. + col_ids: *const u8, + // The length of the byte slice in `col_ids`. Must be `1`. + col_len: usize, +) -> u16; +``` + +## Inserting and deleting rows + +```rust +/// Inserts a row into the table identified by `table_id`, +/// where the row is read from the byte slice `row_ptr` in WASM memory, +/// lasting `row_len` bytes. +/// +/// Errors if there were unique constraint violations, +/// if there were any memory access violations in associated with `row`, +/// if the `table_id` doesn't identify a table, +/// or if `(row, row_len)` doesn't decode from BSATN to a `ProductValue` +/// according to the `ProductType` that the table's schema specifies. +fn _insert( + // The table to insert the row into. + // The interpretation of `(row, row_len)` depends on this ID + // as it's table schema determines how to decode the raw bytes. + table_id: u32, + // An in/out pointer to a byte buffer + // holding the BSATN-encoded `ProductValue` row data to insert. + // + // The pointer is written to with the inserted row re-encoded. + // This is due to auto-incrementing columns. + row: *mut u8, + // The length of the buffer pointed to by `row`. + row_len: usize +) -> u16; + +/// Deletes all rows in the table identified by `table_id` +/// where the column identified by `col_id` matches the byte string, +/// in WASM memory, pointed to by `value`. +/// +/// Matching is defined by decoding of `value` to an `AlgebraicValue` +/// according to the column's schema and then `Ord for AlgebraicValue`. +/// +/// The number of rows deleted is written to the WASM pointer `out`. +/// +/// Errors if there were memory access violations +/// associated with `value` or `out`, +/// if no columns were deleted, +/// or if the column wasn't found. +fn _delete_by_col_eq( + // The table to delete rows from. + table_id: u32, + // The position of the column to match `(value, value_len)` against. + col_id: u32, + // A pointer to a byte buffer holding a BSATN-encoded `AlgebraicValue` + // of the `AlgebraicType` that the table's schema specifies + // for the column identified by `col_id`. + value: *const u8, + // The length of the buffer pointed to by `value`. + value_len: usize, + // An out pointer that the number of rows deleted is written to. + out: *mut u32 +) -> u16; +``` + +## Querying tables + +```rust +/// Queries the `table_id` associated with the given (table) `name` +/// where `name` points to a UTF-8 slice +/// in WASM memory of `name_len` bytes. +/// +/// The table id is written into the `out` pointer. +/// +/// Errors on memory access violations associated with `name` +/// or if the table does not exist. +fn _get_table_id( + // A pointer to a buffer holding the name of the table + // as a valid UTF-8 encoded string. + name: *const u8, + // The length of the buffer pointed to by `name`. + name_len: usize, + // An out pointer to write the table ID to. + out: *mut u32 +) -> u16; + +/// Finds all rows in the table identified by `table_id`, +/// where the row has a column, identified by `col_id`, +/// with data matching the byte string, +/// in WASM memory, pointed to at by `val`. +/// +/// Matching is defined by decoding of `value` +/// to an `AlgebraicValue` according to the column's schema +/// and then `Ord for AlgebraicValue`. +/// +/// The rows found are BSATN encoded and then concatenated. +/// The resulting byte string from the concatenation +/// is written to a fresh buffer +/// with the buffer's identifier written to the WASM pointer `out`. +/// +/// Errors if no table with `table_id` exists, +/// if `col_id` does not identify a column of the table, +/// if `(value, value_len)` cannot be decoded to an `AlgebraicValue` +/// typed at the `AlgebraicType` of the column, +/// or if memory access violations occurred associated with `value` or `out`. +fn _iter_by_col_eq( + // Identifies the table to find rows in. + table_id: u32, + // The position of the column in the table + // to match `(value, value_len)` against. + col_id: u32, + // A pointer to a byte buffer holding a BSATN encoded + // value typed at the `AlgebraicType` of the column. + value: *const u8, + // The length of the buffer pointed to by `value`. + value_len: usize, + // An out pointer to which the new buffer's id is written to. + out: *mut Buffer +) -> u16; + +/// Starts iteration on each row, as bytes, +/// of a table identified by `table_id`. +/// +/// The iterator is registered in the host environment +/// under an assigned index which is written to the `out` pointer provided. +/// +/// Errors if the table doesn't exist +/// or if memory access violations occurred in association with `out`. +fn _iter_start( + // The ID of the table to start row iteration on. + table_id: u32, + // An out pointer to which an identifier + // to the newly created buffer is written. + out: *mut BufferIter +) -> u16; + +/// Like [`_iter_start`], starts iteration on each row, +/// as bytes, of a table identified by `table_id`. +/// +/// The rows are filtered through `filter`, which is read from WASM memory +/// and is encoded in the embedded language defined by `spacetimedb_lib::filter::Expr`. +/// +/// The iterator is registered in the host environment +/// under an assigned index which is written to the `out` pointer provided. +/// +/// Errors if `table_id` doesn't identify a table, +/// if `(filter, filter_len)` doesn't decode to a filter expression, +/// or if there were memory access violations +/// in association with `filter` or `out`. +fn _iter_start_filtered( + // The ID of the table to start row iteration on. + table_id: u32, + // A pointer to a buffer holding an encoded filter expression. + filter: *const u8, + // The length of the buffer pointed to by `filter`. + filter_len: usize, + // An out pointer to which an identifier + // to the newly created buffer is written. + out: *mut BufferIter +) -> u16; + +/// Advances the registered iterator with the index given by `iter_key`. +/// +/// On success, the next element (the row as bytes) is written to a buffer. +/// The buffer's index is returned and written to the `out` pointer. +/// If there are no elements left, an invalid buffer index is written to `out`. +/// On failure however, the error is returned. +/// +/// Errors if `iter` does not identify a registered `BufferIter`, +/// or if there were memory access violations in association with `out`. +fn _iter_next( + // An identifier for the iterator buffer to advance. + // Ownership of the buffer nor the identifier is moved into the function. + iter: ManuallyDrop, + // An out pointer to write the newly created buffer's identifier to. + out: *mut Buffer +) -> u16; + +/// Drops the entire registered iterator with the index given by `iter_key`. +/// The iterator is effectively de-registered. +/// +/// Returns an error if the iterator does not exist. +fn _iter_drop( + // An identifier for the iterator buffer to unregister / drop. + iter: ManuallyDrop +) -> u16; +``` + +## Appendix, `bindings.h` + +```c +#include +#include +#include +#include +#include + +typedef uint32_t Buffer; +typedef uint32_t BufferIter; + +void _console_log( + uint8_t level, + const uint8_t *target, + size_t target_len, + const uint8_t *filename, + size_t filename_len, + uint32_t line_number, + const uint8_t *text, + size_t text_len +); + + +Buffer _buffer_alloc( + const uint8_t *data, + size_t data_len +); +void _buffer_consume( + Buffer bufh, + uint8_t *into, + size_t len +); +size_t _buffer_len(Buffer bufh); + + +void _schedule_reducer( + const uint8_t *name, + size_t name_len, + const uint8_t *args, + size_t args_len, + uint64_t time, + uint64_t *out +); +void _cancel_reducer(uint64_t id); + + +uint16_t _create_index( + const uint8_t *index_name, + size_t index_name_len, + uint32_t table_id, + uint8_t index_type, + const uint8_t *col_ids, + size_t col_len +); + + +uint16_t _insert( + uint32_t table_id, + uint8_t *row, + size_t row_len +); +uint16_t _delete_by_col_eq( + uint32_t table_id, + uint32_t col_id, + const uint8_t *value, + size_t value_len, + uint32_t *out +); + + +uint16_t _get_table_id( + const uint8_t *name, + size_t name_len, + uint32_t *out +); +uint16_t _iter_by_col_eq( + uint32_t table_id, + uint32_t col_id, + const uint8_t *value, + size_t value_len, + Buffer *out +); +uint16_t _iter_drop(BufferIter iter); +uint16_t _iter_next(BufferIter iter, Buffer *out); +uint16_t _iter_start(uint32_t table_id, BufferIter *out); +uint16_t _iter_start_filtered( + uint32_t table_id, + const uint8_t *filter, + size_t filter_len, + BufferIter *out +); +``` + +[`bindings_sys::raw`]: https://github.com/clockworklabs/SpacetimeDB/blob/master/crates/bindings-sys/src/lib.rs#L44-L215 +[`bindings`]: https://github.com/clockworklabs/SpacetimeDB/blob/master/crates/bindings/src/lib.rs +[module_ref]: /docs/languages/rust/rust-module-reference +[module_quick_start]: /docs/languages/rust/rust-module-quick-start +[wasm_c_abi]: https://github.com/WebAssembly/tool-conventions/blob/main/BasicCABI.md +[c_header]: #appendix-bindingsh diff --git a/docs/docs/Overview/_category.json b/docs/docs/Overview/_category.json new file mode 100644 index 00000000000..35164a50a91 --- /dev/null +++ b/docs/docs/Overview/_category.json @@ -0,0 +1 @@ +{"title":"Overview","disabled":false,"index":"index.md"} \ No newline at end of file diff --git a/docs/docs/Overview/index.md b/docs/docs/Overview/index.md new file mode 100644 index 00000000000..2464e6e3303 --- /dev/null +++ b/docs/docs/Overview/index.md @@ -0,0 +1,114 @@ +# SpacetimeDB Documentation + +## Installation + +You can run SpacetimeDB as a standalone database server via the `spacetime` CLI tool. + +You can find the instructions to install the CLI tool for your platform [here](/install). + + + +To get started running your own standalone instance of SpacetimeDB check out our [Getting Started Guide](/docs/getting-started). + + + +## What is SpacetimeDB? + +You can think of SpacetimeDB as 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". + +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. + +
+ SpacetimeDB Architecture +
+ SpacetimeDB application architecture + (elements in white are provided by SpacetimeDB) +
+
+ +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. + +## 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`. + +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. + +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. + +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. + +## Language Support + +### Server-side Libraries + +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) +- Python (Coming soon) +- C# (Coming soon) +- Typescript (Coming soon) +- C++ (Planned) +- Lua (Planned) + +### 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) +- 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). + +## FAQ + +1. What is SpacetimeDB? + It's a whole 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. + +1. How do I get/install SpacetimeDB? + 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! + +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-project) guide! + +TL;DR in an empty directory: + +```bash +spacetime init --lang=rust +spacetime publish +spacetime generate --out-dir --lang=csharp +``` diff --git a/docs/docs/SATN Reference/Binary Format.md b/docs/docs/SATN Reference/Binary Format.md new file mode 100644 index 00000000000..0da55ce73ea --- /dev/null +++ b/docs/docs/SATN Reference/Binary Format.md @@ -0,0 +1,115 @@ +# SATN Binary Format (BSATN) + +The Spacetime Algebraic Type Notation binary (BSATN) format defines +how Spacetime `AlgebraicValue`s and friends are encoded as byte strings. + +Algebraic values and product values are BSATN-encoded for e.g., +module-host communication and for storing row data in the database. + +## Notes on notation + +In this reference, we give a formal definition of the format. +To do this, we use inductive definitions, and define the following notation: + +- `bsatn(x)` denotes a function converting some value `x` to a list of bytes. +- `a: B` means that `a` is of type `B`. +- `Foo(x)` denotes extracting `x` out of some variant or type `Foo`. +- `a ++ b` denotes concatenating two byte lists `a` and `b`. +- `bsatn(A) = bsatn(B) | ... | bsatn(Z)` where `B` to `Z` are variants of `A` + means that `bsatn(A)` is defined as e.g., + `bsatn(B)`, `bsatn(C)`, .., `bsatn(Z)` depending on what variant of `A` it was. +- `[]` denotes the empty list of bytes. + +## Values + +### 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). | + +### `AlgebraicValue` + +The BSATN encoding of an `AlgebraicValue` defers to the encoding of each variant: + +```fsharp +bsatn(AlgebraicValue) = bsatn(SumValue) | bsatn(ProductValue) | bsatn(BuiltinValue) +``` + +### `SumValue` + +An instance of a [`SumType`](#sumtype). +`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), +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). +`ProductValue`s are binary encoded as: + +```fsharp +bsatn(elems) = bsatn(elem_0) ++ .. ++ bsatn(elem_n) +``` + +Field names are not encoded. + +### `BuiltinValue` + +An instance of a [`BuiltinType`](#builtintype). +The BSATN encoding of `BuiltinValue`s defers to the encoding of each variant: + +```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(Map) + +bsatn(Bool(b)) = bsatn(b as u8) +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(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(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) +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 + +- `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) +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/docs/SATN Reference/_category.json b/docs/docs/SATN Reference/_category.json new file mode 100644 index 00000000000..e26b2f0564a --- /dev/null +++ b/docs/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/docs/SATN Reference/index.md b/docs/docs/SATN Reference/index.md new file mode 100644 index 00000000000..cedc496a726 --- /dev/null +++ b/docs/docs/SATN Reference/index.md @@ -0,0 +1,163 @@ +# 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). + +## Values + +### 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). | +| | | + +### `AlgebraicValue` + +```json +SumValue | ProductValue | BuiltinValue +``` + +### `SumValue` + +An instance of a [`SumType`](#sumtype). `SumValue`s are encoded as a JSON object with a single key, a non-negative integer tag which identifies the variant. The value associated with this key is the variant data. Variants which hold no data will have an empty array as their value. + +The tag is an index into the [`SumType.variants`](#sumtype) array of the value's [`SumType`](#sumtype). + +```json +{ + "": AlgebraicValue +} +``` + +### `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). + +```json +array +``` + +### `BuiltinValue` + +An instance of a [`BuiltinType`](#builtintype). `BuiltinValue`s are encoded as JSON values of corresponding types. + +```json +boolean | number | string | array | map +``` + +| [`BuiltinType`](#builtintype) | JSON type | +| ----------------------------- | ------------------------------------- | +| `Bool` | `boolean` | +| Integer types | `number` | +| Float types | `number` | +| `String` | `string` | +| Array types | `array` | +| Map types | `map` | + +All SATS integer types are encoded as JSON `number`s, so values of 64-bit and 128-bit integer types may lose precision when encoding values larger than 2⁵². + +## Types + +All SATS types are JSON-encoded by converting them to an `AlgebraicValue`, then JSON-encoding that meta-value. + +### At a glance + +| Type | Description | +| --------------------------------------- | ------------------------------------------------------------------------------------ | +| [`AlgebraicType`](#algebraictype) | Any SATS type. | +| [`SumType`](#sumtype) | Sum types, i.e. tagged unions. | +| [`ProductType`](#productype) | 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. | + +#### `AlgebraicType` + +`AlgebraicType` is the most general meta-type in the Spacetime Algebraic Type System. Any SATS type can be represented as an `AlgebraicType`. `AlgebraicType` is encoded as a tagged union, with variants for [`SumType`](#sumtype), [`ProductType`](#producttype), [`BuiltinType`](#builtintype) and [`AlgebraicTypeRef`](#algebraictyperef). + +```json +{ "Sum": SumType } +| { "Product": ProductType } +| { "Builtin": BuiltinType } +| { "Ref": AlgebraicTypeRef } +``` + +#### `SumType` + +The meta-type `SumType` represents sum types, also called tagged unions or Rust `enum`s. A sum type has some number of variants, each of which has an `AlgebraicType` of variant data, and an optional string discriminant. For each instance, exactly one variant will be active. The instance will contain only that variant's data. + +A `SumType` with zero variants is called an empty type or never type because it is impossible to construct an instance. + +Instances of `SumType`s are [`SumValue`s](#sumvalue), and store a tag which identifies the active variant. + +```json +// SumType: +{ + "variants": array, +} + +// SumTypeVariant: +{ + "algebraic_type": AlgebraicType, + "name": { "some": string } | { "none": [] } +} +``` + +### `ProductType` + +The meta-type `ProductType` represents product types, also called structs or tuples. A product type has some number of fields, each of which has an `AlgebraicType` of field data, and an optional string field name. Each instance will contain data for all of the product type's fields. + +A `ProductType` with zero fields is called a unit type because it has a single instance, the unit, which is empty. + +Instances of `ProductType`s are [`ProductValue`s](#productvalue), and store an array of field data. + +```json +// ProductType: +{ + "elements": array, +} + +// ProductTypeElement: +{ + "algebraic_type": AlgebraicType, + "name": { "some": string } | { "none": [] } +} +``` + +### `BuiltinType` + +The meta-type `BuiltinType` represents SATS primitive types: booleans, integers, floating-point numbers, strings, arrays and maps. `BuiltinType` is encoded as a tagged union, with a variant for each SATS primitive type. + +SATS integer types are identified by their signedness and width in bits. SATS supports the same set of integer types as Rust, i.e. 8, 16, 32, 64 and 128-bit signed and unsigned integers. + +SATS floating-point number types are identified by their width in bits. SATS supports 32 and 64-bit floats, which correspond to [IEEE 754](https://en.wikipedia.org/wiki/IEEE_754) single- and double-precision binary floats, respectively. + +SATS array and map types are homogeneous, meaning that each array has a single element type to which all its elements must conform, and each map has a key type and a value type to which all of its keys and values must conform. + +```json +{ "Bool": [] } +| { "I8": [] } +| { "U8": [] } +| { "I16": [] } +| { "U16": [] } +| { "I32": [] } +| { "U32": [] } +| { "I64": [] } +| { "U64": [] } +| { "I128": [] } +| { "U128": [] } +| { "F32": [] } +| { "F64": [] } +| { "String": [] } +| { "Array": AlgebraicType } +| { "Map": { + "key_ty": AlgebraicType, + "ty": AlgebraicType, + } } +``` + +### `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). diff --git a/docs/docs/SQL Reference/_category.json b/docs/docs/SQL Reference/_category.json new file mode 100644 index 00000000000..73d7df23590 --- /dev/null +++ b/docs/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/docs/SQL Reference/index.md b/docs/docs/SQL Reference/index.md new file mode 100644 index 00000000000..08f9536a36a --- /dev/null +++ b/docs/docs/SQL Reference/index.md @@ -0,0 +1,407 @@ +# 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 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/). + +SpacetimeDB 0.6 implements a relatively small subset of SQL. Future SpacetimeDB versions will implement additional SQL features. + +## Types + +| 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. | + +### Definition statements + +| Statement | Description | +| ----------------------------- | ------------------------------------ | +| [CREATE TABLE](#create-table) | Create a new table. | +| [DROP TABLE](#drop-table) | Remove a table, discarding all rows. | + +### Query statements + +| 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. | + +## Data types + +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. + +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. + +Most SATS builtin types map cleanly to SQL types. + +### Nullable types + +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`. + +### Logic types + +| SQL | SATS | Example | +| --------- | ------ | --------------- | +| `BOOLEAN` | `Bool` | `true`, `false` | + +### Numeric types + +#### Integer types + +An integer is a number without a fractional component. + +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. + +| 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 | + +#### Floating-point types + +SpacetimeDB supports single- and double-precision [binary IEEE-754 floats](https://en.wikipedia.org/wiki/IEEE_754). + +| 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 | + +### Text types + +SpacetimeDB supports a single string type, `String`. SpacetimeDB strings are UTF-8 encoded. + +| SQL | SATS | Example | Notes | +| ----------------------------------------------- | -------- | ------- | -------------------- | +| `CHAR`, `VARCHAR`, `NVARCHAR`, `TEXT`, `STRING` | `String` | 'hello' | Always UTF-8 encoded | + +> SpacetimeDB SQL currently does not support length contraints like `CHAR(10)`. + +## Syntax + +### Comments + +SQL line comments begin with `--`. + +```sql +-- This is a comment +``` + +### Expressions + +We can express different, composable, values that are universally called `expressions`. + +An expression is one of the following: + +#### Literals + +| Example | Description | +| --------- | ----------- | +| `1` | An integer. | +| `1.0` | A float. | +| `'hello'` | A string. | +| `true` | A boolean. | + +#### Binary operators + +| Example | Description | +| ------- | ------------------- | +| `1 > 2` | Integer comparison. | +| `1 + 2` | Integer addition. | + +#### Logical expressions + +Any expression which returns a boolean, i.e. `true` or `false`, is a logical expression. + +| 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. | + +#### Function calls + +| Example | Description | +| --------------- | -------------------------------------------------- | +| `lower('JOHN')` | Apply the function `lower` to the string `'JOHN'`. | + +#### Table identifiers + +| Example | Description | +| ------------- | ------------------------- | +| `inventory` | Refers to a table. | +| `"inventory"` | Refers to the same table. | + +#### Column references + +| 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. | + +#### Wildcards + +Special "star" expressions which select all the columns of a table. + +| Example | Description | +| ------------- | ------------------------------------------------------- | +| `*` | Refers to all columns of a table identified by context. | +| `inventory.*` | Refers to all columns of the `inventory` table. | + +#### Parenthesized expressions + +Sub-expressions can be enclosed in parentheses for grouping and to override operator precedence. + +| Example | Description | +| ------------- | ----------------------- | +| `1 + (2 / 3)` | One plus a fraction. | +| `(1 + 2) / 3` | A sum divided by three. | + +### `CREATE TABLE` + +A `CREATE TABLE` statement creates a new, initially empty table in the database. + +The syntax of the `CREATE TABLE` statement is: + +> **CREATE TABLE** _table_name_ (_column_name_ _data_type_, ...); + +![create-table](/images/syntax/create_table.svg) + +#### 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); +``` + +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); +``` + +Create a table `location` with three columns, an integer `entity_id` and floats `x` and `z`: + +```sql +CREATE TABLE location (entity_id INTEGER, x REAL, z REAL); +``` + +### `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: + +> **DROP TABLE** _table_name_; + +![drop-table](/images/syntax/drop_table.svg) + +Examples: + +```sql +DROP TABLE inventory; +``` + +## Queries + +### `FROM` + +A `FROM` clause derives a data source from a table name. + +The syntax of the `FROM` clause is: + +> **FROM** _table_name_ _join_clause_?; + +![from](/images/syntax/from.svg) + +#### Examples + +Select all rows from the `inventory` table: + +```sql +SELECT * FROM inventory; +``` + +### `JOIN` + +A `JOIN` clause combines two data sources into a new data source. + +Currently, SpacetimeDB SQL supports only inner joins, which return rows from two data sources where the values of two columns match. + +The syntax of the `JOIN` clause is: + +> **JOIN** _table_name_ **ON** _expr_ = _expr_; + +![join](/images/syntax/join.svg) + +### Examples + +Select all players rows who have a corresponding location: + +```sql +SELECT player.* FROM player + JOIN location + ON location.entity_id = player.entity_id; +``` + +Select all inventories which have a corresponding player, and where that player has a corresponding location: + +```sql +SELECT inventory.* FROM inventory + JOIN player + ON inventory.inventory_id = player.inventory_id + JOIN location + ON player.entity_id = location.entity_id; +``` + +### `SELECT` + +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. + +The syntax of the `SELECT` command is: + +> **SELECT** _column_expr_ > **FROM** _from_expr_ +> {**WHERE** _expr_}? + +![sql-select](/images/syntax/select.svg) + +#### Examples + +Select all columns of all rows from the `inventory` table: + +```sql +SELECT * FROM inventory; +SELECT inventory.* FROM inventory; +``` + +Select only the `inventory_id` column of all rows from the `inventory` table: + +```sql +SELECT inventory_id FROM inventory; +SELECT inventory.inventory_id FROM inventory; +``` + +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`. + +#### 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; +``` + +Select all columns of all rows from the `inventory` table with the `inventory_id` 1: + +```sql +SELECT * FROM inventory WHERE inventory_id = 1; +``` + +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; +``` + +Select all columns of all rows from the `inventory` table where the `inventory_id` is 2 or greater: + +```sql +SELECT * FROM inventory WHERE inventory_id > 1; +``` + +### `INSERT` + +An `INSERT INTO` statement inserts new rows into a table. + +One can insert one or more rows specified by value expressions. + +The syntax of the `INSERT INTO` statement is: + +> **INSERT INTO** _table_name_ (_column_name_, ...) **VALUES** (_expr_, ...), ...; + +![sql-insert](/images/syntax/insert.svg) + +#### Examples + +Insert a single row: + +```sql +INSERT INTO inventory (inventory_id, name) VALUES (1, 'health1'); +``` + +Insert two rows: + +```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. + +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) + +#### Examples + +Set the `name` column of all rows from the `inventory` table with the `inventory_id` 1 to `'new name'`: + +```sql +UPDATE inventory + SET name = 'new name' + WHERE inventory_id = 1; +``` + +### DELETE + +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. + +The syntax of the `DELETE` statement is + +> **DELETE** _table_name_ +> {**WHERE** _expr_}?; + +![sql-delete](/images/syntax/delete.svg) + +#### Examples + +Delete all the rows from the `inventory` table with the `inventory_id` 1: + +```sql +DELETE FROM inventory WHERE inventory_id = 1; +``` + +Delete all rows from the `inventory` table, leaving it empty: + +```sql +DELETE FROM inventory; +``` diff --git a/docs/docs/Server Module Languages/C#/ModuleReference.md b/docs/docs/Server Module Languages/C#/ModuleReference.md new file mode 100644 index 00000000000..305ea211d08 --- /dev/null +++ b/docs/docs/Server Module Languages/C#/ModuleReference.md @@ -0,0 +1,311 @@ +# SpacetimeDB C# Modules + +You can use the [C# SpacetimeDB library](https://github.com/clockworklabs/SpacetimeDBLibCSharp) to write modules in C# which interact with the SpacetimeDB database. + +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. + +## Example + +Let's start with a heavily commented version of the default example from the landing page: + +```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; + +// 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. +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 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)] + 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) + { + // 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}"); + } + + [SpacetimeDB.Reducer] + public static void SayHello() + { + // Each table type gets a static Iter() method that can be used to iterate over the entire table. + foreach (var person in Person.Iter()) + { + Log($"Hello, {person.Name}!"); + } + Log("Hello, World!"); + } +} +``` + +## API reference + +Now we'll get into details on all the APIs SpacetimeDB provides for writing modules in C#. + +### Logging + +First of all, logging as we're likely going to use it a lot for debugging and reporting errors. + +`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. + +Supported log levels are provided by the `LogLevel` enum: + +```csharp +public enum LogLevel +{ + Error, + Warn, + Info, + Debug, + Trace, + Panic +} +``` + +If omitted, the log level will default to `Info`, so these two forms are equivalent: + +```csharp +Log("Hello, World!"); +Log("Hello, World!", LogLevel.Info); +``` + +### Supported types + +#### Built-in types + +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: + +- `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) + +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. + +#### 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. + +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. + +```csharp +[SpacetimeDB.Type] +public partial struct Point +{ + public int x; + public int y; +} +``` + +`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. + +```csharp +[SpacetimeDB.Type] +public enum Color +{ + Red, + Green, + Blue, +} +``` + +#### Tagged enums + +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. + +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. + +```csharp +// Example declaration: +[SpacetimeDB.Type] +partial struct Option : SpacetimeDB.TaggedEnum<(T Some, Unit None)> { } + +// Usage: +var option = new Option { Some = 42 }; +if (option.IsSome) +{ + Log($"Value: {option.Some}"); +} +``` + +### 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. + +It implies `[SpacetimeDB.Type]`, so you must not specify both attributes on the same type. + +```csharp +[SpacetimeDB.Table] +public partial struct Person +{ + [SpacetimeDB.Column(ColumnAttrs.Unique | ColumnAttrs.AutoInc)] + public int Id; + public string Name; + public int Age; +} +``` + +The example above will generate the following extra methods: + +```csharp +public partial struct Person +{ + // Inserts current instance as a new row into the table. + public void Insert(); + + // Returns an iterator over all rows in the table, e.g.: + // `for (var person in Person.Iter()) { ... }` + public static IEnumerable Iter(); + + // 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); + + // Generated for each column: + + // 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); + + // Generated for each unique column: + + // 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); +} +``` + +#### 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. + +The supported column attributes are: + +- `ColumnAttrs.AutoInc` - this column should be auto-incremented. +- `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. + +These attributes are bitflags and can be combined together, but you can also use some predefined shortcut aliases: + +- `ColumnAttrs.Identity` - same as `ColumnAttrs.Unique | ColumnAttrs.AutoInc`. +- `ColumnAttrs.PrimaryKeyAuto` - same as `ColumnAttrs.PrimaryKey | ColumnAttrs.AutoInc`. + +### Reducers + +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. + +```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}"); +} +``` + +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: + +```csharp +[SpacetimeDB.Reducer] +public static void PrintInfo(DbEventArgs e) +{ + Log($"Sender: {e.Sender}"); + Log($"Time: {e.Time}"); +} +``` + +`[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`. + +```csharp +// Example reducer: +[SpacetimeDB.Reducer] +public static void Add(string name, int age) { ... } + +// Auto-generated by the codegen: +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) +{ + // 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(); +} +``` + +#### 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: + +- `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. + +Example: + +```csharp +[SpacetimeDB.Reducer(ReducerKind.Init)] +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/docs/Server Module Languages/C#/_category.json b/docs/docs/Server Module Languages/C#/_category.json new file mode 100644 index 00000000000..71ae9015f93 --- /dev/null +++ b/docs/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/docs/Server Module Languages/C#/index.md b/docs/docs/Server Module Languages/C#/index.md new file mode 100644 index 00000000000..e849002f6e7 --- /dev/null +++ b/docs/docs/Server Module Languages/C#/index.md @@ -0,0 +1,292 @@ +# C# Module Quickstart + +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. + +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. + +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 + +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 + +Next we need to [install .NET](https://dotnet.microsoft.com/en-us/download/dotnet) so that we can build and publish our module. + +## Project structure + +Create and enter a directory `quickstart-chat`: + +```bash +mkdir quickstart-chat +cd quickstart-chat +``` + +Now create `server`, our module, which runs in the database: + +```bash +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. + +To the top of `server/Lib.cs`, add some imports we'll be using: + +```C# +using System.Runtime.CompilerServices; +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. +- `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 +{ +} +``` + +## 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. + +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; + } +``` + +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 = ""; + } +``` + +## Set users' names + +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`. + +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: + +```C# + [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); + } + } +``` + +For now, we'll just do a bare minimum of validation, rejecting the empty name. You could extend this in various ways, like: + +- Comparing against a blacklist for moderation purposes. +- Unicode-normalizing names. +- Rejecting names that contain non-printable characters, or removing characters or replacing them with a placeholder. +- Rejecting or truncating long names. +- Rejecting duplicate names. + +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) + { + if (string.IsNullOrEmpty(name)) + { + throw new Exception("Names must not be empty"); + } + return 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`. + +In `server/Lib.cs`, add to the `Module` class: + +```C# + [SpacetimeDB.Reducer] + public static void SendMessage(DbEventArgs dbEvent, string text) + { + text = ValidateMessage(text); + Log(text); + new Message + { + 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) + { + if (string.IsNullOrEmpty(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: + +- Rejecting messages from senders who haven't set their names. +- Rate-limiting users so they can't send new messages too quickly. + +## 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. + +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`. + +In `server/Lib.cs`, add the definition of the connect reducer to the `Module` class: + +```C# + [ModuleInitializer] + public static void Init() + { + OnConnect += (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 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(); + } + }; + } +``` + +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. + +Add the following code after the `OnConnect` lambda: + +```C# + OnDisconnect += (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."); + } + }; +``` + +## 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 the `quickstart-chat` directory, run: + +```bash +spacetime publish --project-path server +``` + +## 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!"]' +``` + +Once we've called our `send_message` reducer, we can check to make sure it ran by running the `logs` command. + +```bash +spacetime logs +``` + +You should now see the output that your module printed in the database. + +```bash +info: Hello, World! +``` + +## SQL Queries + +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" +``` + +```bash + text +--------- + "Hello, World!" +``` + +## 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). + +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). diff --git a/docs/docs/Server Module Languages/Rust/ModuleReference.md b/docs/docs/Server Module Languages/Rust/ModuleReference.md new file mode 100644 index 00000000000..05d62bdc201 --- /dev/null +++ b/docs/docs/Server Module Languages/Rust/ModuleReference.md @@ -0,0 +1,454 @@ +# SpacetimeDB Rust Modules + +Rust clients of SpacetimeDB use the [Rust SpacetimeDB module library][module library] to write modules which interact with the SpacetimeDB database. + +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. +#[spacetimedb(table)] +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. +#[spacetimedb(reducer)] +pub fn add(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) +} + +// 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 +// functions, not methods. +#[spacetimedb(reducer)] +pub fn say_hello() { + // 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() { + // 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. +#[spacetimedb(reducer)] +pub fn add_person(name: String) -> Result<(), String> { + if name.is_empty() { + return Err("Name cannot be empty"); + } + + 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 + +`#[spacetimedb(table)]` takes no further arguments, and is applied to a Rust struct with named fields: + +```rust +#[spacetimedb(table)] +struct Table { + 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 `#[spacetimedb(table)]` types are `SpacetimeType`s, and accordingly, all of their fields have to be. + +```rust +#[spacetimedb(table)] +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 +#[spacetimedb(table)] +struct Person { + #[unique] + id: u64, + + name: String, + address: String, +} +``` + +### 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 +#[spacetimedb(reducer)] +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)?; + item.owner = Some(player_id); + Item::update_by_id(id, item); + Ok(()) +} + +struct Item { + #[unique] + item_id: u64, + + owner: Option, +} +``` + +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. + +```rust +#[spacetimedb(reducer, repeat = 1s)] +fn every_second() {} + +#[spacetimedb(reducer, repeat = 1000ms)] +fn every_thousand_milliseconds() {} +``` + +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 +#[spacetimedb(reducer, repeat = 1s)] +fn tick_timestamp(time: Timestamp) { + println!("tick at {time}"); +} + +#[spacetimedb(reducer, repeat = 500ms)] +fn tick_ctx(ctx: ReducerContext) { + println!("tick at {}", ctx.timestamp) +} +``` + +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. + +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] + +#[sats] + +## 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, +}; + +#[spacetimedb(reducer)] +fn output(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. + OutputtedNumbers::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 +#[spacetimedb(table)] +struct Ordinary { + ordinary_field: u64, +} +``` + +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)] +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 +#[spacetimedb(table)] +struct Autoinc { + #[autoinc] + autoinc_field: u64, +} +``` + +These attributes can be combined, to create an automatically assigned ID usable for filtering. + +```rust +#[spacetimedb(table)] +struct Identity { + #[autoinc] + #[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 `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) { + let ordinary = Ordinary { ordinary_field: value }; + let result = 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 +#[spacetimedb(reducer)] +fn insert_unique(value: u64) { + let result = Ordinary::insert(Unique { unique_field: value }); + assert!(result.is_ok()); + + let result = Ordinary::insert(Unique { unique_field: value }); + assert!(result.is_err()); +} +``` + +When inserting a table with an `#[autoinc]` 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 +#[spacetimedb(reducer)] +fn insert_autoinc() { + 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 }) + assert_eq!(actual.autoinc_field, i); + } +} + +#[spacetimedb(reducer)] +fn insert_id() { + 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 { autoinc_field: 23 }) + } +} +``` + +### Iterating + +Given a table, we can iterate over all the rows in it. + +```rust +#[spacetimedb(table)] +struct Person { + #[unique] + id: u64, + + age: u32, + name: String, + address: String, +} +``` + +// Every table structure an iter function, like: + +```rust +fn MyTable::iter() -> TableIter +``` + +`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() { + let mut addresses = HashSet::new(); + + for person in 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 +#[spacetimedb(reducer)] +fn filtering(id: u64) { + match Person::filter_by_id(&id) { + Some(person) => println!("Found {person}"), + None => println!("No person with id {id}"), + } +} +``` + +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::filter_by_age(&21) { + println!("{person} has turned 21"); + } +} +``` + +### Deleting + +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) +} +``` + +[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 diff --git a/docs/docs/Server Module Languages/Rust/_category.json b/docs/docs/Server Module Languages/Rust/_category.json new file mode 100644 index 00000000000..6280366ccfe --- /dev/null +++ b/docs/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/docs/Server Module Languages/Rust/index.md b/docs/docs/Server Module Languages/Rust/index.md new file mode 100644 index 00000000000..ed59d8dd4ba --- /dev/null +++ b/docs/docs/Server Module Languages/Rust/index.md @@ -0,0 +1,272 @@ +# Rust Module Quickstart + +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. + +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. + +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. + +## 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. + +## 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: + +```bash +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +``` + +If you're on Windows, go [here](https://learn.microsoft.com/en-us/windows/dev-environment/rust/setup). + +## Project structure + +Create and enter a directory `quickstart-chat`: + +```bash +mkdir quickstart-chat +cd quickstart-chat +``` + +Now create `server`, our module, which runs in the database: + +```bash +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. + +To the top of `server/src/lib.rs`, add some imports we'll be using: + +```rust +use spacetimedb::{spacetimedb, ReducerContext, Identity, Timestamp}; +``` + +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. +- `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. + +To `server/src/lib.rs`, add the definition of the table `User`: + +```rust +#[spacetimedb(table)] +pub struct User { + #[primarykey] + identity: Identity, + name: Option, + online: bool, +} +``` + +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. + +To `server/src/lib.rs`, add the definition of the table `Message`: + +```rust +#[spacetimedb(table)] +pub struct Message { + sender: Identity, + sent: Timestamp, + text: String, +} +``` + +## Set users' names + +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`. + +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> { + let name = validate_name(name)?; + if let Some(user) = User::filter_by_identity(&ctx.sender) { + User::update_by_identity(&ctx.sender, User { name: Some(name), ..user }); + Ok(()) + } else { + Err("Cannot set name for unknown user".to_string()) + } +} +``` + +For now, we'll just do a bare minimum of validation, rejecting the empty name. You could extend this in various ways, like: + +- Comparing against a blacklist for moderation purposes. +- Unicode-normalizing names. +- Rejecting names that contain non-printable characters, or removing characters or replacing them with a placeholder. +- Rejecting or truncating long names. +- Rejecting duplicate names. + +To `server/src/lib.rs`, add: + +```rust +/// Takes a name and checks if it's acceptable as a user's name. +fn validate_name(name: String) -> Result { + if name.is_empty() { + Err("Names must not be empty".to_string()) + } else { + Ok(name) + } +} +``` + +## 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`. + +To `server/src/lib.rs`, add: + +```rust +#[spacetimedb(reducer)] +/// Clients invoke this reducer to send messages. +pub fn send_message(ctx: ReducerContext, text: String) -> Result<(), String> { + let text = validate_message(text)?; + log::info!("{}", text); + Message::insert(Message { + sender: ctx.sender, + text, + sent: ctx.timestamp, + }); + Ok(()) +} +``` + +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. + +To `server/src/lib.rs`, add: + +```rust +/// Takes a message's text and checks if it's acceptable to send. +fn validate_message(text: String) -> Result { + if text.is_empty() { + Err("Messages must not be empty".to_string()) + } else { + Ok(text) + } +} +``` + +You could extend the validation in `validate_message` in similar ways to `validate_name`, or add additional checks to `send_message`, like: + +- Rejecting messages from senders who haven't set their names. +- Rate-limiting users so they can't send new messages too quickly. + +## 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. + +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: + +```rust +#[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 this is a returning user, i.e. we already have a `User` with this `Identity`, + // set `online: true`, but leave `name` and `identity` unchanged. + User::update_by_identity(&ctx.sender, User { online: true, ..user }); + } else { + // If this is a new user, create a `User` row for the `Identity`, + // which is online, but hasn't set a name. + User::insert(User { + name: None, + identity: ctx.sender, + online: true, + }).unwrap(); + } +} +``` + +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. + +```rust +#[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) { + User::update_by_identity(&ctx.sender, User { online: false, ..user }); + } else { + // This branch should be unreachable, + // as it doesn't make sense for a client to disconnect without connecting first. + log::warn!("Disconnect event for unknown user with identity {:?}", ctx.sender); + } +} +``` + +## 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 ``. + +From the `quickstart-chat` directory, run: + +```bash +spacetime publish --project-path server +``` + +## 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!"]' +``` + +Once we've called our `send_message` reducer, we can check to make sure it ran by running the `logs` command. + +```bash +spacetime logs +``` + +You should now see the output that your module printed in the database. + +```bash +info: Hello, World! +``` + +## SQL Queries + +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" +``` + +```bash + text +--------- + "Hello, World!" +``` + +## What's next? + +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). + +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). diff --git a/docs/docs/Server Module Languages/_category.json b/docs/docs/Server Module Languages/_category.json new file mode 100644 index 00000000000..3bfa0e87292 --- /dev/null +++ b/docs/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/docs/Server Module Languages/index.md b/docs/docs/Server Module Languages/index.md new file mode 100644 index 00000000000..d66681319f9 --- /dev/null +++ b/docs/docs/Server Module Languages/index.md @@ -0,0 +1,30 @@ +# Server Module Overview + +Server modules are the core of a SpacetimeDB application. They define the structure of the database and the server-side logic that processes and handles client requests. These functions are called reducers and are transactional, meaning they ensure data consistency and integrity. Reducers can perform operations such as inserting, updating, and deleting data in the database. + +In the following sections, we'll cover the basics of server modules and how to create and deploy them. + +## Supported Languages + +### 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 Module Reference](/docs/server-languages/rust/rust-module-reference) +- [Rust Module Quickstart Guide](/docs/server-languages/rust/rust-module-quickstart-guide) + +### 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) + +### Coming Soon + +We have plans to support additional languages in the future. + +- Python +- Typescript +- C++ +- Lua diff --git a/docs/docs/Unity Tutorial/Part 2 - Resources And Scheduling.md b/docs/docs/Unity Tutorial/Part 2 - Resources And Scheduling.md new file mode 100644 index 00000000000..5cd205efc17 --- /dev/null +++ b/docs/docs/Unity Tutorial/Part 2 - Resources And Scheduling.md @@ -0,0 +1,255 @@ +# Part 2 - Resources and Scheduling + +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 + +In this section we will add functionality to our server to spawn the resource nodes. + +### Step 1: Add the SpacetimeDB Tables for Resource Nodes + +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. + +```toml +rand = "0.8.5" +``` + +We also need to add the `getrandom` feature to our SpacetimeDB crate. Update the `spacetimedb` line to: + +```toml +spacetimedb = { "0.5", features = ["getrandom"] } +``` + +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. + +```rust +#[derive(SpacetimeType, Clone)] +pub enum ResourceNodeType { + Iron, +} + +#[spacetimedb(table)] +#[derive(Clone)] +pub struct ResourceNodeComponent { + #[primarykey] + pub entity_id: u64, + + // Resource type of this resource node + pub resource_type: ResourceNodeType, +} +``` + +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)] +#[derive(Clone)] +pub struct StaticLocationComponent { + #[primarykey] + pub entity_id: u64, + + pub location: StdbVector2, + pub rotation: f32, +} +``` + +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)] +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, +} +``` + +4. In the `init` reducer, we need to set the initial values of our two new variables. Update the following code: + +```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."); +``` + +### 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. + +```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(); + + // Retrieve the maximum number of nodes we want to spawn from the Config table + let num_resource_nodes = config.num_resource_nodes as usize; + + // 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(()); + } + + // 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. + +```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. + +```rust + // Start our resource spawner repeating reducer + spacetimedb::schedule!("1000ms", resource_spawner_agent(_, Timestamp::now())); +``` + +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 +``` + +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. + +```bash +spacetime logs -f yourname/bitcraftmini +``` + +### 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`. + +```csharp + public ulong EntityId; + + public ResourceNodeType Type = ResourceNodeType.Iron; +``` + +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. + +```csharp + var resourceType = res?.Type ?? ResourceNodeType.Iron; + switch (resourceType) + { + case ResourceNodeType.Iron: + _animator.SetTrigger("Mine"); + Interacting = true; + break; + default: + Interacting = false; + break; + } + for (int i = 0; i < _tools.Length; i++) + { + _tools[i].SetActive(((int)resourceType) == i); + } + _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: + +```csharp + SpacetimeDBClient.instance.Subscribe(new List() + { + "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" + }); +``` + +4. Next let's add an `OnInsert` handler for the `ResourceNodeComponent`. Add the following line to the `Start` function. + +```csharp + ResourceNodeComponent.OnInsert += ResourceNodeComponent_OnInsert; +``` + +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. + +To get the position and the rotation of the node, we look up the `StaticLocationComponent` for this entity by using the EntityId. + +```csharp + private void ResourceNodeComponent_OnInsert(ResourceNodeComponent insertedValue, ReducerEvent callInfo) + { + switch(insertedValue.ResourceType) + { + case ResourceNodeType.Iron: + var iron = Instantiate(IronPrefab); + StaticLocationComponent loc = StaticLocationComponent.FilterByEntityId(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; + } + } +``` + +### Step 4: Play the Game! + +6. Hit Play in the Unity Editor and you should now see your resource nodes spawning in the world! diff --git a/docs/docs/Unity Tutorial/Part 3 - BitCraft Mini.md b/docs/docs/Unity Tutorial/Part 3 - BitCraft Mini.md new file mode 100644 index 00000000000..e1f5e3eb616 --- /dev/null +++ b/docs/docs/Unity Tutorial/Part 3 - BitCraft Mini.md @@ -0,0 +1,102 @@ +# Part 3 - BitCraft Mini + +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/DeploymentOverview.md) + +## 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/docs/docs/Unity Tutorial/_category.json b/docs/docs/Unity Tutorial/_category.json new file mode 100644 index 00000000000..95b84e96a17 --- /dev/null +++ b/docs/docs/Unity Tutorial/_category.json @@ -0,0 +1 @@ +{"title":"Unity Tutorial","disabled":false,"index":"index.md"} \ No newline at end of file diff --git a/docs/docs/Unity Tutorial/index.md b/docs/docs/Unity Tutorial/index.md new file mode 100644 index 00000000000..92f1a04c130 --- /dev/null +++ b/docs/docs/Unity Tutorial/index.md @@ -0,0 +1,917 @@ +# Part 1 - Basic Multiplayer + +![UnityTutorial-HeroImage](/images/unity-tutorial/UnityTutorial-HeroImage.JPG) + +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. + +## 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. + +### 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**. + +![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. + +Ensure that you have selected the **3D (URP)** template for this project. + +![UnityHub-3DURP](/images/unity-tutorial/UnityHub-3DURP.JPG) + +3. 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/com.clockworklabs.spacetimedbsdk/releases/latest](https://github.com/clockworklabs/com.clockworklabs.spacetimedbsdk/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. + +![Unity-ImportCustomPackage2](/images/unity-tutorial/Unity-ImportCustomPackage2.JPG) + +### Step 4: Running the Project + +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. + +![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) + +3. Enter any name and click "Continue" + +4. You should see a character loaded in the scene, and you can use the keyboard or mouse controls to move the character around. + +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. + +## 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 +``` + +3. Make sure your CLI is pointed to your local instance of SpacetimeDB. You can do this by running the following command: + +```bash +spacetime server set http://localhost:3000 +``` + +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: + +```bash +cd path/to/tutorial_project_folder +``` + +5. 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. + +### 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. + +--- + +**Understanding ECS** + +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. + +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. + +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. + +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. + +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. + +```rust +use spacetimedb::{spacetimedb, Identity, SpacetimeType, Timestamp, ReducerContext}; +use log; + +#[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. + +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. + +```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. + + #[primarykey] + #[autoinc] + pub entity_id: u64, +} +``` + +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. + +```rust +#[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. + + #[primarykey] + pub entity_id: u64, + #[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. + +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. + +```rust +#[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 + + 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. + 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. + + 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 { + entity_id, + 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."); + + log::info!("Player created: {}({})", username, entity_id); + + Ok(()) +} +``` + +SpacetimeDB also gives you the ability to define custom reducers that automatically trigger when certain events occur. + +- `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`. +- `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)`. + +```rust +#[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."); +} +``` + +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. + +```rust +#[spacetimedb(connect)] +pub fn identity_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) { + // Called when the client disconnects, we update the logged_in state to false + update_player_login_state(ctx, false); +} + + +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); + } +} +``` + +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. + +--- + +**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. + +--- + +```rust +#[spacetimedb(reducer)] +pub fn move_player( + ctx: ReducerContext, + start: StdbVector2, + direction: StdbVector2, +) -> 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); + + + return Ok(()); + } + } + + + // If we can not find the PlayerComponent for this user something went wrong. + // This should never happen. + 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); + + + return Ok(()); + } + } + + + return Err("Player not found".to_string()); +} +``` + +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. + +```bash +cd Server + +spacetime publish -c yourname-bitcraftmini +``` + +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 + +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 +``` + +### Step 2: Connect to the 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. + +![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: + +`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`. + +```csharp +using SpacetimeDB; +using SpacetimeDB.Types; +``` + +3. Inside the class definition add the following members: + +```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; + + // 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. + +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. + +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. + +--- + +**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 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. + +--- + +```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."); + + 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 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) => { + 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, sslEnabled); +``` + +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. + +```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; +} +``` + +### Step 3: Adding the Multiplayer Functionality + +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: + +```csharp + LocalPlayer.instance.username = _usernameField.text; + BitcraftMiniGameManager.instance.StartGame(); +``` + +with: + +```csharp + // 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. + +```csharp + 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); + 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); + + // register for a callback that is called when the client gets an + // update for a row in the MobileLocationComponent table + MobileLocationComponent.OnUpdate += MobileLocationComponent_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) + +```csharp + private void MobileLocationComponent_OnUpdate(MobileLocationComponent oldObj, MobileLocationComponent obj, ReducerEvent callInfo) + { + // 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); + } + } + } +``` + +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`. + +```csharp + 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. + +```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) + { + // 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; + } + } +``` + +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 + +```csharp + private Vector3? lastUpdateDirection; + + private void FixedUpdate() + { + var directionVec = GetDirectionVec(); + PlayerMovementController.Local.DirectionVec = directionVec; + + // 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) + { + Reducer.StopPlayer(new StdbVector2() { X = ourPos.x, Z = ourPos.z }); + lastUpdateDirection = null; + } + } +``` + +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. + +![GameManager-Inspector2](/images/unity-tutorial/GameManager-Inspector2.JPG) + +### Step 4: Play the Game! + +1. 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 + +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: + +```csharp + 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. + +```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) + { + // 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 + { + // if the remote player is logged in, spawn it + if (obj.LoggedIn) + { + // spawn the player object and attach the RemotePlayer component + var remotePlayer = Instantiate(PlayerPrefab); + 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 + { + var remotePlayer = FindObjectsOfType().FirstOrDefault(item => item.EntityId == obj.EntityId); + if (remotePlayer != null) + { + Destroy(remotePlayer.gameObject); + } + } + } + } +``` + +3. Now you when you play the game you should see remote players disappear when they log out. + +### Step 6: 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. + +```rust +#[spacetimedb(table)] +pub struct ChatMessage { + // The primary key for this table will be auto-incremented + #[primarykey] + #[autoinc] + pub chat_entity_id: u64, + + // The entity id of the player (or NPC) that sent the message + pub source_entity_id: u64, + // Message contents + pub chat_text: String, + // Timestamp of when the message was sent + pub timestamp: Timestamp, +} +``` + +2. Now we need to add a reducer to handle inserting new chat messages. Add the following code to lib.rs. + +```rust +#[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) { + // 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, + }) + .unwrap(); + + return Ok(()); + } + + Err("Player not found".into()) +} +``` + +3. Before updating the client, let's generate the client files and publish our module. + +```bash +spacetime generate --out-dir ../Client/Assets/module_bindings --lang=csharp + +spacetime publish -c yourname-bitcraftmini +``` + +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`. + +```csharp +public void OnChatButtonPress() +{ + Reducer.ChatMessage(_chatInput.text); + _chatInput.text = ""; +} +``` + +5. Next let's add the `ChatMessage` table to our list of subscriptions. + +```csharp + SpacetimeDBClient.instance.Subscribe(new List() + { + "SELECT * FROM Config", + "SELECT * FROM SpawnableEntityComponent", + "SELECT * FROM PlayerComponent", + "SELECT * FROM MobileLocationComponent", + "SELECT * FROM ChatMessage", + }); +``` + +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: + +```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) + { + var player = PlayerComponent.FilterByOwnerId(dbEvent.Identity); + if (player != null) + { + 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. + +## 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 +BitcraftMiniGameManager.Start () (at Assets/_Project/Game/BitcraftMiniGameManager.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/docs/WebSocket API Reference/_category.json b/docs/docs/WebSocket API Reference/_category.json new file mode 100644 index 00000000000..d27973062d0 --- /dev/null +++ b/docs/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/docs/WebSocket API Reference/index.md b/docs/docs/WebSocket API Reference/index.md new file mode 100644 index 00000000000..dd8fbc39a6a --- /dev/null +++ b/docs/docs/WebSocket API Reference/index.md @@ -0,0 +1,322 @@ +# 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. + +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-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 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. + +## 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/satn-reference/satn-reference-binary-format). + +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). + +## 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-reference) 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-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. + +##### 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_pk = 2; + 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_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 + +```typescript +// SubscriptionUpdate: +{ + "SubscriptionUpdate": { + "table_updates": array + } +} + +// TableUpdate: +{ + "table_id": number, + "table_name": string, + "table_row_operations": array +} + +// TableRowOperation: +{ + "op": "insert" | "delete", + "row_pk": string, + "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_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` + +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/docs/package.json b/docs/package.json new file mode 100644 index 00000000000..34621ce0657 --- /dev/null +++ b/docs/package.json @@ -0,0 +1,25 @@ +{ + "name": "spacetime-docs", + "version": "1.0.0", + "description": "", + "main": "dist/index.js", + "scripts": { + "build": "npx tsc", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "bin": { + "spacetime-docs": "./dist/index.js" + }, + "author": "", + "license": "ISC", + "dependencies": { + "clear": "^0.1.0", + "commander": "^11.0.0", + "figlet": "^1.6.0", + "fs-extra": "^11.1.1" + }, + "devDependencies": { + "@types/node": "^20.6.2", + "typescript": "^5.2.2" + } +} diff --git a/docs/spacetime-docs.json b/docs/spacetime-docs.json new file mode 100644 index 00000000000..b65be52d790 --- /dev/null +++ b/docs/spacetime-docs.json @@ -0,0 +1,17 @@ +{ + "docPath": "./docs", + "order": [ + "Overview", + "Getting Started", + "Cloud Testnet", + "Unity Tutorial", + "Server Module Languages", + "Client SDK Languages", + "Module ABI Reference", + "HTTP API Reference", + "WebScoket API Reference", + "SATN Reference", + "SQL Reference" + ], + "editURLRoot": "https://github.com/clockworklabs/spacetime-docs" +} \ No newline at end of file diff --git a/docs/src/index.ts b/docs/src/index.ts new file mode 100644 index 00000000000..303351751d2 --- /dev/null +++ b/docs/src/index.ts @@ -0,0 +1,200 @@ +#! /usr/bin/env node + +import { DocConfig, DocSectionConfig } from "./types"; + +const { Command } = require("commander"); +const clear = require("clear"); +const figlet = require("figlet"); +const path = require("path"); +const fs = require("fs"); +const fsExtra = require("fs-extra"); + +const cwd = process.cwd(); +const DOCS_PATH = path.join(__dirname, "docs"); +const CONFIG_PATH = path.join(cwd, "spacetime-docs.json"); + +let config = { + docPath: "", + order: [], + editURLRoot: "", +}; + +if (fs.existsSync(CONFIG_PATH)) { + const configOpts = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")); + config.docPath = configOpts.docPath || path.join(cwd, "docs"); + config.order = configOpts.order || []; + config.editURLRoot = configOpts.editURLRoot || ""; +} else { + config.docPath = path.join(cwd, "docs"); + config.order = []; + config.editURLRoot = ""; +} + +clear(); + +console.log(figlet.textSync("spacetime-docs", { horizontalLayout: "full" })); + +const program = new Command(); + +program.version("1.0.0").description("Spacetime Docs CLI"); + +program.command("generate").action(() => { + let unorderedSections: DocSectionConfig[] = []; + + const dirs = fs + .readdirSync(config.docPath, { withFileTypes: true }) + .filter((dirent: any) => dirent.isDirectory()) + .map((dirent: any) => dirent.name); + + for (const dir of dirs) { + const section: DocSectionConfig = { + title: dir, + identifier: dir.toLowerCase().replace(" ", "-"), + comingSoon: false, + hasPages: false, + editUrl: `/${dir}`, + jumpLinks: [], + pages: [], + }; + + // Check for subdirectories (pages) + const subDirs = fs + .readdirSync(path.join(config.docPath, dir), { withFileTypes: true }) + .filter((dirent: any) => dirent.isDirectory()) + .map((dirent: any) => dirent.name); + + if (subDirs.length > 0) { + section.hasPages = true; + for (const subDir of subDirs) { + const page: DocSectionConfig = { + title: subDir, + identifier: subDir.toLowerCase(), + comingSoon: false, + hasPages: false, + editUrl: `/${dir}/${subDir}`, + jumpLinks: [], + }; + if (section.pages) { + section.pages.push(page); + } else { + section.pages = [page]; + } + } + } + + unorderedSections.push(section); + } + + let sections = config.order + .map((orderTitle) => { + return unorderedSections.find((section) => section.title === orderTitle); + }) + .filter(Boolean); + + if (sections.length === 0 || sections === undefined) { + sections = []; + } + + const docConfig: DocConfig = { + //@ts-ignore + sections: sections, + rootEditURL: config.editURLRoot, // replace with your actual root edit URL + }; + + const configContent = `export const docConfig = ${JSON.stringify( + docConfig, + null, + 2 + )};`; + fs.writeFileSync(path.join(cwd, "docs-config.ts"), configContent); + console.log("docs-config.ts generated successfully!"); +}); + +program + .command("page") + .argument("", "The route to create the page in") + .argument("", "The name of the page") + .action((route: string, pageName: string) => { + const routePath = path.join(config.docPath, route); + const pagePath = path.join(routePath, `${pageName}.md`); + + if (!fs.existsSync(routePath)) { + console.log(`Route ${route} does not exist.`); + return; + } + + if (fs.existsSync(pagePath)) { + console.log(`Page ${pageName} already exists in route ${route}.`); + return; + } + + fs.writeFileSync(pagePath, `# ${pageName}`); + console.log(`Page ${pageName} created successfully in route ${route}.`); + }); + +program + .command("remove-route") + .argument("", "The route to remove") + .action((route: string) => { + const routePath = path.join(config.docPath, route); + + if (fs.existsSync(routePath)) { + fsExtra.removeSync(routePath); + console.log(`Successfully removed route: ${route}`); + } else { + console.log(`Route ${route} does not exist.`); + } + }); + +program + .command("create-route") + .argument("", "The route to create") + .option( + "-p, --parent ", + "Parent route under which to create the subroute" + ) + .action((routeName: string, options: any) => { + let routePath = path.join(config.docPath, routeName); + + // Check for parent option + if (options.parent) { + routePath = path.join(config.docPath, options.parent, routeName); + } + + const titleName = routeName.charAt(0).toUpperCase() + routeName.slice(1); + + // Check if ./docs exists, if not create it + if (!fs.existsSync(config.docPath)) { + fs.mkdirSync(config.docPath); + } + + // Create the route folder + if (!fs.existsSync(routePath)) { + fs.mkdirSync(routePath, { recursive: true }); // Ensure parent directories are created + } + + // Create the index.md file inside the route folder + const indexPath = path.join(routePath, "index.md"); + const categoryPath = path.join(routePath, "_category.json"); + + if (!fs.existsSync(categoryPath)) { + fs.writeFileSync( + categoryPath, + JSON.stringify({ + title: titleName, + disabled: false, + index: "index.md", + }) + ); + } else { + console.log(`_category.json already exists in ${titleName}`); + } + + if (!fs.existsSync(indexPath)) { + fs.writeFileSync(indexPath, "# Welcome to " + titleName); + } else { + console.log(`index.md already exists in ${titleName}`); + } + }); + +program.parse(process.argv); diff --git a/docs/src/types.ts b/docs/src/types.ts new file mode 100644 index 00000000000..e3cd091c731 --- /dev/null +++ b/docs/src/types.ts @@ -0,0 +1,23 @@ +export type DocConfig = { + sections: DocSectionConfig[]; + rootEditURL: string; +}; + +export type DocSectionConfig = { + title: string; + identifier: string; + indexIdentifier?: string; + comingSoon: boolean; + expiremental?: boolean; + hasPages: boolean; + editUrl: string; + nextKey?: JumpLink; + previousKey?: JumpLink; + pages?: DocSectionConfig[]; + jumpLinks: JumpLink[]; +}; + +export type JumpLink = { + title: string; + route: string; +}; diff --git a/docs/tsconfig.json b/docs/tsconfig.json new file mode 100644 index 00000000000..e3320274a30 --- /dev/null +++ b/docs/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "strict": true, + "target": "es6", + "module": "commonjs", + "sourceMap": true, + "esModuleInterop": true, + "moduleResolution": "node" + }, + "exclude": ["node_modules", "./docs-config.ts"] +} diff --git a/docs/yarn.lock b/docs/yarn.lock new file mode 100644 index 00000000000..0c75aadff05 --- /dev/null +++ b/docs/yarn.lock @@ -0,0 +1,56 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@types/node@^20.6.2": + version "20.6.2" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.6.2.tgz#a065925409f59657022e9063275cd0b9bd7e1b12" + integrity sha512-Y+/1vGBHV/cYk6OI1Na/LHzwnlNCAfU3ZNGrc1LdRe/LAIbdDPTTv/HU3M7yXN448aTVDq3eKRm2cg7iKLb8gw== + +clear@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/clear/-/clear-0.1.0.tgz#b81b1e03437a716984fd7ac97c87d73bdfe7048a" + integrity sha512-qMjRnoL+JDPJHeLePZJuao6+8orzHMGP04A8CdwCNsKhRbOnKRjefxONR7bwILT3MHecxKBjHkKL/tkZ8r4Uzw== + +commander@^11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-11.0.0.tgz#43e19c25dbedc8256203538e8d7e9346877a6f67" + integrity sha512-9HMlXtt/BNoYr8ooyjjNRdIilOTkVJXB+GhxMTtOKwk0R4j4lS4NpjuqmRxroBfnfTSHQIHQB7wryHhXarNjmQ== + +figlet@^1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/figlet/-/figlet-1.6.0.tgz#812050fa9f01043b4d44ddeb11f20fb268fa4b93" + integrity sha512-31EQGhCEITv6+hi2ORRPyn3bulaV9Fl4xOdR169cBzH/n1UqcxsiSB/noo6SJdD7Kfb1Ljit+IgR1USvF/XbdA== + +fs-extra@^11.1.1: + version "11.1.1" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.1.1.tgz#da69f7c39f3b002378b0954bb6ae7efdc0876e2d" + integrity sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + +graceful-fs@^4.1.6, graceful-fs@^4.2.0: + version "4.2.11" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" + integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== + +jsonfile@^6.0.1: + version "6.1.0" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" + integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== + dependencies: + universalify "^2.0.0" + optionalDependencies: + graceful-fs "^4.1.6" + +typescript@^5.2.2: + version "5.2.2" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.2.2.tgz#5ebb5e5a5b75f085f22bc3f8460fba308310fa78" + integrity sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w== + +universalify@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" + integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== From b6a5510e801c4c14ab9b755a1b27fa51e969dd41 Mon Sep 17 00:00:00 2001 From: Nathaniel Richards Date: Mon, 18 Sep 2023 13:12:07 -0400 Subject: [PATCH 002/195] Docs config generating well --- docs/docs-config.ts | 145 -- docs/docs/docs-config.ts | 3498 ++++++++++++++++++++++++++++++++++++++ docs/spacetime-docs.json | 2 +- docs/src/index.ts | 154 +- docs/src/types.ts | 3 +- docs/tsconfig.json | 5 +- 6 files changed, 3600 insertions(+), 207 deletions(-) delete mode 100644 docs/docs-config.ts create mode 100644 docs/docs/docs-config.ts diff --git a/docs/docs-config.ts b/docs/docs-config.ts deleted file mode 100644 index 92c23a1af75..00000000000 --- a/docs/docs-config.ts +++ /dev/null @@ -1,145 +0,0 @@ -export const docConfig = { - "sections": [ - { - "title": "Overview", - "identifier": "overview", - "comingSoon": false, - "hasPages": false, - "editUrl": "/Overview", - "jumpLinks": [], - "pages": [] - }, - { - "title": "Getting Started", - "identifier": "getting-started", - "comingSoon": false, - "hasPages": false, - "editUrl": "/Getting Started", - "jumpLinks": [], - "pages": [] - }, - { - "title": "Cloud Testnet", - "identifier": "cloud-testnet", - "comingSoon": false, - "hasPages": false, - "editUrl": "/Cloud Testnet", - "jumpLinks": [], - "pages": [] - }, - { - "title": "Unity Tutorial", - "identifier": "unity-tutorial", - "comingSoon": false, - "hasPages": false, - "editUrl": "/Unity Tutorial", - "jumpLinks": [], - "pages": [] - }, - { - "title": "Server Module Languages", - "identifier": "server-module languages", - "comingSoon": false, - "hasPages": true, - "editUrl": "/Server Module Languages", - "jumpLinks": [], - "pages": [ - { - "title": "C#", - "identifier": "c#", - "comingSoon": false, - "hasPages": false, - "editUrl": "/Server Module Languages/C#", - "jumpLinks": [] - }, - { - "title": "Rust", - "identifier": "rust", - "comingSoon": false, - "hasPages": false, - "editUrl": "/Server Module Languages/Rust", - "jumpLinks": [] - } - ] - }, - { - "title": "Client SDK Languages", - "identifier": "client-sdk languages", - "comingSoon": false, - "hasPages": true, - "editUrl": "/Client SDK Languages", - "jumpLinks": [], - "pages": [ - { - "title": "C#", - "identifier": "c#", - "comingSoon": false, - "hasPages": false, - "editUrl": "/Client SDK Languages/C#", - "jumpLinks": [] - }, - { - "title": "Python", - "identifier": "python", - "comingSoon": false, - "hasPages": false, - "editUrl": "/Client SDK Languages/Python", - "jumpLinks": [] - }, - { - "title": "Rust", - "identifier": "rust", - "comingSoon": false, - "hasPages": false, - "editUrl": "/Client SDK Languages/Rust", - "jumpLinks": [] - }, - { - "title": "Typescript", - "identifier": "typescript", - "comingSoon": false, - "hasPages": false, - "editUrl": "/Client SDK Languages/Typescript", - "jumpLinks": [] - } - ] - }, - { - "title": "Module ABI Reference", - "identifier": "module-abi reference", - "comingSoon": false, - "hasPages": false, - "editUrl": "/Module ABI Reference", - "jumpLinks": [], - "pages": [] - }, - { - "title": "HTTP API Reference", - "identifier": "http-api reference", - "comingSoon": false, - "hasPages": false, - "editUrl": "/HTTP API Reference", - "jumpLinks": [], - "pages": [] - }, - { - "title": "SATN Reference", - "identifier": "satn-reference", - "comingSoon": false, - "hasPages": false, - "editUrl": "/SATN Reference", - "jumpLinks": [], - "pages": [] - }, - { - "title": "SQL Reference", - "identifier": "sql-reference", - "comingSoon": false, - "hasPages": false, - "editUrl": "/SQL Reference", - "jumpLinks": [], - "pages": [] - } - ], - "rootEditURL": "https://github.com/clockworklabs/spacetime-docs" -}; \ No newline at end of file diff --git a/docs/docs/docs-config.ts b/docs/docs/docs-config.ts new file mode 100644 index 00000000000..b971de62877 --- /dev/null +++ b/docs/docs/docs-config.ts @@ -0,0 +1,3498 @@ +export const docsConfig = { + "sections": [ + { + "title": "Overview", + "identifier": "Overview", + "indexIdentifier": "index", + "comingSoon": false, + "hasPages": true, + "editUrl": "Overview/index.md", + "jumpLinks": [], + "pages": [ + { + "title": "SpacetimeDB Documentation", + "identifier": "index", + "indexIdentifier": "index", + "hasPages": false, + "editUrl": "index.md", + "jumpLinks": [ + { + "title": "SpacetimeDB Documentation", + "route": "spacetimedb-documentation", + "depth": 1 + }, + { + "title": "Installation", + "route": "installation", + "depth": 2 + }, + { + "title": "What is SpacetimeDB?", + "route": "what-is-spacetimedb-", + "depth": 2 + }, + { + "title": "State Synchronization", + "route": "state-synchronization", + "depth": 2 + }, + { + "title": "Identities", + "route": "identities", + "depth": 2 + }, + { + "title": "Language Support", + "route": "language-support", + "depth": 2 + }, + { + "title": "Server-side Libraries", + "route": "server-side-libraries", + "depth": 3 + }, + { + "title": "Client-side SDKs", + "route": "client-side-sdks", + "depth": 3 + }, + { + "title": "Unity", + "route": "unity", + "depth": 3 + }, + { + "title": "FAQ", + "route": "faq", + "depth": 2 + } + ], + "pages": [] + } + ] + }, + { + "title": "Getting Started", + "identifier": "Getting Started", + "indexIdentifier": "index", + "comingSoon": false, + "hasPages": true, + "editUrl": "Getting%20Started/index.md", + "jumpLinks": [], + "pages": [ + { + "title": "Getting Started", + "identifier": "index", + "indexIdentifier": "index", + "hasPages": false, + "editUrl": "index.md", + "jumpLinks": [ + { + "title": "Getting Started", + "route": "getting-started", + "depth": 1 + }, + { + "title": "What's Next?", + "route": "what-s-next-", + "depth": 2 + } + ], + "pages": [] + } + ] + }, + { + "title": "Cloud Testnet", + "identifier": "Cloud Testnet", + "indexIdentifier": "index", + "comingSoon": false, + "hasPages": true, + "editUrl": "Cloud%20Testnet/index.md", + "jumpLinks": [], + "pages": [ + { + "title": "SpacetimeDB Cloud Deployment", + "identifier": "index", + "indexIdentifier": "index", + "hasPages": false, + "editUrl": "index.md", + "jumpLinks": [ + { + "title": "SpacetimeDB Cloud Deployment", + "route": "spacetimedb-cloud-deployment", + "depth": 1 + }, + { + "title": "Deploy via CLI", + "route": "deploy-via-cli", + "depth": 2 + }, + { + "title": "Connecting your Identity to the Web Dashboard", + "route": "connecting-your-identity-to-the-web-dashboard", + "depth": 2 + } + ], + "pages": [] + } + ] + }, + { + "title": "Unity Tutorial", + "identifier": "Unity Tutorial", + "indexIdentifier": "index", + "comingSoon": false, + "hasPages": true, + "editUrl": "Unity%20Tutorial/index.md", + "jumpLinks": [], + "pages": [ + { + "title": "Part 1 - Basic Multiplayer", + "identifier": "index", + "indexIdentifier": "index", + "hasPages": false, + "editUrl": "index.md", + "jumpLinks": [ + { + "title": "Part 1 - Basic Multiplayer", + "route": "part-1-basic-multiplayer", + "depth": 1 + }, + { + "title": "Setting up the Tutorial Unity Project", + "route": "setting-up-the-tutorial-unity-project", + "depth": 2 + }, + { + "title": "Step 1: Create a Blank Unity Project", + "route": "step-1-create-a-blank-unity-project", + "depth": 3 + }, + { + "title": "Step 2: Adding Required Packages", + "route": "step-2-adding-required-packages", + "depth": 3 + }, + { + "title": "Step 3: Importing the Tutorial Package", + "route": "step-3-importing-the-tutorial-package", + "depth": 3 + }, + { + "title": "Step 4: Running the Project", + "route": "step-4-running-the-project", + "depth": 3 + }, + { + "title": "Writing our SpacetimeDB Server Module", + "route": "writing-our-spacetimedb-server-module", + "depth": 2 + }, + { + "title": "Step 1: Create the Module", + "route": "step-1-create-the-module", + "depth": 3 + }, + { + "title": "Step 2: SpacetimeDB Tables", + "route": "step-2-spacetimedb-tables", + "depth": 3 + }, + { + "title": "Updating our Unity Project to use SpacetimeDB", + "route": "updating-our-unity-project-to-use-spacetimedb", + "depth": 2 + }, + { + "title": "Step 1: Import the SDK and Generate Module Files", + "route": "step-1-import-the-sdk-and-generate-module-files", + "depth": 3 + }, + { + "title": "Step 2: Connect to the SpacetimeDB Module", + "route": "step-2-connect-to-the-spacetimedb-module", + "depth": 3 + }, + { + "title": "Step 3: Adding the Multiplayer Functionality", + "route": "step-3-adding-the-multiplayer-functionality", + "depth": 3 + }, + { + "title": "Step 4: Play the Game!", + "route": "step-4-play-the-game-", + "depth": 3 + }, + { + "title": "Step 5: Implement Player Logout", + "route": "step-5-implement-player-logout", + "depth": 3 + }, + { + "title": "Step 6: Add Chat Support", + "route": "step-6-add-chat-support", + "depth": 3 + }, + { + "title": "Conclusion", + "route": "conclusion", + "depth": 2 + }, + { + "title": "Troubleshooting", + "route": "troubleshooting", + "depth": 3 + } + ], + "pages": [] + }, + { + "title": "Part 2 - Resources and Scheduling", + "identifier": "Part 2 - Resources And Scheduling", + "indexIdentifier": "Part 2 - Resources And Scheduling", + "hasPages": false, + "editUrl": "Part%202%20-%20Resources%20And%20Scheduling.md", + "jumpLinks": [ + { + "title": "Part 2 - Resources and Scheduling", + "route": "part-2-resources-and-scheduling", + "depth": 1 + }, + { + "title": "Add Resource Node Spawner", + "route": "add-resource-node-spawner", + "depth": 2 + }, + { + "title": "Step 1: Add the SpacetimeDB Tables for Resource Nodes", + "route": "step-1-add-the-spacetimedb-tables-for-resource-nodes", + "depth": 3 + }, + { + "title": "Step 2: Write our Resource Spawner Repeating Reducer", + "route": "step-2-write-our-resource-spawner-repeating-reducer", + "depth": 3 + }, + { + "title": "Step 3: Spawn the Resource Nodes on the Client", + "route": "step-3-spawn-the-resource-nodes-on-the-client", + "depth": 3 + }, + { + "title": "Step 4: Play the Game!", + "route": "step-4-play-the-game-", + "depth": 3 + } + ], + "pages": [] + }, + { + "title": "Part 3 - BitCraft Mini", + "identifier": "Part 3 - BitCraft Mini", + "indexIdentifier": "Part 3 - BitCraft Mini", + "hasPages": false, + "editUrl": "Part%203%20-%20BitCraft%20Mini.md", + "jumpLinks": [ + { + "title": "Part 3 - BitCraft Mini", + "route": "part-3-bitcraft-mini", + "depth": 1 + }, + { + "title": "1. Download", + "route": "1-download", + "depth": 2 + }, + { + "title": "2. Compile the Spacetime Module", + "route": "2-compile-the-spacetime-module", + "depth": 2 + }, + { + "title": "3. Replace address in BitCraftMiniGameManager", + "route": "3-replace-address-in-bitcraftminigamemanager", + "depth": 2 + }, + { + "title": "4. Play Mode", + "route": "4-play-mode", + "depth": 2 + }, + { + "title": "5. Editing the Module", + "route": "5-editing-the-module", + "depth": 2 + } + ], + "pages": [] + } + ] + }, + { + "title": "Server Module Languages", + "identifier": "Server Module Languages", + "indexIdentifier": "index", + "comingSoon": false, + "hasPages": true, + "editUrl": "Server%20Module%20Languages/index.md", + "jumpLinks": [], + "pages": [ + { + "title": "C#", + "identifier": "C#", + "indexIdentifier": "index", + "comingSoon": false, + "tag": "Expiremental", + "hasPages": true, + "editUrl": "C%23/index.md", + "jumpLinks": [], + "pages": [ + { + "title": "C# Module Quickstart", + "identifier": "index", + "indexIdentifier": "index", + "hasPages": false, + "editUrl": "index.md", + "jumpLinks": [ + { + "title": "C# Module Quickstart", + "route": "c-module-quickstart", + "depth": 1 + }, + { + "title": "Install SpacetimeDB", + "route": "install-spacetimedb", + "depth": 2 + }, + { + "title": "Install .NET", + "route": "install-net", + "depth": 2 + }, + { + "title": "Project structure", + "route": "project-structure", + "depth": 2 + }, + { + "title": "Declare imports", + "route": "declare-imports", + "depth": 2 + }, + { + "title": "Define tables", + "route": "define-tables", + "depth": 2 + }, + { + "title": "Set users' names", + "route": "set-users-names", + "depth": 2 + }, + { + "title": "Send messages", + "route": "send-messages", + "depth": 2 + }, + { + "title": "Set users' online status", + "route": "set-users-online-status", + "depth": 2 + }, + { + "title": "Publish the module", + "route": "publish-the-module", + "depth": 2 + }, + { + "title": "Call Reducers", + "route": "call-reducers", + "depth": 2 + }, + { + "title": "SQL Queries", + "route": "sql-queries", + "depth": 2 + }, + { + "title": "What's next?", + "route": "what-s-next-", + "depth": 2 + } + ], + "pages": [] + }, + { + "title": "SpacetimeDB C# Modules", + "identifier": "ModuleReference", + "indexIdentifier": "ModuleReference", + "hasPages": false, + "editUrl": "ModuleReference.md", + "jumpLinks": [ + { + "title": "SpacetimeDB C# Modules", + "route": "spacetimedb-c-modules", + "depth": 1 + }, + { + "title": "Example", + "route": "example", + "depth": 2 + }, + { + "title": "API reference", + "route": "api-reference", + "depth": 2 + }, + { + "title": "Logging", + "route": "logging", + "depth": 3 + }, + { + "title": "Supported types", + "route": "supported-types", + "depth": 3 + }, + { + "title": "Built-in types", + "route": "built-in-types", + "depth": 4 + }, + { + "title": "Custom types", + "route": "custom-types", + "depth": 4 + }, + { + "title": "Tagged enums", + "route": "tagged-enums", + "depth": 4 + }, + { + "title": "Tables", + "route": "tables", + "depth": 3 + }, + { + "title": "Column attributes", + "route": "column-attributes", + "depth": 4 + }, + { + "title": "Reducers", + "route": "reducers", + "depth": 3 + }, + { + "title": "Special reducers", + "route": "special-reducers", + "depth": 4 + }, + { + "title": "Connection events", + "route": "connection-events", + "depth": 3 + } + ], + "pages": [] + } + ] + }, + { + "title": "Server Module Overview", + "identifier": "index", + "indexIdentifier": "index", + "hasPages": false, + "editUrl": "index.md", + "jumpLinks": [ + { + "title": "Server Module Overview", + "route": "server-module-overview", + "depth": 1 + }, + { + "title": "Supported Languages", + "route": "supported-languages", + "depth": 2 + }, + { + "title": "Rust", + "route": "rust", + "depth": 3 + }, + { + "title": "C#", + "route": "c-", + "depth": 3 + }, + { + "title": "Coming Soon", + "route": "coming-soon", + "depth": 3 + } + ], + "pages": [] + }, + { + "title": "Rust", + "identifier": "Rust", + "indexIdentifier": "index", + "comingSoon": false, + "hasPages": true, + "editUrl": "Rust/index.md", + "jumpLinks": [], + "pages": [ + { + "title": "Rust Module Quickstart", + "identifier": "index", + "indexIdentifier": "index", + "hasPages": false, + "editUrl": "index.md", + "jumpLinks": [ + { + "title": "Rust Module Quickstart", + "route": "rust-module-quickstart", + "depth": 1 + }, + { + "title": "Install SpacetimeDB", + "route": "install-spacetimedb", + "depth": 2 + }, + { + "title": "Install Rust", + "route": "install-rust", + "depth": 2 + }, + { + "title": "Project structure", + "route": "project-structure", + "depth": 2 + }, + { + "title": "Declare imports", + "route": "declare-imports", + "depth": 2 + }, + { + "title": "Define tables", + "route": "define-tables", + "depth": 2 + }, + { + "title": "Set users' names", + "route": "set-users-names", + "depth": 2 + }, + { + "title": "Send messages", + "route": "send-messages", + "depth": 2 + }, + { + "title": "Set users' online status", + "route": "set-users-online-status", + "depth": 2 + }, + { + "title": "Publish the module", + "route": "publish-the-module", + "depth": 2 + }, + { + "title": "Call Reducers", + "route": "call-reducers", + "depth": 2 + }, + { + "title": "SQL Queries", + "route": "sql-queries", + "depth": 2 + }, + { + "title": "What's next?", + "route": "what-s-next-", + "depth": 2 + } + ], + "pages": [] + }, + { + "title": "SpacetimeDB Rust Modules", + "identifier": "ModuleReference", + "indexIdentifier": "ModuleReference", + "hasPages": false, + "editUrl": "ModuleReference.md", + "jumpLinks": [ + { + "title": "SpacetimeDB Rust Modules", + "route": "spacetimedb-rust-modules", + "depth": 1 + }, + { + "title": "SpacetimeDB Macro basics", + "route": "spacetimedb-macro-basics", + "depth": 2 + }, + { + "title": "Macro API", + "route": "macro-api", + "depth": 2 + }, + { + "title": "Defining tables", + "route": "defining-tables", + "depth": 3 + }, + { + "title": "Defining reducers", + "route": "defining-reducers", + "depth": 3 + }, + { + "title": "Client API", + "route": "client-api", + "depth": 2 + }, + { + "title": "`println!` and friends", + "route": "-println-and-friends", + "depth": 3 + }, + { + "title": "Generated functions on a SpacetimeDB table", + "route": "generated-functions-on-a-spacetimedb-table", + "depth": 3 + }, + { + "title": "Insertion", + "route": "insertion", + "depth": 3 + }, + { + "title": "Iterating", + "route": "iterating", + "depth": 3 + }, + { + "title": "Filtering", + "route": "filtering", + "depth": 3 + }, + { + "title": "Deleting", + "route": "deleting", + "depth": 3 + } + ], + "pages": [] + } + ] + } + ] + }, + { + "title": "Client SDK Languages", + "identifier": "Client SDK Languages", + "indexIdentifier": "index", + "comingSoon": false, + "hasPages": true, + "editUrl": "Client%20SDK%20Languages/index.md", + "jumpLinks": [], + "pages": [ + { + "title": "C#", + "identifier": "C#", + "indexIdentifier": "index", + "comingSoon": false, + "hasPages": true, + "editUrl": "C%23/index.md", + "jumpLinks": [], + "pages": [ + { + "title": "C# Client SDK Quick Start", + "identifier": "index", + "indexIdentifier": "index", + "hasPages": false, + "editUrl": "index.md", + "jumpLinks": [ + { + "title": "C# Client SDK Quick Start", + "route": "c-client-sdk-quick-start", + "depth": 1 + }, + { + "title": "Project structure", + "route": "project-structure", + "depth": 2 + }, + { + "title": "Add the NuGet package for the C# SpacetimeDB SDK", + "route": "add-the-nuget-package-for-the-c-spacetimedb-sdk", + "depth": 2 + }, + { + "title": "Generate your module types", + "route": "generate-your-module-types", + "depth": 2 + }, + { + "title": "Add imports to Program.cs", + "route": "add-imports-to-program-cs", + "depth": 2 + }, + { + "title": "Define Main function", + "route": "define-main-function", + "depth": 2 + }, + { + "title": "Register callbacks", + "route": "register-callbacks", + "depth": 2 + }, + { + "title": "Notify about new users", + "route": "notify-about-new-users", + "depth": 3 + }, + { + "title": "Notify about updated users", + "route": "notify-about-updated-users", + "depth": 3 + }, + { + "title": "Print messages", + "route": "print-messages", + "depth": 3 + }, + { + "title": "Warn if our name was rejected", + "route": "warn-if-our-name-was-rejected", + "depth": 3 + }, + { + "title": "Warn if our message was rejected", + "route": "warn-if-our-message-was-rejected", + "depth": 3 + }, + { + "title": "Connect callback", + "route": "connect-callback", + "depth": 2 + }, + { + "title": "OnIdentityReceived callback", + "route": "onidentityreceived-callback", + "depth": 2 + }, + { + "title": "OnSubscriptionApplied callback", + "route": "onsubscriptionapplied-callback", + "depth": 2 + }, + { + "title": "Process thread", + "route": "process-thread", + "depth": 2 + }, + { + "title": "Input loop and ProcessCommands", + "route": "input-loop-and-processcommands", + "depth": 2 + }, + { + "title": "Run the client", + "route": "run-the-client", + "depth": 2 + }, + { + "title": "What's next?", + "route": "what-s-next-", + "depth": 2 + } + ], + "pages": [] + }, + { + "title": "The SpacetimeDB C# client SDK", + "identifier": "SDK Reference", + "indexIdentifier": "SDK Reference", + "hasPages": false, + "editUrl": "SDK%20Reference.md", + "jumpLinks": [ + { + "title": "The SpacetimeDB C# client SDK", + "route": "the-spacetimedb-c-client-sdk", + "depth": 1 + }, + { + "title": "Table of Contents", + "route": "table-of-contents", + "depth": 2 + }, + { + "title": "Install the SDK", + "route": "install-the-sdk", + "depth": 2 + }, + { + "title": "Using the `dotnet` CLI tool", + "route": "using-the-dotnet-cli-tool", + "depth": 3 + }, + { + "title": "Using Unity", + "route": "using-unity", + "depth": 3 + }, + { + "title": "Generate module bindings", + "route": "generate-module-bindings", + "depth": 2 + }, + { + "title": "Initialization", + "route": "initialization", + "depth": 2 + }, + { + "title": "Static Method `SpacetimeDBClient.CreateInstance`", + "route": "static-method-spacetimedbclient-createinstance-", + "depth": 3 + }, + { + "title": "Property `SpacetimeDBClient.instance`", + "route": "property-spacetimedbclient-instance-", + "depth": 3 + }, + { + "title": "Class `NetworkManager`", + "route": "class-networkmanager-", + "depth": 3 + }, + { + "title": "Method `SpacetimeDBClient.Connect`", + "route": "method-spacetimedbclient-connect-", + "depth": 3 + }, + { + "title": "Event `SpacetimeDBClient.onIdentityReceived`", + "route": "event-spacetimedbclient-onidentityreceived-", + "depth": 3 + }, + { + "title": "Event `SpacetimeDBClient.onConnect`", + "route": "event-spacetimedbclient-onconnect-", + "depth": 3 + }, + { + "title": "Subscribe to queries", + "route": "subscribe-to-queries", + "depth": 2 + }, + { + "title": "Method `SpacetimeDBClient.Subscribe`", + "route": "method-spacetimedbclient-subscribe-", + "depth": 3 + }, + { + "title": "Event `SpacetimeDBClient.onSubscriptionApplied`", + "route": "event-spacetimedbclient-onsubscriptionapplied-", + "depth": 3 + }, + { + "title": "View rows of subscribed tables", + "route": "view-rows-of-subscribed-tables", + "depth": 2 + }, + { + "title": "Class `{TABLE}`", + "route": "class-table-", + "depth": 3 + }, + { + "title": "Static Method `{TABLE}.Iter`", + "route": "static-method-table-iter-", + "depth": 4 + }, + { + "title": "Static Method `{TABLE}.FilterBy{COLUMN}`", + "route": "static-method-table-filterby-column-", + "depth": 4 + }, + { + "title": "Static Method `{TABLE}.Count`", + "route": "static-method-table-count-", + "depth": 4 + }, + { + "title": "Static Event `{TABLE}.OnInsert`", + "route": "static-event-table-oninsert-", + "depth": 4 + }, + { + "title": "Static Event `{TABLE}.OnBeforeDelete`", + "route": "static-event-table-onbeforedelete-", + "depth": 4 + }, + { + "title": "Static Event `{TABLE}.OnDelete`", + "route": "static-event-table-ondelete-", + "depth": 4 + }, + { + "title": "Static Event `{TABLE}.OnUpdate`", + "route": "static-event-table-onupdate-", + "depth": 4 + }, + { + "title": "Observe and invoke reducers", + "route": "observe-and-invoke-reducers", + "depth": 2 + }, + { + "title": "Class `Reducer`", + "route": "class-reducer-", + "depth": 3 + }, + { + "title": "Static Method `Reducer.{REDUCER}`", + "route": "static-method-reducer-reducer-", + "depth": 4 + }, + { + "title": "Static Event `Reducer.On{REDUCER}`", + "route": "static-event-reducer-on-reducer-", + "depth": 4 + }, + { + "title": "Class `ReducerEvent`", + "route": "class-reducerevent-", + "depth": 3 + }, + { + "title": "Enum `Status`", + "route": "enum-status-", + "depth": 4 + }, + { + "title": "Variant `Status.Committed`", + "route": "variant-status-committed-", + "depth": 5 + }, + { + "title": "Variant `Status.Failed`", + "route": "variant-status-failed-", + "depth": 5 + }, + { + "title": "Variant `Status.OutOfEnergy`", + "route": "variant-status-outofenergy-", + "depth": 5 + }, + { + "title": "Identity management", + "route": "identity-management", + "depth": 2 + }, + { + "title": "Class `AuthToken`", + "route": "class-authtoken-", + "depth": 3 + }, + { + "title": "Static Method `AuthToken.Init`", + "route": "static-method-authtoken-init-", + "depth": 4 + }, + { + "title": "Static Property `AuthToken.Token`", + "route": "static-property-authtoken-token-", + "depth": 4 + }, + { + "title": "Static Method `AuthToken.SaveToken`", + "route": "static-method-authtoken-savetoken-", + "depth": 4 + }, + { + "title": "Class `Identity`", + "route": "class-identity-", + "depth": 3 + }, + { + "title": "Customizing logging", + "route": "customizing-logging", + "depth": 2 + }, + { + "title": "Interface `ISpacetimeDBLogger`", + "route": "interface-ispacetimedblogger-", + "depth": 3 + }, + { + "title": "Class `ConsoleLogger`", + "route": "class-consolelogger-", + "depth": 3 + }, + { + "title": "Class `UnityDebugLogger`", + "route": "class-unitydebuglogger-", + "depth": 3 + } + ], + "pages": [] + } + ] + }, + { + "title": "Welcome to Client SDK Languages# SpacetimeDB Client SDKs Overview", + "identifier": "index", + "indexIdentifier": "index", + "hasPages": false, + "editUrl": "index.md", + "jumpLinks": [ + { + "title": "Welcome to Client SDK Languages# SpacetimeDB Client SDKs Overview", + "route": "welcome-to-client-sdk-languages-spacetimedb-client-sdks-overview", + "depth": 1 + }, + { + "title": "Key Features", + "route": "key-features", + "depth": 2 + }, + { + "title": "Connection Management", + "route": "connection-management", + "depth": 3 + }, + { + "title": "Authentication", + "route": "authentication", + "depth": 3 + }, + { + "title": "Local Database View", + "route": "local-database-view", + "depth": 3 + }, + { + "title": "Reducer Calls", + "route": "reducer-calls", + "depth": 3 + }, + { + "title": "Callback Registrations", + "route": "callback-registrations", + "depth": 3 + }, + { + "title": "Connection and Subscription Callbacks", + "route": "connection-and-subscription-callbacks", + "depth": 4 + }, + { + "title": "Row Update Callbacks", + "route": "row-update-callbacks", + "depth": 4 + }, + { + "title": "Reducer Call Callbacks", + "route": "reducer-call-callbacks", + "depth": 4 + }, + { + "title": "Choosing a Language", + "route": "choosing-a-language", + "depth": 2 + }, + { + "title": "Team Expertise", + "route": "team-expertise", + "depth": 3 + }, + { + "title": "Application Type", + "route": "application-type", + "depth": 3 + }, + { + "title": "Performance", + "route": "performance", + "depth": 3 + }, + { + "title": "Platform Support", + "route": "platform-support", + "depth": 3 + }, + { + "title": "Ecosystem and Libraries", + "route": "ecosystem-and-libraries", + "depth": 3 + } + ], + "pages": [] + }, + { + "title": "Python", + "identifier": "Python", + "indexIdentifier": "index", + "comingSoon": false, + "hasPages": true, + "editUrl": "Python/index.md", + "jumpLinks": [], + "pages": [ + { + "title": "Python Client SDK Quick Start", + "identifier": "index", + "indexIdentifier": "index", + "hasPages": false, + "editUrl": "index.md", + "jumpLinks": [ + { + "title": "Python Client SDK Quick Start", + "route": "python-client-sdk-quick-start", + "depth": 1 + }, + { + "title": "Install the SpacetimeDB SDK Python Package", + "route": "install-the-spacetimedb-sdk-python-package", + "depth": 2 + }, + { + "title": "Project structure", + "route": "project-structure", + "depth": 2 + }, + { + "title": "Create the Python main file", + "route": "create-the-python-main-file", + "depth": 2 + }, + { + "title": "Add imports", + "route": "add-imports", + "depth": 2 + }, + { + "title": "Generate your module types", + "route": "generate-your-module-types", + "depth": 2 + }, + { + "title": "Global variables", + "route": "global-variables", + "depth": 2 + }, + { + "title": "Define main function", + "route": "define-main-function", + "depth": 2 + }, + { + "title": "Register callbacks", + "route": "register-callbacks", + "depth": 2 + }, + { + "title": "Handling User row updates", + "route": "handling-user-row-updates", + "depth": 3 + }, + { + "title": "Print messages", + "route": "print-messages", + "depth": 3 + }, + { + "title": "Warn if our name was rejected", + "route": "warn-if-our-name-was-rejected", + "depth": 3 + }, + { + "title": "Warn if our message was rejected", + "route": "warn-if-our-message-was-rejected", + "depth": 3 + }, + { + "title": "OnSubscriptionApplied callback", + "route": "onsubscriptionapplied-callback", + "depth": 3 + }, + { + "title": "Check commands repeating event", + "route": "check-commands-repeating-event", + "depth": 3 + }, + { + "title": "OnConnect callback", + "route": "onconnect-callback", + "depth": 3 + }, + { + "title": "Async client thread", + "route": "async-client-thread", + "depth": 2 + }, + { + "title": "Input loop", + "route": "input-loop", + "depth": 2 + }, + { + "title": "Run the client", + "route": "run-the-client", + "depth": 2 + }, + { + "title": "Next steps", + "route": "next-steps", + "depth": 2 + } + ], + "pages": [] + }, + { + "title": "The SpacetimeDB Python client SDK", + "identifier": "SDK Reference", + "indexIdentifier": "SDK Reference", + "hasPages": false, + "editUrl": "SDK%20Reference.md", + "jumpLinks": [ + { + "title": "The SpacetimeDB Python client SDK", + "route": "the-spacetimedb-python-client-sdk", + "depth": 1 + }, + { + "title": "Install the SDK", + "route": "install-the-sdk", + "depth": 2 + }, + { + "title": "Generate module bindings", + "route": "generate-module-bindings", + "depth": 2 + }, + { + "title": "Basic vs Async SpacetimeDB Client", + "route": "basic-vs-async-spacetimedb-client", + "depth": 2 + }, + { + "title": "Common Client Reference", + "route": "common-client-reference", + "depth": 2 + }, + { + "title": "API at a glance", + "route": "api-at-a-glance", + "depth": 3 + }, + { + "title": "Type `Identity`", + "route": "type-identity-", + "depth": 3 + }, + { + "title": "Type `ReducerEvent`", + "route": "type-reducerevent-", + "depth": 3 + }, + { + "title": "Type `{TABLE}`", + "route": "type-table-", + "depth": 3 + }, + { + "title": "Method `filter_by_{COLUMN}`", + "route": "method-filter_by_-column-", + "depth": 3 + }, + { + "title": "Method `iter`", + "route": "method-iter-", + "depth": 3 + }, + { + "title": "Method `register_row_update`", + "route": "method-register_row_update-", + "depth": 3 + }, + { + "title": "Function `{REDUCER_NAME}`", + "route": "function-reducer_name-", + "depth": 3 + }, + { + "title": "Function `register_on_{REDUCER_NAME}`", + "route": "function-register_on_-reducer_name-", + "depth": 3 + }, + { + "title": "Async Client Reference", + "route": "async-client-reference", + "depth": 2 + }, + { + "title": "API at a glance", + "route": "api-at-a-glance", + "depth": 3 + }, + { + "title": "Function `run`", + "route": "function-run-", + "depth": 3 + }, + { + "title": "Function `subscribe`", + "route": "function-subscribe-", + "depth": 3 + }, + { + "title": "Function `register_on_subscription_applied`", + "route": "function-register_on_subscription_applied-", + "depth": 3 + }, + { + "title": "Function `force_close`", + "route": "function-force_close-", + "depth": 3 + }, + { + "title": "Function `schedule_event`", + "route": "function-schedule_event-", + "depth": 3 + }, + { + "title": "Basic Client Reference", + "route": "basic-client-reference", + "depth": 2 + }, + { + "title": "API at a glance", + "route": "api-at-a-glance", + "depth": 3 + }, + { + "title": "Function `init`", + "route": "function-init-", + "depth": 3 + }, + { + "title": "Function `subscribe`", + "route": "function-subscribe-", + "depth": 3 + }, + { + "title": "Function `register_on_event`", + "route": "function-register_on_event-", + "depth": 3 + }, + { + "title": "Function `unregister_on_event`", + "route": "function-unregister_on_event-", + "depth": 3 + }, + { + "title": "Function `register_on_subscription_applied`", + "route": "function-register_on_subscription_applied-", + "depth": 3 + }, + { + "title": "Function `unregister_on_subscription_applied`", + "route": "function-unregister_on_subscription_applied-", + "depth": 3 + }, + { + "title": "Function `update`", + "route": "function-update-", + "depth": 3 + }, + { + "title": "Function `close`", + "route": "function-close-", + "depth": 3 + }, + { + "title": "Type `TransactionUpdateMessage`", + "route": "type-transactionupdatemessage-", + "depth": 3 + } + ], + "pages": [] + } + ] + }, + { + "title": "Rust", + "identifier": "Rust", + "indexIdentifier": "index", + "comingSoon": false, + "hasPages": true, + "editUrl": "Rust/index.md", + "jumpLinks": [], + "pages": [ + { + "title": "Rust Client SDK Quick Start", + "identifier": "index", + "indexIdentifier": "index", + "hasPages": false, + "editUrl": "index.md", + "jumpLinks": [ + { + "title": "Rust Client SDK Quick Start", + "route": "rust-client-sdk-quick-start", + "depth": 1 + }, + { + "title": "Project structure", + "route": "project-structure", + "depth": 2 + }, + { + "title": "Depend on `spacetimedb-sdk` and `hex`", + "route": "depend-on-spacetimedb-sdk-and-hex-", + "depth": 2 + }, + { + "title": "Clear `client/src/main.rs`", + "route": "clear-client-src-main-rs-", + "depth": 2 + }, + { + "title": "Generate your module types", + "route": "generate-your-module-types", + "depth": 2 + }, + { + "title": "Add more imports", + "route": "add-more-imports", + "depth": 2 + }, + { + "title": "Define main function", + "route": "define-main-function", + "depth": 2 + }, + { + "title": "Register callbacks", + "route": "register-callbacks", + "depth": 2 + }, + { + "title": "Save credentials", + "route": "save-credentials", + "depth": 3 + }, + { + "title": "Notify about new users", + "route": "notify-about-new-users", + "depth": 3 + }, + { + "title": "Notify about updated users", + "route": "notify-about-updated-users", + "depth": 3 + }, + { + "title": "Print messages", + "route": "print-messages", + "depth": 3 + }, + { + "title": "Print past messages in order", + "route": "print-past-messages-in-order", + "depth": 3 + }, + { + "title": "Warn if our name was rejected", + "route": "warn-if-our-name-was-rejected", + "depth": 3 + }, + { + "title": "Warn if our message was rejected", + "route": "warn-if-our-message-was-rejected", + "depth": 3 + }, + { + "title": "Exit on disconnect", + "route": "exit-on-disconnect", + "depth": 3 + }, + { + "title": "Connect to the database", + "route": "connect-to-the-database", + "depth": 2 + }, + { + "title": "Subscribe to queries", + "route": "subscribe-to-queries", + "depth": 2 + }, + { + "title": "Handle user input", + "route": "handle-user-input", + "depth": 2 + }, + { + "title": "Run it", + "route": "run-it", + "depth": 2 + }, + { + "title": "What's next?", + "route": "what-s-next-", + "depth": 2 + } + ], + "pages": [] + }, + { + "title": "The SpacetimeDB Rust client SDK", + "identifier": "SDK Reference", + "indexIdentifier": "SDK Reference", + "hasPages": false, + "editUrl": "SDK%20Reference.md", + "jumpLinks": [ + { + "title": "The SpacetimeDB Rust client SDK", + "route": "the-spacetimedb-rust-client-sdk", + "depth": 1 + }, + { + "title": "Install the SDK", + "route": "install-the-sdk", + "depth": 2 + }, + { + "title": "Generate module bindings", + "route": "generate-module-bindings", + "depth": 2 + }, + { + "title": "API at a glance", + "route": "api-at-a-glance", + "depth": 2 + }, + { + "title": "Connect to a database", + "route": "connect-to-a-database", + "depth": 2 + }, + { + "title": "Function `connect`", + "route": "function-connect-", + "depth": 3 + }, + { + "title": "Function `disconnect`", + "route": "function-disconnect-", + "depth": 3 + }, + { + "title": "Function `on_disconnect`", + "route": "function-on_disconnect-", + "depth": 3 + }, + { + "title": "Function `once_on_disconnect`", + "route": "function-once_on_disconnect-", + "depth": 3 + }, + { + "title": "Function `remove_on_disconnect`", + "route": "function-remove_on_disconnect-", + "depth": 3 + }, + { + "title": "Subscribe to queries", + "route": "subscribe-to-queries", + "depth": 2 + }, + { + "title": "Function `subscribe`", + "route": "function-subscribe-", + "depth": 3 + }, + { + "title": "Function `subscribe_owned`", + "route": "function-subscribe_owned-", + "depth": 3 + }, + { + "title": "Function `on_subscription_applied`", + "route": "function-on_subscription_applied-", + "depth": 3 + }, + { + "title": "Function `once_on_subscription_applied`", + "route": "function-once_on_subscription_applied-", + "depth": 3 + }, + { + "title": "Function `remove_on_subscription_applied`", + "route": "function-remove_on_subscription_applied-", + "depth": 3 + }, + { + "title": "Identify a client", + "route": "identify-a-client", + "depth": 2 + }, + { + "title": "Type `Identity`", + "route": "type-identity-", + "depth": 3 + }, + { + "title": "Type `Token`", + "route": "type-token-", + "depth": 3 + }, + { + "title": "Type `Credentials`", + "route": "type-credentials-", + "depth": 3 + }, + { + "title": "Function `identity`", + "route": "function-identity-", + "depth": 3 + }, + { + "title": "Function `token`", + "route": "function-token-", + "depth": 3 + }, + { + "title": "Function `credentials`", + "route": "function-credentials-", + "depth": 3 + }, + { + "title": "Function `on_connect`", + "route": "function-on_connect-", + "depth": 3 + }, + { + "title": "Function `once_on_connect`", + "route": "function-once_on_connect-", + "depth": 3 + }, + { + "title": "Function `remove_on_connect`", + "route": "function-remove_on_connect-", + "depth": 3 + }, + { + "title": "Function `load_credentials`", + "route": "function-load_credentials-", + "depth": 3 + }, + { + "title": "Function `save_credentials`", + "route": "function-save_credentials-", + "depth": 3 + }, + { + "title": "View subscribed rows of tables", + "route": "view-subscribed-rows-of-tables", + "depth": 2 + }, + { + "title": "Type `{TABLE}`", + "route": "type-table-", + "depth": 3 + }, + { + "title": "Method `filter_by_{COLUMN}`", + "route": "method-filter_by_-column-", + "depth": 3 + }, + { + "title": "Trait `TableType`", + "route": "trait-tabletype-", + "depth": 3 + }, + { + "title": "Method `count`", + "route": "method-count-", + "depth": 4 + }, + { + "title": "Method `iter`", + "route": "method-iter-", + "depth": 4 + }, + { + "title": "Method `filter`", + "route": "method-filter-", + "depth": 4 + }, + { + "title": "Method `find`", + "route": "method-find-", + "depth": 4 + }, + { + "title": "Method `on_insert`", + "route": "method-on_insert-", + "depth": 4 + }, + { + "title": "Method `remove_on_insert`", + "route": "method-remove_on_insert-", + "depth": 4 + }, + { + "title": "Method `on_delete`", + "route": "method-on_delete-", + "depth": 4 + }, + { + "title": "Method `remove_on_delete`", + "route": "method-remove_on_delete-", + "depth": 4 + }, + { + "title": "Trait `TableWithPrimaryKey`", + "route": "trait-tablewithprimarykey-", + "depth": 3 + }, + { + "title": "Method `on_update`", + "route": "method-on_update-", + "depth": 4 + }, + { + "title": "Method `remove_on_update`", + "route": "method-remove_on_update-", + "depth": 4 + }, + { + "title": "Observe and request reducer invocations", + "route": "observe-and-request-reducer-invocations", + "depth": 2 + }, + { + "title": "Type `ReducerEvent`", + "route": "type-reducerevent-", + "depth": 3 + }, + { + "title": "Type `{REDUCER}Args`", + "route": "type-reducer-args-", + "depth": 3 + }, + { + "title": "Function `{REDUCER}`", + "route": "function-reducer-", + "depth": 3 + }, + { + "title": "Function `on_{REDUCER}`", + "route": "function-on_-reducer-", + "depth": 3 + }, + { + "title": "Function `once_on_{REDUCER}`", + "route": "function-once_on_-reducer-", + "depth": 3 + }, + { + "title": "Function `remove_on_{REDUCER}`", + "route": "function-remove_on_-reducer-", + "depth": 3 + }, + { + "title": "Type `Status`", + "route": "type-status-", + "depth": 3 + }, + { + "title": "Variant `Status::Committed`", + "route": "variant-status-committed-", + "depth": 4 + }, + { + "title": "Variant `Status::Failed(String)`", + "route": "variant-status-failed-string-", + "depth": 4 + }, + { + "title": "Variant `Status::OutOfEnergy`", + "route": "variant-status-outofenergy-", + "depth": 4 + } + ], + "pages": [] + } + ] + }, + { + "title": "Typescript", + "identifier": "Typescript", + "indexIdentifier": "index", + "comingSoon": false, + "hasPages": true, + "editUrl": "Typescript/index.md", + "jumpLinks": [], + "pages": [ + { + "title": "Typescript Client SDK Quick Start", + "identifier": "index", + "indexIdentifier": "index", + "hasPages": false, + "editUrl": "index.md", + "jumpLinks": [ + { + "title": "Typescript Client SDK Quick Start", + "route": "typescript-client-sdk-quick-start", + "depth": 1 + }, + { + "title": "Project structure", + "route": "project-structure", + "depth": 2 + }, + { + "title": "Basic layout", + "route": "basic-layout", + "depth": 2 + }, + { + "title": "Generate your module types", + "route": "generate-your-module-types", + "depth": 2 + }, + { + "title": "Create your SpacetimeDB client", + "route": "create-your-spacetimedb-client", + "depth": 2 + }, + { + "title": "Register callbacks and connect", + "route": "register-callbacks-and-connect", + "depth": 2 + }, + { + "title": "onConnect Callback", + "route": "onconnect-callback", + "depth": 3 + }, + { + "title": "initialStateSync callback", + "route": "initialstatesync-callback", + "depth": 3 + }, + { + "title": "Message.onInsert callback - Update messages", + "route": "message-oninsert-callback-update-messages", + "depth": 3 + }, + { + "title": "User.onInsert callback - Notify about new users", + "route": "user-oninsert-callback-notify-about-new-users", + "depth": 3 + }, + { + "title": "User.onUpdate callback - Notify about updated users", + "route": "user-onupdate-callback-notify-about-updated-users", + "depth": 3 + }, + { + "title": "SetNameReducer.on callback - Handle errors and update profile name", + "route": "setnamereducer-on-callback-handle-errors-and-update-profile-name", + "depth": 3 + }, + { + "title": "SendMessageReducer.on callback - Handle errors", + "route": "sendmessagereducer-on-callback-handle-errors", + "depth": 3 + }, + { + "title": "Update the UI button callbacks", + "route": "update-the-ui-button-callbacks", + "depth": 2 + }, + { + "title": "Connecting to the module", + "route": "connecting-to-the-module", + "depth": 2 + }, + { + "title": "What's next?", + "route": "what-s-next-", + "depth": 2 + }, + { + "title": "Troubleshooting", + "route": "troubleshooting", + "depth": 2 + } + ], + "pages": [] + }, + { + "title": "The SpacetimeDB Typescript client SDK", + "identifier": "SDK Reference", + "indexIdentifier": "SDK Reference", + "hasPages": false, + "editUrl": "SDK%20Reference.md", + "jumpLinks": [ + { + "title": "The SpacetimeDB Typescript client SDK", + "route": "the-spacetimedb-typescript-client-sdk", + "depth": 1 + }, + { + "title": "Install the SDK", + "route": "install-the-sdk", + "depth": 2 + }, + { + "title": "Tip for utilities/scripts", + "route": "tip-for-utilities-scripts", + "depth": 3 + }, + { + "title": "Generate module bindings", + "route": "generate-module-bindings", + "depth": 2 + }, + { + "title": "API at a glance", + "route": "api-at-a-glance", + "depth": 2 + }, + { + "title": "Classes", + "route": "classes", + "depth": 3 + }, + { + "title": "Class `SpacetimeDBClient`", + "route": "class-spacetimedbclient-", + "depth": 3 + }, + { + "title": "Constructors", + "route": "constructors", + "depth": 2 + }, + { + "title": "`SpacetimeDBClient` constructor", + "route": "-spacetimedbclient-constructor", + "depth": 3 + }, + { + "title": "Parameters", + "route": "parameters", + "depth": 4 + }, + { + "title": "Example", + "route": "example", + "depth": 4 + }, + { + "title": "Properties", + "route": "properties", + "depth": 2 + }, + { + "title": "`SpacetimeDBClient` identity", + "route": "-spacetimedbclient-identity", + "depth": 3 + }, + { + "title": "`SpacetimeDBClient` live", + "route": "-spacetimedbclient-live", + "depth": 3 + }, + { + "title": "`SpacetimeDBClient` token", + "route": "-spacetimedbclient-token", + "depth": 3 + }, + { + "title": "Parameters", + "route": "parameters", + "depth": 4 + }, + { + "title": "`SpacetimeDBClient` connect", + "route": "-spacetimedbclient-connect", + "depth": 3 + }, + { + "title": "Parameters", + "route": "parameters", + "depth": 4 + }, + { + "title": "Returns", + "route": "returns", + "depth": 4 + }, + { + "title": "Example", + "route": "example", + "depth": 4 + }, + { + "title": "`SpacetimeDBClient` disconnect", + "route": "-spacetimedbclient-disconnect", + "depth": 3 + }, + { + "title": "Example", + "route": "example", + "depth": 4 + }, + { + "title": "`SpacetimeDBClient` subscribe", + "route": "-spacetimedbclient-subscribe", + "depth": 3 + }, + { + "title": "Parameters", + "route": "parameters", + "depth": 4 + }, + { + "title": "Example", + "route": "example", + "depth": 4 + }, + { + "title": "Events", + "route": "events", + "depth": 2 + }, + { + "title": "`SpacetimeDBClient` onConnect", + "route": "-spacetimedbclient-onconnect", + "depth": 3 + }, + { + "title": "Parameters", + "route": "parameters", + "depth": 4 + }, + { + "title": "Example", + "route": "example", + "depth": 4 + }, + { + "title": "`SpacetimeDBClient` onError", + "route": "-spacetimedbclient-onerror", + "depth": 3 + }, + { + "title": "Parameters", + "route": "parameters", + "depth": 4 + }, + { + "title": "Example", + "route": "example", + "depth": 4 + }, + { + "title": "Class `Identity`", + "route": "class-identity-", + "depth": 3 + }, + { + "title": "Constructors", + "route": "constructors", + "depth": 2 + }, + { + "title": "`Identity` constructor", + "route": "-identity-constructor", + "depth": 3 + }, + { + "title": "Parameters", + "route": "parameters", + "depth": 4 + }, + { + "title": "Methods", + "route": "methods", + "depth": 2 + }, + { + "title": "`Identity` isEqual", + "route": "-identity-isequal", + "depth": 3 + }, + { + "title": "Parameters", + "route": "parameters", + "depth": 4 + }, + { + "title": "Returns", + "route": "returns", + "depth": 4 + }, + { + "title": "`Identity` toHexString", + "route": "-identity-tohexstring", + "depth": 3 + }, + { + "title": "Returns", + "route": "returns", + "depth": 4 + }, + { + "title": "`Identity` fromString", + "route": "-identity-fromstring", + "depth": 3 + }, + { + "title": "Parameters", + "route": "parameters", + "depth": 4 + }, + { + "title": "Returns", + "route": "returns", + "depth": 4 + }, + { + "title": "Class `{Table}`", + "route": "class-table-", + "depth": 3 + }, + { + "title": "Properties", + "route": "properties", + "depth": 2 + }, + { + "title": "{Table} name", + "route": "-table-name", + "depth": 3 + }, + { + "title": "{Table} tableName", + "route": "-table-tablename", + "depth": 3 + }, + { + "title": "Methods", + "route": "methods", + "depth": 2 + }, + { + "title": "{Table} all", + "route": "-table-all", + "depth": 3 + }, + { + "title": "Returns", + "route": "returns", + "depth": 4 + }, + { + "title": "Example", + "route": "example", + "depth": 4 + }, + { + "title": "{Table} count", + "route": "-table-count", + "depth": 3 + }, + { + "title": "Returns", + "route": "returns", + "depth": 4 + }, + { + "title": "Example", + "route": "example", + "depth": 4 + }, + { + "title": "{Table} filterBy{COLUMN}", + "route": "-table-filterby-column-", + "depth": 3 + }, + { + "title": "Parameters", + "route": "parameters", + "depth": 4 + }, + { + "title": "Returns", + "route": "returns", + "depth": 4 + }, + { + "title": "Example", + "route": "example", + "depth": 4 + }, + { + "title": "{Table} fromValue", + "route": "-table-fromvalue", + "depth": 3 + }, + { + "title": "Parameters", + "route": "parameters", + "depth": 4 + }, + { + "title": "Returns", + "route": "returns", + "depth": 4 + }, + { + "title": "{Table} getAlgebraicType", + "route": "-table-getalgebraictype", + "depth": 3 + }, + { + "title": "Example", + "route": "example", + "depth": 4 + }, + { + "title": "Returns", + "route": "returns", + "depth": 4 + }, + { + "title": "{Table} onInsert", + "route": "-table-oninsert", + "depth": 3 + }, + { + "title": "Parameters", + "route": "parameters", + "depth": 4 + }, + { + "title": "Example", + "route": "example", + "depth": 4 + }, + { + "title": "{Table} removeOnInsert", + "route": "-table-removeoninsert", + "depth": 3 + }, + { + "title": "Parameters", + "route": "parameters", + "depth": 4 + }, + { + "title": "{Table} onUpdate", + "route": "-table-onupdate", + "depth": 3 + }, + { + "title": "Parameters", + "route": "parameters", + "depth": 4 + }, + { + "title": "Example", + "route": "example", + "depth": 4 + }, + { + "title": "{Table} removeOnUpdate", + "route": "-table-removeonupdate", + "depth": 3 + }, + { + "title": "Parameters", + "route": "parameters", + "depth": 4 + }, + { + "title": "{Table} onDelete", + "route": "-table-ondelete", + "depth": 3 + }, + { + "title": "Parameters", + "route": "parameters", + "depth": 4 + }, + { + "title": "Example", + "route": "example", + "depth": 4 + }, + { + "title": "{Table} removeOnDelete", + "route": "-table-removeondelete", + "depth": 3 + }, + { + "title": "Parameters", + "route": "parameters", + "depth": 4 + }, + { + "title": "Class `{Reducer}`", + "route": "class-reducer-", + "depth": 3 + }, + { + "title": "Static methods", + "route": "static-methods", + "depth": 2 + }, + { + "title": "{Reducer} call", + "route": "-reducer-call", + "depth": 3 + }, + { + "title": "Example", + "route": "example", + "depth": 4 + }, + { + "title": "Events", + "route": "events", + "depth": 2 + }, + { + "title": "{Reducer} on", + "route": "-reducer-on", + "depth": 3 + }, + { + "title": "Parameters", + "route": "parameters", + "depth": 4 + }, + { + "title": "Example", + "route": "example", + "depth": 4 + } + ], + "pages": [] + } + ] + } + ] + }, + { + "title": "Module ABI Reference", + "identifier": "Module ABI Reference", + "indexIdentifier": "index", + "comingSoon": false, + "hasPages": true, + "editUrl": "Module%20ABI%20Reference/index.md", + "jumpLinks": [], + "pages": [ + { + "title": "Module ABI Reference", + "identifier": "index", + "indexIdentifier": "index", + "hasPages": false, + "editUrl": "index.md", + "jumpLinks": [ + { + "title": "Module ABI Reference", + "route": "module-abi-reference", + "depth": 1 + }, + { + "title": "General notes", + "route": "general-notes", + "depth": 2 + }, + { + "title": "Logging", + "route": "logging", + "depth": 2 + }, + { + "title": "Buffer handling", + "route": "buffer-handling", + "depth": 2 + }, + { + "title": "Reducer scheduling", + "route": "reducer-scheduling", + "depth": 2 + }, + { + "title": "Altering tables", + "route": "altering-tables", + "depth": 2 + }, + { + "title": "Inserting and deleting rows", + "route": "inserting-and-deleting-rows", + "depth": 2 + }, + { + "title": "Querying tables", + "route": "querying-tables", + "depth": 2 + }, + { + "title": "Appendix, `bindings.h`", + "route": "appendix-bindings-h-", + "depth": 2 + } + ], + "pages": [] + } + ] + }, + { + "title": "HTTP API Reference", + "identifier": "HTTP API Reference", + "indexIdentifier": "index", + "comingSoon": false, + "hasPages": true, + "editUrl": "HTTP%20API%20Reference/index.md", + "jumpLinks": [], + "pages": [ + { + "title": "`/database` HTTP API", + "identifier": "Databases", + "indexIdentifier": "Databases", + "hasPages": false, + "editUrl": "Databases.md", + "jumpLinks": [ + { + "title": "`/database` HTTP API", + "route": "-database-http-api", + "depth": 1 + }, + { + "title": "At a glance", + "route": "at-a-glance", + "depth": 2 + }, + { + "title": "`/database/dns/:name GET`", + "route": "-database-dns-name-get-", + "depth": 2 + }, + { + "title": "Parameters", + "route": "parameters", + "depth": 4 + }, + { + "title": "Returns", + "route": "returns", + "depth": 4 + }, + { + "title": "`/database/reverse_dns/:address GET`", + "route": "-database-reverse_dns-address-get-", + "depth": 2 + }, + { + "title": "Parameters", + "route": "parameters", + "depth": 4 + }, + { + "title": "Returns", + "route": "returns", + "depth": 4 + }, + { + "title": "`/database/set_name GET`", + "route": "-database-set_name-get-", + "depth": 2 + }, + { + "title": "Query Parameters", + "route": "query-parameters", + "depth": 4 + }, + { + "title": "Required Headers", + "route": "required-headers", + "depth": 4 + }, + { + "title": "Returns", + "route": "returns", + "depth": 4 + }, + { + "title": "`/database/ping GET`", + "route": "-database-ping-get-", + "depth": 2 + }, + { + "title": "`/database/register_tld GET`", + "route": "-database-register_tld-get-", + "depth": 2 + }, + { + "title": "Query Parameters", + "route": "query-parameters", + "depth": 4 + }, + { + "title": "Required Headers", + "route": "required-headers", + "depth": 4 + }, + { + "title": "Returns", + "route": "returns", + "depth": 4 + }, + { + "title": "`/database/request_recovery_code GET`", + "route": "-database-request_recovery_code-get-", + "depth": 2 + }, + { + "title": "Query Parameters", + "route": "query-parameters", + "depth": 4 + }, + { + "title": "`/database/confirm_recovery_code GET`", + "route": "-database-confirm_recovery_code-get-", + "depth": 2 + }, + { + "title": "Query Parameters", + "route": "query-parameters", + "depth": 4 + }, + { + "title": "`/database/publish POST`", + "route": "-database-publish-post-", + "depth": 2 + }, + { + "title": "Query Parameters", + "route": "query-parameters", + "depth": 4 + }, + { + "title": "Required Headers", + "route": "required-headers", + "depth": 4 + }, + { + "title": "Data", + "route": "data", + "depth": 4 + }, + { + "title": "Returns", + "route": "returns", + "depth": 4 + }, + { + "title": "`/database/delete/:address POST`", + "route": "-database-delete-address-post-", + "depth": 2 + }, + { + "title": "Parameters", + "route": "parameters", + "depth": 4 + }, + { + "title": "Required Headers", + "route": "required-headers", + "depth": 4 + }, + { + "title": "`/database/subscribe/:name_or_address GET`", + "route": "-database-subscribe-name_or_address-get-", + "depth": 2 + }, + { + "title": "Parameters", + "route": "parameters", + "depth": 4 + }, + { + "title": "Required Headers", + "route": "required-headers", + "depth": 4 + }, + { + "title": "Optional Headers", + "route": "optional-headers", + "depth": 4 + }, + { + "title": "`/database/call/:name_or_address/:reducer POST`", + "route": "-database-call-name_or_address-reducer-post-", + "depth": 2 + }, + { + "title": "Parameters", + "route": "parameters", + "depth": 4 + }, + { + "title": "Required Headers", + "route": "required-headers", + "depth": 4 + }, + { + "title": "Data", + "route": "data", + "depth": 4 + }, + { + "title": "`/database/schema/:name_or_address GET`", + "route": "-database-schema-name_or_address-get-", + "depth": 2 + }, + { + "title": "Parameters", + "route": "parameters", + "depth": 4 + }, + { + "title": "Query Parameters", + "route": "query-parameters", + "depth": 4 + }, + { + "title": "Returns", + "route": "returns", + "depth": 4 + }, + { + "title": "`/database/schema/:name_or_address/:entity_type/:entity GET`", + "route": "-database-schema-name_or_address-entity_type-entity-get-", + "depth": 2 + }, + { + "title": "Parameters", + "route": "parameters", + "depth": 4 + }, + { + "title": "Query Parameters", + "route": "query-parameters", + "depth": 4 + }, + { + "title": "Returns", + "route": "returns", + "depth": 4 + }, + { + "title": "`/database/info/:name_or_address GET`", + "route": "-database-info-name_or_address-get-", + "depth": 2 + }, + { + "title": "Parameters", + "route": "parameters", + "depth": 4 + }, + { + "title": "Returns", + "route": "returns", + "depth": 4 + }, + { + "title": "`/database/logs/:name_or_address GET`", + "route": "-database-logs-name_or_address-get-", + "depth": 2 + }, + { + "title": "Parameters", + "route": "parameters", + "depth": 4 + }, + { + "title": "Query Parameters", + "route": "query-parameters", + "depth": 4 + }, + { + "title": "Required Headers", + "route": "required-headers", + "depth": 4 + }, + { + "title": "Returns", + "route": "returns", + "depth": 4 + }, + { + "title": "`/database/sql/:name_or_address POST`", + "route": "-database-sql-name_or_address-post-", + "depth": 2 + }, + { + "title": "Parameters", + "route": "parameters", + "depth": 4 + }, + { + "title": "Required Headers", + "route": "required-headers", + "depth": 4 + }, + { + "title": "Data", + "route": "data", + "depth": 4 + }, + { + "title": "Returns", + "route": "returns", + "depth": 4 + } + ], + "pages": [] + }, + { + "title": "`/energy` HTTP API", + "identifier": "Energy", + "indexIdentifier": "Energy", + "hasPages": false, + "editUrl": "Energy.md", + "jumpLinks": [ + { + "title": "`/energy` HTTP API", + "route": "-energy-http-api", + "depth": 1 + }, + { + "title": "At a glance", + "route": "at-a-glance", + "depth": 2 + }, + { + "title": "`/energy/:identity GET`", + "route": "-energy-identity-get-", + "depth": 2 + }, + { + "title": "Parameters", + "route": "parameters", + "depth": 4 + }, + { + "title": "Returns", + "route": "returns", + "depth": 4 + }, + { + "title": "`/energy/:identity POST`", + "route": "-energy-identity-post-", + "depth": 2 + }, + { + "title": "Parameters", + "route": "parameters", + "depth": 4 + }, + { + "title": "Query Parameters", + "route": "query-parameters", + "depth": 4 + }, + { + "title": "Required Headers", + "route": "required-headers", + "depth": 4 + }, + { + "title": "Returns", + "route": "returns", + "depth": 4 + } + ], + "pages": [] + }, + { + "title": "`/identity` HTTP API", + "identifier": "Identities", + "indexIdentifier": "Identities", + "hasPages": false, + "editUrl": "Identities.md", + "jumpLinks": [ + { + "title": "`/identity` HTTP API", + "route": "-identity-http-api", + "depth": 1 + }, + { + "title": "At a glance", + "route": "at-a-glance", + "depth": 2 + }, + { + "title": "`/identity GET`", + "route": "-identity-get-", + "depth": 2 + }, + { + "title": "Query Parameters", + "route": "query-parameters", + "depth": 4 + }, + { + "title": "Returns", + "route": "returns", + "depth": 4 + }, + { + "title": "`/identity POST`", + "route": "-identity-post-", + "depth": 2 + }, + { + "title": "Query Parameters", + "route": "query-parameters", + "depth": 4 + }, + { + "title": "Returns", + "route": "returns", + "depth": 4 + }, + { + "title": "`/identity/websocket_token POST`", + "route": "-identity-websocket_token-post-", + "depth": 2 + }, + { + "title": "Required Headers", + "route": "required-headers", + "depth": 4 + }, + { + "title": "Returns", + "route": "returns", + "depth": 4 + }, + { + "title": "`/identity/:identity/set-email POST`", + "route": "-identity-identity-set-email-post-", + "depth": 2 + }, + { + "title": "Parameters", + "route": "parameters", + "depth": 4 + }, + { + "title": "Query Parameters", + "route": "query-parameters", + "depth": 4 + }, + { + "title": "Required Headers", + "route": "required-headers", + "depth": 4 + }, + { + "title": "`/identity/:identity/databases GET`", + "route": "-identity-identity-databases-get-", + "depth": 2 + }, + { + "title": "Parameters", + "route": "parameters", + "depth": 4 + }, + { + "title": "Returns", + "route": "returns", + "depth": 4 + }, + { + "title": "`/identity/:identity/verify GET`", + "route": "-identity-identity-verify-get-", + "depth": 2 + }, + { + "title": "Parameters", + "route": "parameters", + "depth": 4 + }, + { + "title": "Required Headers", + "route": "required-headers", + "depth": 4 + }, + { + "title": "Returns", + "route": "returns", + "depth": 4 + } + ], + "pages": [] + }, + { + "title": "SpacetimeDB HTTP Authorization", + "identifier": "index", + "indexIdentifier": "index", + "hasPages": false, + "editUrl": "index.md", + "jumpLinks": [ + { + "title": "SpacetimeDB HTTP Authorization", + "route": "spacetimedb-http-authorization", + "depth": 1 + }, + { + "title": "Generating identities and tokens", + "route": "generating-identities-and-tokens", + "depth": 3 + }, + { + "title": "Encoding `Authorization` headers", + "route": "encoding-authorization-headers", + "depth": 3 + }, + { + "title": "Python", + "route": "python", + "depth": 4 + }, + { + "title": "Rust", + "route": "rust", + "depth": 4 + }, + { + "title": "C#", + "route": "c-", + "depth": 4 + } + ], + "pages": [] + } + ] + }, + { + "title": "SATN Reference", + "identifier": "SATN Reference", + "indexIdentifier": "index", + "comingSoon": false, + "hasPages": true, + "editUrl": "SATN%20Reference/index.md", + "jumpLinks": [], + "pages": [ + { + "title": "SATN Binary Format (BSATN)", + "identifier": "Binary Format", + "indexIdentifier": "Binary Format", + "hasPages": false, + "editUrl": "Binary%20Format.md", + "jumpLinks": [ + { + "title": "SATN Binary Format (BSATN)", + "route": "satn-binary-format-bsatn-", + "depth": 1 + }, + { + "title": "Notes on notation", + "route": "notes-on-notation", + "depth": 2 + }, + { + "title": "Values", + "route": "values", + "depth": 2 + }, + { + "title": "At a glance", + "route": "at-a-glance", + "depth": 3 + }, + { + "title": "`AlgebraicValue`", + "route": "-algebraicvalue-", + "depth": 3 + }, + { + "title": "`SumValue`", + "route": "-sumvalue-", + "depth": 3 + }, + { + "title": "`ProductValue`", + "route": "-productvalue-", + "depth": 3 + }, + { + "title": "`BuiltinValue`", + "route": "-builtinvalue-", + "depth": 3 + }, + { + "title": "Types", + "route": "types", + "depth": 2 + } + ], + "pages": [] + }, + { + "title": "SATN JSON Format", + "identifier": "index", + "indexIdentifier": "index", + "hasPages": false, + "editUrl": "index.md", + "jumpLinks": [ + { + "title": "SATN JSON Format", + "route": "satn-json-format", + "depth": 1 + }, + { + "title": "Values", + "route": "values", + "depth": 2 + }, + { + "title": "At a glance", + "route": "at-a-glance", + "depth": 3 + }, + { + "title": "`AlgebraicValue`", + "route": "-algebraicvalue-", + "depth": 3 + }, + { + "title": "`SumValue`", + "route": "-sumvalue-", + "depth": 3 + }, + { + "title": "`ProductValue`", + "route": "-productvalue-", + "depth": 3 + }, + { + "title": "`BuiltinValue`", + "route": "-builtinvalue-", + "depth": 3 + }, + { + "title": "Types", + "route": "types", + "depth": 2 + }, + { + "title": "At a glance", + "route": "at-a-glance", + "depth": 3 + }, + { + "title": "`AlgebraicType`", + "route": "-algebraictype-", + "depth": 4 + }, + { + "title": "`SumType`", + "route": "-sumtype-", + "depth": 4 + }, + { + "title": "`ProductType`", + "route": "-producttype-", + "depth": 3 + }, + { + "title": "`BuiltinType`", + "route": "-builtintype-", + "depth": 3 + }, + { + "title": "`AlgebraicTypeRef`", + "route": "-algebraictyperef-", + "depth": 3 + } + ], + "pages": [] + } + ] + }, + { + "title": "SQL Reference", + "identifier": "SQL Reference", + "indexIdentifier": "index", + "comingSoon": false, + "hasPages": true, + "editUrl": "SQL%20Reference/index.md", + "jumpLinks": [], + "pages": [ + { + "title": "SQL Support", + "identifier": "index", + "indexIdentifier": "index", + "hasPages": false, + "editUrl": "index.md", + "jumpLinks": [ + { + "title": "SQL Support", + "route": "sql-support", + "depth": 1 + }, + { + "title": "Types", + "route": "types", + "depth": 2 + }, + { + "title": "Definition statements", + "route": "definition-statements", + "depth": 3 + }, + { + "title": "Query statements", + "route": "query-statements", + "depth": 3 + }, + { + "title": "Data types", + "route": "data-types", + "depth": 2 + }, + { + "title": "Nullable types", + "route": "nullable-types", + "depth": 3 + }, + { + "title": "Logic types", + "route": "logic-types", + "depth": 3 + }, + { + "title": "Numeric types", + "route": "numeric-types", + "depth": 3 + }, + { + "title": "Integer types", + "route": "integer-types", + "depth": 4 + }, + { + "title": "Floating-point types", + "route": "floating-point-types", + "depth": 4 + }, + { + "title": "Text types", + "route": "text-types", + "depth": 3 + }, + { + "title": "Syntax", + "route": "syntax", + "depth": 2 + }, + { + "title": "Comments", + "route": "comments", + "depth": 3 + }, + { + "title": "Expressions", + "route": "expressions", + "depth": 3 + }, + { + "title": "Literals", + "route": "literals", + "depth": 4 + }, + { + "title": "Binary operators", + "route": "binary-operators", + "depth": 4 + }, + { + "title": "Logical expressions", + "route": "logical-expressions", + "depth": 4 + }, + { + "title": "Function calls", + "route": "function-calls", + "depth": 4 + }, + { + "title": "Table identifiers", + "route": "table-identifiers", + "depth": 4 + }, + { + "title": "Column references", + "route": "column-references", + "depth": 4 + }, + { + "title": "Wildcards", + "route": "wildcards", + "depth": 4 + }, + { + "title": "Parenthesized expressions", + "route": "parenthesized-expressions", + "depth": 4 + }, + { + "title": "`CREATE TABLE`", + "route": "-create-table-", + "depth": 3 + }, + { + "title": "Examples", + "route": "examples", + "depth": 4 + }, + { + "title": "`DROP TABLE`", + "route": "-drop-table-", + "depth": 3 + }, + { + "title": "Queries", + "route": "queries", + "depth": 2 + }, + { + "title": "`FROM`", + "route": "-from-", + "depth": 3 + }, + { + "title": "Examples", + "route": "examples", + "depth": 4 + }, + { + "title": "`JOIN`", + "route": "-join-", + "depth": 3 + }, + { + "title": "Examples", + "route": "examples", + "depth": 3 + }, + { + "title": "`SELECT`", + "route": "-select-", + "depth": 3 + }, + { + "title": "Examples", + "route": "examples", + "depth": 4 + }, + { + "title": "Examples", + "route": "examples", + "depth": 4 + }, + { + "title": "`INSERT`", + "route": "-insert-", + "depth": 3 + }, + { + "title": "Examples", + "route": "examples", + "depth": 4 + }, + { + "title": "UPDATE", + "route": "update", + "depth": 3 + }, + { + "title": "Examples", + "route": "examples", + "depth": 4 + }, + { + "title": "DELETE", + "route": "delete", + "depth": 3 + }, + { + "title": "Examples", + "route": "examples", + "depth": 4 + } + ], + "pages": [] + } + ] + }, + { + "title": "WebSocket API Reference", + "identifier": "WebSocket API Reference", + "indexIdentifier": "index", + "comingSoon": false, + "hasPages": true, + "editUrl": "WebSocket%20API%20Reference/index.md", + "jumpLinks": [], + "pages": [ + { + "title": "The SpacetimeDB WebSocket API", + "identifier": "index", + "indexIdentifier": "index", + "hasPages": false, + "editUrl": "index.md", + "jumpLinks": [ + { + "title": "The SpacetimeDB WebSocket API", + "route": "the-spacetimedb-websocket-api", + "depth": 1 + }, + { + "title": "Connecting", + "route": "connecting", + "depth": 2 + }, + { + "title": "Protocols", + "route": "protocols", + "depth": 2 + }, + { + "title": "Binary Protocol", + "route": "binary-protocol", + "depth": 3 + }, + { + "title": "Text Protocol", + "route": "text-protocol", + "depth": 3 + }, + { + "title": "Messages", + "route": "messages", + "depth": 2 + }, + { + "title": "Client to server", + "route": "client-to-server", + "depth": 3 + }, + { + "title": "`FunctionCall`", + "route": "-functioncall-", + "depth": 4 + }, + { + "title": "Binary: ProtoBuf definition", + "route": "binary-protobuf-definition", + "depth": 5 + }, + { + "title": "Text: JSON encoding", + "route": "text-json-encoding", + "depth": 5 + }, + { + "title": "`Subscribe`", + "route": "-subscribe-", + "depth": 4 + }, + { + "title": "Binary: ProtoBuf definition", + "route": "binary-protobuf-definition", + "depth": 5 + }, + { + "title": "Text: JSON encoding", + "route": "text-json-encoding", + "depth": 5 + }, + { + "title": "Server to client", + "route": "server-to-client", + "depth": 3 + }, + { + "title": "`IdentityToken`", + "route": "-identitytoken-", + "depth": 4 + }, + { + "title": "Binary: ProtoBuf definition", + "route": "binary-protobuf-definition", + "depth": 5 + }, + { + "title": "Text: JSON encoding", + "route": "text-json-encoding", + "depth": 5 + }, + { + "title": "`SubscriptionUpdate`", + "route": "-subscriptionupdate-", + "depth": 4 + }, + { + "title": "Binary: ProtoBuf definition", + "route": "binary-protobuf-definition", + "depth": 5 + }, + { + "title": "Text: JSON encoding", + "route": "text-json-encoding", + "depth": 5 + }, + { + "title": "`TransactionUpdate`", + "route": "-transactionupdate-", + "depth": 4 + }, + { + "title": "Binary: ProtoBuf definition", + "route": "binary-protobuf-definition", + "depth": 5 + }, + { + "title": "Text: JSON encoding", + "route": "text-json-encoding", + "depth": 5 + } + ], + "pages": [] + } + ] + } + ], + "rootEditURL": "https://github.com/clockworklabs/spacetime-docs/edit/master/docs/" +}; \ No newline at end of file diff --git a/docs/spacetime-docs.json b/docs/spacetime-docs.json index b65be52d790..289514c1eb1 100644 --- a/docs/spacetime-docs.json +++ b/docs/spacetime-docs.json @@ -13,5 +13,5 @@ "SATN Reference", "SQL Reference" ], - "editURLRoot": "https://github.com/clockworklabs/spacetime-docs" + "editURLRoot": "https://github.com/clockworklabs/spacetime-docs/edit/master/docs/" } \ No newline at end of file diff --git a/docs/src/index.ts b/docs/src/index.ts index 303351751d2..7927a60e65a 100644 --- a/docs/src/index.ts +++ b/docs/src/index.ts @@ -1,6 +1,6 @@ #! /usr/bin/env node -import { DocConfig, DocSectionConfig } from "./types"; +import { DocConfig, DocSectionConfig, JumpLink } from "./types"; const { Command } = require("commander"); const clear = require("clear"); @@ -13,9 +13,29 @@ const cwd = process.cwd(); const DOCS_PATH = path.join(__dirname, "docs"); const CONFIG_PATH = path.join(cwd, "spacetime-docs.json"); +function extractHeadersFromMarkdown(filePath) { + const content = fs.readFileSync(filePath, "utf-8"); + const headers: JumpLink[] = []; + const titleRegex = /^#\s+(.+)$/m; + const headerMatch = content.match(titleRegex); + const title = headerMatch ? headerMatch[1] : null; + + const headerRegex = /^(#+)\s+(.+)$/gm; // This captures the hashes and the header text + let match; + while ((match = headerRegex.exec(content))) { + const depth = match[1].length; // Count of #'s indicate depth + headers.push({ + title: match[2], + route: match[2].toLowerCase().replace(/[^\w]+/g, "-"), + depth: depth, + }); + } + + return { title, jumpLinks: headers }; +} let config = { docPath: "", - order: [], + order: [] as any[], editURLRoot: "", }; @@ -39,75 +59,93 @@ const program = new Command(); program.version("1.0.0").description("Spacetime Docs CLI"); program.command("generate").action(() => { - let unorderedSections: DocSectionConfig[] = []; - - const dirs = fs - .readdirSync(config.docPath, { withFileTypes: true }) - .filter((dirent: any) => dirent.isDirectory()) - .map((dirent: any) => dirent.name); - - for (const dir of dirs) { - const section: DocSectionConfig = { - title: dir, - identifier: dir.toLowerCase().replace(" ", "-"), - comingSoon: false, + const rootDir = config.docPath; + + function processDirectory(dir) { + const categoryFile = path.join(dir, "_category.json"); + if (!fs.existsSync(categoryFile)) return null; + + const category = fsExtra.readJSONSync(categoryFile); + const docSectionConfig = { + title: category.title, + identifier: path.basename(dir), + indexIdentifier: category.index.replace(".md", ""), + comingSoon: category.disabled || false, + tag: category.tag || undefined, hasPages: false, - editUrl: `/${dir}`, + editUrl: encodeURIComponent(category.title) + "/" + category.index, jumpLinks: [], - pages: [], + pages: [] as any[], }; - // Check for subdirectories (pages) - const subDirs = fs - .readdirSync(path.join(config.docPath, dir), { withFileTypes: true }) - .filter((dirent: any) => dirent.isDirectory()) - .map((dirent: any) => dirent.name); - - if (subDirs.length > 0) { - section.hasPages = true; - for (const subDir of subDirs) { - const page: DocSectionConfig = { - title: subDir, - identifier: subDir.toLowerCase(), - comingSoon: false, - hasPages: false, - editUrl: `/${dir}/${subDir}`, - jumpLinks: [], - }; - if (section.pages) { - section.pages.push(page); - } else { - section.pages = [page]; + const items = fs.readdirSync(dir); + const subSections: any[] = []; + + items.forEach((item) => { + const itemPath = path.join(dir, item); + const isDirectory = fs.statSync(itemPath).isDirectory(); + const isMarkdownFile = path.extname(item) === ".md"; + + if (isDirectory) { + const subSection = processDirectory(itemPath); + if (subSection) { + subSections.push(subSection); } + } else if (isMarkdownFile && item !== "_category.json") { + const { title, jumpLinks } = extractHeadersFromMarkdown(itemPath); + const pageIdentifier = item.replace(".md", ""); + + subSections.push({ + title: title || pageIdentifier, // Use the extracted title if available, otherwise fallback to the pageIdentifier + identifier: pageIdentifier, + indexIdentifier: pageIdentifier, + hasPages: false, + editUrl: encodeURIComponent(pageIdentifier) + ".md", + jumpLinks: jumpLinks, + pages: [], + }); } + }); + + if (subSections.length > 0) { + docSectionConfig.hasPages = true; + docSectionConfig.pages = subSections; } - unorderedSections.push(section); + return docSectionConfig; } - let sections = config.order - .map((orderTitle) => { - return unorderedSections.find((section) => section.title === orderTitle); - }) - .filter(Boolean); + const docConfig = { + sections: [] as any[], + rootEditURL: config.editURLRoot, + }; - if (sections.length === 0 || sections === undefined) { - sections = []; - } + const folders = fs.readdirSync(rootDir); + folders.forEach((folder) => { + const folderPath = path.join(rootDir, folder); + if (fs.statSync(folderPath).isDirectory()) { + const section = processDirectory(folderPath); + if (section) { + docConfig.sections.push(section); + } + } + }); - const docConfig: DocConfig = { - //@ts-ignore - sections: sections, - rootEditURL: config.editURLRoot, // replace with your actual root edit URL - }; + docConfig.sections = docConfig.sections.sort((a: any, b: any) => { + const orderA = config.order.indexOf(a.title); + const orderB = config.order.indexOf(b.title); + + if (orderA === -1 && orderB === -1) return 0; // If both items are not in the order list, they remain in their current order. + if (orderA === -1) return 1; // If only 'a' is not in the order list, 'b' comes first. + if (orderB === -1) return -1; // If only 'b' is not in the order list, 'a' comes first. + + return orderA - orderB; // Otherwise, sort according to the order in the order list. + }); - const configContent = `export const docConfig = ${JSON.stringify( - docConfig, - null, - 2 - )};`; - fs.writeFileSync(path.join(cwd, "docs-config.ts"), configContent); - console.log("docs-config.ts generated successfully!"); + fs.writeFileSync( + path.join(rootDir, "docs-config.ts"), + `export const docsConfig = ${JSON.stringify(docConfig, null, 2)};` + ); }); program diff --git a/docs/src/types.ts b/docs/src/types.ts index e3cd091c731..e19e2bd4327 100644 --- a/docs/src/types.ts +++ b/docs/src/types.ts @@ -8,7 +8,7 @@ export type DocSectionConfig = { identifier: string; indexIdentifier?: string; comingSoon: boolean; - expiremental?: boolean; + tag?: boolean; hasPages: boolean; editUrl: string; nextKey?: JumpLink; @@ -20,4 +20,5 @@ export type DocSectionConfig = { export type JumpLink = { title: string; route: string; + depth: number; }; diff --git a/docs/tsconfig.json b/docs/tsconfig.json index e3320274a30..4777e8b13b1 100644 --- a/docs/tsconfig.json +++ b/docs/tsconfig.json @@ -7,7 +7,8 @@ "module": "commonjs", "sourceMap": true, "esModuleInterop": true, - "moduleResolution": "node" + "moduleResolution": "node", + "noImplicitAny": false }, - "exclude": ["node_modules", "./docs-config.ts"] + "exclude": ["node_modules", "*/docs-config.ts"] } From 68c9eea34264a85ee30cfd332874da1bec3255b1 Mon Sep 17 00:00:00 2001 From: Nathaniel Richards Date: Mon, 18 Sep 2023 13:13:57 -0400 Subject: [PATCH 003/195] Added tag documentation --- docs/README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/README.md b/docs/README.md index cfabc943ae3..89e183f613e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -38,3 +38,11 @@ In the `spacetime-docs.json` file in your project root add: "SQL Reference" ] ``` + +## Add tags + +Tags will show up next to the section title in the sidebar. In the `_category.json` file for a section add: + +```json +tag: "New" // Or anything else... +``` From 32b6e94d0519b145949f9bc1b396d386691f3cb0 Mon Sep 17 00:00:00 2001 From: Nathaniel Richards Date: Mon, 18 Sep 2023 13:20:17 -0400 Subject: [PATCH 004/195] Added docs about images --- docs/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/README.md b/docs/README.md index 89e183f613e..f8b68ffde2b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -46,3 +46,7 @@ Tags will show up next to the section title in the sidebar. In the `_category.js ```json tag: "New" // Or anything else... ``` + +## Images + +When referencing your images upload straight to GITHUB in the `images` folder and use that link as our own makeshift CDN for now. From a592f0e260e138b4ca54c97641a6c9bd21bd9739 Mon Sep 17 00:00:00 2001 From: Nathaniel Richards Date: Tue, 19 Sep 2023 13:57:07 -0400 Subject: [PATCH 005/195] Added support for previous and next key --- docs/docs/docs-config.ts | 153 ++++++++++++++++++++++++++++++++++++--- docs/src/index.ts | 53 ++++++++++++-- docs/src/types.ts | 1 + 3 files changed, 191 insertions(+), 16 deletions(-) diff --git a/docs/docs/docs-config.ts b/docs/docs/docs-config.ts index b971de62877..7f58b31ca28 100644 --- a/docs/docs/docs-config.ts +++ b/docs/docs/docs-config.ts @@ -13,6 +13,7 @@ export const docsConfig = { "title": "SpacetimeDB Documentation", "identifier": "index", "indexIdentifier": "index", + "content": "# SpacetimeDB Documentation\r\n\r\n## Installation\r\n\r\nYou can run SpacetimeDB as a standalone database server via the `spacetime` CLI tool.\r\n\r\nYou can find the instructions to install the CLI tool for your platform [here](/install).\r\n\r\n\r\n\r\nTo get started running your own standalone instance of SpacetimeDB check out our [Getting Started Guide](/docs/getting-started).\r\n\r\n\r\n\r\n## What is SpacetimeDB?\r\n\r\nYou can think of SpacetimeDB as a database that is also a server.\r\n\r\nIt 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\".\r\n\r\nInstead 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.\r\n\r\nThis 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.\r\n\r\n
\r\n \"SpacetimeDB\r\n
\r\n SpacetimeDB application architecture\r\n (elements in white are provided by SpacetimeDB)\r\n
\r\n
\r\n\r\nIt'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.\r\n\r\nSo 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.\r\n\r\nSpacetimeDB 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.\r\n\r\nThis 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.\r\n\r\n## State Synchronization\r\n\r\nSpacetimeDB 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.\r\n\r\n## Identities\r\n\r\nAn 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`.\r\n\r\nSpacetimeDB 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.\r\n\r\nEach 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.\r\n\r\nAdditionally, 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.\r\n\r\nSpacetimeDB provides tools in the CLI and the [client SDKs](/docs/client-languages/client-sdk-overview) for managing credentials.\r\n\r\n## Language Support\r\n\r\n### Server-side Libraries\r\n\r\nCurrently, Rust is the best-supported language for writing SpacetimeDB modules. Support for lots of other languages is in the works!\r\n\r\n- [Rust](/docs/server-languages/rust/rust-module-reference) - [(Quickstart)](/docs/server-languages/rust/rust-module-quickstart-guide)\r\n- [C#](/docs/server-languages/csharp/csharp-module-reference) - [(Quickstart)](/docs/server-languages/csharp/csharp-module-quickstart-guide)\r\n- Python (Coming soon)\r\n- C# (Coming soon)\r\n- Typescript (Coming soon)\r\n- C++ (Planned)\r\n- Lua (Planned)\r\n\r\n### Client-side SDKs\r\n\r\n- [Rust](/docs/client-languages/rust/rust-sdk-reference) - [(Quickstart)](/docs/client-languages/rust/rust-sdk-quickstart-guide)\r\n- [C#](/docs/client-languages/csharp/csharp-sdk-reference) - [(Quickstart)](/docs/client-languages/csharp/csharp-sdk-quickstart-guide)\r\n- [TypeScript](/docs/client-languages/typescript/typescript-sdk-reference) - [(Quickstart)](client-languages/typescript/typescript-sdk-quickstart-guide)\r\n- [Python](/docs/client-languages/python/python-sdk-reference) - [(Quickstart)](/docs/python/python-sdk-quickstart-guide)\r\n- C++ (Planned)\r\n- Lua (Planned)\r\n\r\n### Unity\r\n\r\nSpacetimeDB 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).\r\n\r\n## FAQ\r\n\r\n1. What is SpacetimeDB?\r\n It's a whole cloud platform within a database that's fast enough to run real-time games.\r\n\r\n1. How do I use SpacetimeDB?\r\n 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.\r\n\r\n1. How do I get/install SpacetimeDB?\r\n Just install our command line tool and then upload your application to the cloud.\r\n\r\n1. How do I create a new database with SpacetimeDB?\r\n Follow our [Quick Start](/docs/quick-start) guide!\r\n\r\nTL;DR in an empty directory:\r\n\r\n```bash\r\nspacetime init --lang=rust\r\nspacetime publish\r\n```\r\n\r\n5. How do I create a Unity game with SpacetimeDB?\r\n Follow our [Unity Project](/docs/unity-project) guide!\r\n\r\nTL;DR in an empty directory:\r\n\r\n```bash\r\nspacetime init --lang=rust\r\nspacetime publish\r\nspacetime generate --out-dir --lang=csharp\r\n```\r\n", "hasPages": false, "editUrl": "index.md", "jumpLinks": [ @@ -69,7 +70,13 @@ export const docsConfig = { ], "pages": [] } - ] + ], + "previousKey": null, + "nextKey": { + "title": "Getting Started", + "route": "index", + "depth": 1 + } }, { "title": "Getting Started", @@ -84,6 +91,7 @@ export const docsConfig = { "title": "Getting Started", "identifier": "index", "indexIdentifier": "index", + "content": "# Getting Started\r\n\r\nTo develop SpacetimeDB applications locally, you will need to run the Standalone version of the server.\r\n\r\n1. [Install](/install) the SpacetimeDB CLI (Command Line Interface).\r\n2. Run the start command\r\n\r\n```bash\r\nspacetime start\r\n```\r\n\r\nThe server listens on port `3000` by default. You can change this by using the `--listen-addr` option described below.\r\n\r\nSSL is not supported in standalone mode.\r\n\r\nTo set up your CLI to connect to the server, you can run the `spacetime server` command.\r\n\r\n```bash\r\nspacetime server set \"http://localhost:3000\"\r\n```\r\n\r\n## What's Next?\r\n\r\nYou are ready to start developing SpacetimeDB modules. We have a quickstart guide for each supported server-side language:\r\n\r\n- [Rust](/docs/server-languages/rust/rust-module-quickstart-guide)\r\n- [C#](/docs/server-languages/csharp/csharp-module-quickstart-guide)\r\n\r\nThen you can write your client application. We have a quickstart guide for each supported client-side language:\r\n\r\n- [Rust](/docs/client-languages/rust/rust-sdk-quickstart-guide)\r\n- [C#](/docs/client-languages/csharp/csharp-sdk-quickstart-guide)\r\n- [Typescript](/docs/client-languages/typescript/typescript-sdk-quickstart-guide)\r\n- [Python](/docs/client-languages/python/python-sdk-quickstart-guide)\r\n\r\nWe also have a [step-by-step tutorial](/docs/unity-tutorial/unity-tutorial-part-1) for building a multiplayer game in Unity3d.\r\n", "hasPages": false, "editUrl": "index.md", "jumpLinks": [ @@ -100,7 +108,17 @@ export const docsConfig = { ], "pages": [] } - ] + ], + "previousKey": { + "title": "Overview", + "route": "index", + "depth": 1 + }, + "nextKey": { + "title": "Cloud Testnet", + "route": "index", + "depth": 1 + } }, { "title": "Cloud Testnet", @@ -115,6 +133,7 @@ export const docsConfig = { "title": "SpacetimeDB Cloud Deployment", "identifier": "index", "indexIdentifier": "index", + "content": "# SpacetimeDB Cloud Deployment\r\n\r\nThe SpacetimeDB Cloud is a managed cloud service that provides developers an easy way to deploy their SpacetimeDB apps to the cloud.\r\n\r\nCurrently only the `testnet` is available for SpacetimeDB cloud which is subject to wipes. The `mainnet` will be available soon.\r\n\r\n## Deploy via CLI\r\n\r\n1. [Install](/install) the SpacetimeDB CLI.\r\n1. Configure your CLI to use the SpacetimeDB Cloud. To do this, run the `spacetime server` command:\r\n\r\n```bash\r\nspacetime server set \"https://testnet.spacetimedb.com\"\r\n```\r\n\r\n## Connecting your Identity to the Web Dashboard\r\n\r\nBy associating an email with your CLI identity, you can view your published modules on the web dashboard.\r\n\r\n1. Get your identity using the `spacetime identity list` command. Copy it to your clipboard.\r\n1. Connect your email address to your identity using the `spacetime identity set-email` command:\r\n\r\n```bash\r\nspacetime identity set-email \r\n```\r\n\r\n1. Open the SpacetimeDB website and log in using your email address.\r\n1. Choose your identity from the dropdown menu.\r\n1. Validate your email address by clicking the link in the email you receive.\r\n1. You should now be able to see your published modules on the web dashboard.\r\n\r\n---\r\n\r\nWith SpacetimeDB Cloud, you benefit from automatic scaling, robust security, and the convenience of not having to manage the hosting environment.\r\n", "hasPages": false, "editUrl": "index.md", "jumpLinks": [ @@ -136,7 +155,17 @@ export const docsConfig = { ], "pages": [] } - ] + ], + "previousKey": { + "title": "Getting Started", + "route": "index", + "depth": 1 + }, + "nextKey": { + "title": "Unity Tutorial", + "route": "index", + "depth": 1 + } }, { "title": "Unity Tutorial", @@ -151,6 +180,7 @@ export const docsConfig = { "title": "Part 1 - Basic Multiplayer", "identifier": "index", "indexIdentifier": "index", + "content": "# Part 1 - Basic Multiplayer\r\n\r\n![UnityTutorial-HeroImage](/images/unity-tutorial/UnityTutorial-HeroImage.JPG)\r\n\r\nThe 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.\r\n\r\n## Setting up the Tutorial Unity Project\r\n\r\nIn 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.\r\n\r\n### Step 1: Create a Blank Unity Project\r\n\r\n1. Open Unity and create a new project by selecting \"New\" from the Unity Hub or going to **File -> New Project**.\r\n\r\n![UnityHub-NewProject](/images/unity-tutorial/UnityHub-NewProject.JPG)\r\n\r\n2. 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\".\r\n\r\nThis 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.\r\n\r\nEnsure that you have selected the **3D (URP)** template for this project.\r\n\r\n![UnityHub-3DURP](/images/unity-tutorial/UnityHub-3DURP.JPG)\r\n\r\n3. Click \"Create\" to generate the blank project.\r\n\r\n### Step 2: Adding Required Packages\r\n\r\nTo work with SpacetimeDB and ensure compatibility, we need to add some essential packages to our Unity project. Follow these steps:\r\n\r\n1. Open the Unity Package Manager by going to **Window -> Package Manager**.\r\n2. In the Package Manager window, select the \"Unity Registry\" tab to view unity packages.\r\n3. Search for and install the following package:\r\n - **Input System**: Enables the use of Unity's new Input system used by this project.\r\n\r\n![PackageManager-InputSystem](/images/unity-tutorial/PackageManager-InputSystem.JPG)\r\n\r\n4. You may need to restart the Unity Editor to switch to the new Input system.\r\n\r\n![PackageManager-Restart](/images/unity-tutorial/PackageManager-Restart.JPG)\r\n\r\n### Step 3: Importing the Tutorial Package\r\n\r\nIn this step, we will import the provided Unity tutorial package that contains the basic single-player game setup. Follow these instructions:\r\n\r\n1. 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)\r\n2. In Unity, go to **Assets -> Import Package -> Custom Package**.\r\n\r\n![Unity-ImportCustomPackageB](/images/unity-tutorial/Unity-ImportCustomPackageB.JPG)\r\n\r\n3. Browse and select the downloaded tutorial package file.\r\n4. 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.\r\n\r\n![Unity-ImportCustomPackage2](/images/unity-tutorial/Unity-ImportCustomPackage2.JPG)\r\n\r\n### Step 4: Running the Project\r\n\r\nNow that we have everything set up, let's run the project and see it in action:\r\n\r\n1. Open the scene named \"Main\" in the Scenes folder provided in the project hierarchy by double-clicking it.\r\n\r\n![Unity-OpenSceneMain](/images/unity-tutorial/Unity-OpenSceneMain.JPG)\r\n\r\nNOTE: 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.\r\n\r\n![Unity Import TMP Essentials](/images/unity-tutorial/Unity-ImportTMPEssentials.JPG)\r\n\r\n2. Press the **Play** button located at the top of the Unity Editor.\r\n\r\n![Unity-Play](/images/unity-tutorial/Unity-Play.JPG)\r\n\r\n3. Enter any name and click \"Continue\"\r\n\r\n4. You should see a character loaded in the scene, and you can use the keyboard or mouse controls to move the character around.\r\n\r\nCongratulations! 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.\r\n\r\n## Writing our SpacetimeDB Server Module\r\n\r\n### Step 1: Create the Module\r\n\r\n1. It is important that you already have SpacetimeDB [installed](/install).\r\n\r\n2. Run the SpacetimeDB standalone using the installed CLI. In your terminal or command window, run the following command:\r\n\r\n```bash\r\nspacetime start\r\n```\r\n\r\n3. Make sure your CLI is pointed to your local instance of SpacetimeDB. You can do this by running the following command:\r\n\r\n```bash\r\nspacetime server set http://localhost:3000\r\n```\r\n\r\n4. Open a new command prompt or terminal and navigate to the folder where your Unity project is located using the cd command. For example:\r\n\r\n```bash\r\ncd path/to/tutorial_project_folder\r\n```\r\n\r\n5. Run the following command to initialize the SpacetimeDB server project with Rust as the language:\r\n\r\n```bash\r\nspacetime init --lang=rust ./Server\r\n```\r\n\r\nThis 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.\r\n\r\n### Step 2: SpacetimeDB Tables\r\n\r\n1. Using your favorite code editor (we recommend VS Code) open the newly created lib.rs file in the Server folder.\r\n2. Erase everything in the file as we are going to be writing our module from scratch.\r\n\r\n---\r\n\r\n**Understanding ECS**\r\n\r\nECS 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).\r\n\r\nWe chose ECS for this example project because it promotes scalability, modularity, and efficient data management, making it ideal for building multiplayer games with SpacetimeDB.\r\n\r\n---\r\n\r\n3. Add the following code to lib.rs.\r\n\r\nWe 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.\r\n\r\nYou'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.\r\n\r\nThe `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\r\nglobal variables that can be accessed from anywhere.\r\n\r\nWe 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.\r\n\r\n```rust\r\nuse spacetimedb::{spacetimedb, Identity, SpacetimeType, Timestamp, ReducerContext};\r\nuse log;\r\n\r\n#[spacetimedb(table)]\r\n#[derive(Clone)]\r\npub struct Config {\r\n // Config is a global table with a single row. This table will be used to\r\n // store configuration or global variables\r\n\r\n #[primarykey]\r\n // always 0\r\n // having a table with a primarykey field which is always zero is a way to store singleton global state\r\n pub version: u32,\r\n\r\n pub message_of_the_day: String,\r\n}\r\n\r\n```\r\n\r\nThe 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.\r\n\r\nThe 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.\r\n\r\n```rust\r\n#[spacetimedb(table)]\r\npub struct SpawnableEntityComponent {\r\n // All entities that can be spawned in the world will have this component.\r\n // This allows us to find all objects in the world by iterating through\r\n // this table. It also ensures that all world objects have a unique\r\n // entity_id.\r\n\r\n #[primarykey]\r\n #[autoinc]\r\n pub entity_id: u64,\r\n}\r\n```\r\n\r\nThe `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.\r\n\r\n```rust\r\n#[derive(Clone)]\r\n#[spacetimedb(table)]\r\npub struct PlayerComponent {\r\n // All players have this component and it associates the spawnable entity\r\n // with the user's identity. It also stores their username.\r\n\r\n #[primarykey]\r\n pub entity_id: u64,\r\n #[unique]\r\n pub owner_id: Identity,\r\n\r\n // username is provided to the create_player reducer\r\n pub username: String,\r\n // this value is updated when the user logs in and out\r\n pub logged_in: bool,\r\n}\r\n```\r\n\r\nThe next component, `MobileLocationComponent`, is used to store the last known location and movement direction for spawnable entities that can move smoothly through the world.\r\n\r\nUsing 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.\r\n\r\nWe 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.\r\n\r\n```rust\r\n#[derive(SpacetimeType, Clone)]\r\npub struct StdbVector2 {\r\n // A spacetime type which can be used in tables and reducers to represent\r\n // a 2d position.\r\n pub x: f32,\r\n pub z: f32,\r\n}\r\n\r\nimpl StdbVector2 {\r\n // this allows us to use StdbVector2::ZERO in reducers\r\n pub const ZERO: StdbVector2 = StdbVector2 { x: 0.0, z: 0.0 };\r\n}\r\n\r\n#[spacetimedb(table)]\r\n#[derive(Clone)]\r\npub struct MobileLocationComponent {\r\n // This component will be created for all world objects that can move\r\n // smoothly throughout the world. It keeps track of the position the last\r\n // time the component was updated and the direction the mobile object is\r\n // currently moving.\r\n\r\n #[primarykey]\r\n pub entity_id: u64,\r\n\r\n // The last known location of this entity\r\n pub location: StdbVector2,\r\n // Movement direction, {0,0} if not moving at all.\r\n pub direction: StdbVector2,\r\n // Timestamp when movement started. Timestamp::UNIX_EPOCH if not moving.\r\n pub move_start_timestamp: Timestamp,\r\n}\r\n```\r\n\r\nNext we write our very first reducer, `create_player`. This reducer is called by the client after the user enters a username.\r\n\r\n---\r\n\r\n**SpacetimeDB Reducers**\r\n\r\n\"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.\r\n\r\n---\r\n\r\nThe 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.\r\n\r\nBefore 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.\r\n\r\n---\r\n\r\n**Rust Options**\r\n\r\nRust 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/).\r\n\r\n---\r\n\r\nThe 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.\r\n\r\nNote 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.\r\n\r\n---\r\n\r\n**Rust Results**\r\n\r\nA 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/).\r\n\r\n---\r\n\r\nWe then create and insert our `PlayerComponent` and `MobileLocationComponent` using the same `entity_id`.\r\n\r\nWe 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.\r\n\r\n```rust\r\n#[spacetimedb(reducer)]\r\npub fn create_player(ctx: ReducerContext, username: String) -> Result<(), String> {\r\n // This reducer is called when the user logs in for the first time and\r\n // enters a username\r\n\r\n let owner_id = ctx.sender;\r\n // We check to see if there is already a PlayerComponent with this identity.\r\n // this should never happen because the client only calls it if no player\r\n // is found.\r\n if PlayerComponent::filter_by_owner_id(&owner_id).is_some() {\r\n log::info!(\"Player already exists\");\r\n return Err(\"Player already exists\".to_string());\r\n }\r\n\r\n // Next we create the SpawnableEntityComponent. The entity_id for this\r\n // component automatically increments and we get it back from the result\r\n // of the insert call and use it for all components.\r\n\r\n let entity_id = SpawnableEntityComponent::insert(SpawnableEntityComponent { entity_id: 0 })\r\n .expect(\"Failed to create player spawnable entity component.\")\r\n .entity_id;\r\n // The PlayerComponent uses the same entity_id and stores the identity of\r\n // the owner, username, and whether or not they are logged in.\r\n PlayerComponent::insert(PlayerComponent {\r\n entity_id,\r\n owner_id,\r\n username: username.clone(),\r\n logged_in: true,\r\n })\r\n .expect(\"Failed to insert player component.\");\r\n // The MobileLocationComponent is used to calculate the current position\r\n // of an entity that can move smoothly in the world. We are using 2d\r\n // positions and the client will use the terrain height for the y value.\r\n MobileLocationComponent::insert(MobileLocationComponent {\r\n entity_id,\r\n location: StdbVector2::ZERO,\r\n direction: StdbVector2::ZERO,\r\n move_start_timestamp: Timestamp::UNIX_EPOCH,\r\n })\r\n .expect(\"Failed to insert player mobile entity component.\");\r\n\r\n log::info!(\"Player created: {}({})\", username, entity_id);\r\n\r\n Ok(())\r\n}\r\n```\r\n\r\nSpacetimeDB also gives you the ability to define custom reducers that automatically trigger when certain events occur.\r\n\r\n- `init` - Called the very first time you publish your module and anytime you clear the database. We'll learn about publishing a little later.\r\n- `connect` - Called when a user connects to the SpacetimeDB module. Their identity can be found in the `sender` member of the `ReducerContext`.\r\n- `disconnect` - Called when a user disconnects from the SpacetimeDB module.\r\n\r\nNext 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)`.\r\n\r\n```rust\r\n#[spacetimedb(init)]\r\npub fn init() {\r\n // Called when the module is initially published\r\n\r\n\r\n // Create our global config table.\r\n Config::insert(Config {\r\n version: 0,\r\n message_of_the_day: \"Hello, World!\".to_string(),\r\n })\r\n .expect(\"Failed to insert config.\");\r\n}\r\n```\r\n\r\nWe 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.\r\n\r\n```rust\r\n#[spacetimedb(connect)]\r\npub fn identity_connected(ctx: ReducerContext) {\r\n // called when the client connects, we update the logged_in state to true\r\n update_player_login_state(ctx, true);\r\n}\r\n\r\n\r\n#[spacetimedb(disconnect)]\r\npub fn identity_disconnected(ctx: ReducerContext) {\r\n // Called when the client disconnects, we update the logged_in state to false\r\n update_player_login_state(ctx, false);\r\n}\r\n\r\n\r\npub fn update_player_login_state(ctx: ReducerContext, logged_in: bool) {\r\n // This helper function gets the PlayerComponent, sets the logged\r\n // in variable and updates the SpacetimeDB table row.\r\n if let Some(player) = PlayerComponent::filter_by_owner_id(&ctx.sender) {\r\n let entity_id = player.entity_id;\r\n // We clone the PlayerComponent so we can edit it and pass it back.\r\n let mut player = player.clone();\r\n player.logged_in = logged_in;\r\n PlayerComponent::update_by_entity_id(&entity_id, player);\r\n }\r\n}\r\n```\r\n\r\nOur 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.\r\n\r\nUsing 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.\r\n\r\n---\r\n\r\n**Server Validation**\r\n\r\nIn 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.\r\n\r\n---\r\n\r\n```rust\r\n#[spacetimedb(reducer)]\r\npub fn move_player(\r\n ctx: ReducerContext,\r\n start: StdbVector2,\r\n direction: StdbVector2,\r\n) -> Result<(), String> {\r\n // Update the MobileLocationComponent with the current movement\r\n // values. The client will call this regularly as the direction of movement\r\n // changes. A fully developed game should validate these moves on the server\r\n // before committing them, but that is beyond the scope of this tutorial.\r\n\r\n let owner_id = ctx.sender;\r\n // First, look up the player using the sender identity, then use that\r\n // entity_id to retrieve and update the MobileLocationComponent\r\n if let Some(player) = PlayerComponent::filter_by_owner_id(&owner_id) {\r\n if let Some(mut mobile) = MobileLocationComponent::filter_by_entity_id(&player.entity_id) {\r\n mobile.location = start;\r\n mobile.direction = direction;\r\n mobile.move_start_timestamp = ctx.timestamp;\r\n MobileLocationComponent::update_by_entity_id(&player.entity_id, mobile);\r\n\r\n\r\n return Ok(());\r\n }\r\n }\r\n\r\n\r\n // If we can not find the PlayerComponent for this user something went wrong.\r\n // This should never happen.\r\n return Err(\"Player not found\".to_string());\r\n}\r\n\r\n\r\n#[spacetimedb(reducer)]\r\npub fn stop_player(ctx: ReducerContext, location: StdbVector2) -> Result<(), String> {\r\n // Update the MobileLocationComponent when a player comes to a stop. We set\r\n // the location to the current location and the direction to {0,0}\r\n let owner_id = ctx.sender;\r\n if let Some(player) = PlayerComponent::filter_by_owner_id(&owner_id) {\r\n if let Some(mut mobile) = MobileLocationComponent::filter_by_entity_id(&player.entity_id) {\r\n mobile.location = location;\r\n mobile.direction = StdbVector2::ZERO;\r\n mobile.move_start_timestamp = Timestamp::UNIX_EPOCH;\r\n MobileLocationComponent::update_by_entity_id(&player.entity_id, mobile);\r\n\r\n\r\n return Ok(());\r\n }\r\n }\r\n\r\n\r\n return Err(\"Player not found\".to_string());\r\n}\r\n```\r\n\r\n4. 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.\r\n\r\n```bash\r\ncd Server\r\n\r\nspacetime publish -c yourname-bitcraftmini\r\n```\r\n\r\nIf 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.\r\n\r\n## Updating our Unity Project to use SpacetimeDB\r\n\r\nNow we are ready to connect our bitcraft mini project to SpacetimeDB.\r\n\r\n### Step 1: Import the SDK and Generate Module Files\r\n\r\n1. 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.\r\n\r\n```bash\r\nhttps://github.com/clockworklabs/com.clockworklabs.spacetimedbsdk.git\r\n```\r\n\r\n![Unity-PackageManager](/images/unity-tutorial/Unity-PackageManager.JPG)\r\n\r\n3. 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.\r\n\r\n```bash\r\nmkdir -p ../Client/Assets/module_bindings\r\n\r\nspacetime generate --out-dir ../Client/Assets/module_bindings --lang=csharp\r\n```\r\n\r\n### Step 2: Connect to the SpacetimeDB Module\r\n\r\n1. 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.\r\n\r\n![Unity-AddNetworkManager](/images/unity-tutorial/Unity-AddNetworkManager.JPG)\r\n\r\n2. 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:\r\n\r\n`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`.\r\n\r\n```csharp\r\nusing SpacetimeDB;\r\nusing SpacetimeDB.Types;\r\n```\r\n\r\n3. Inside the class definition add the following members:\r\n\r\n```csharp\r\n // These are connection variables that are exposed on the GameManager\r\n // inspector. The cloud version of SpacetimeDB needs sslEnabled = true\r\n [SerializeField] private string moduleAddress = \"YOUR_MODULE_DOMAIN_OR_ADDRESS\";\r\n [SerializeField] private string hostName = \"localhost:3000\";\r\n [SerializeField] private bool sslEnabled = false;\r\n\r\n // This is the identity for this player that is automatically generated\r\n // the first time you log in. We set this variable when the\r\n // onIdentityReceived callback is triggered by the SDK after connecting\r\n private Identity local_identity;\r\n```\r\n\r\nThe 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.\r\n\r\n4. 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.\r\n\r\nIn 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.\r\n\r\n---\r\n\r\n**Local Client Cache**\r\n\r\nThe \"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.\r\n\r\n---\r\n\r\n```csharp\r\n // When we connect to SpacetimeDB we send our subscription queries\r\n // to tell SpacetimeDB which tables we want to get updates for.\r\n SpacetimeDBClient.instance.onConnect += () =>\r\n {\r\n Debug.Log(\"Connected.\");\r\n\r\n SpacetimeDBClient.instance.Subscribe(new List()\r\n {\r\n \"SELECT * FROM Config\",\r\n \"SELECT * FROM SpawnableEntityComponent\",\r\n \"SELECT * FROM PlayerComponent\",\r\n \"SELECT * FROM MobileLocationComponent\",\r\n });\r\n };\r\n\r\n // called when we have an error connecting to SpacetimeDB\r\n SpacetimeDBClient.instance.onConnectError += (error, message) =>\r\n {\r\n Debug.LogError($\"Connection error: \" + message);\r\n };\r\n\r\n // called when we are disconnected from SpacetimeDB\r\n SpacetimeDBClient.instance.onDisconnect += (closeStatus, error) =>\r\n {\r\n Debug.Log(\"Disconnected.\");\r\n };\r\n\r\n\r\n // called when we receive the client identity from SpacetimeDB\r\n SpacetimeDBClient.instance.onIdentityReceived += (token, identity) => {\r\n AuthToken.SaveToken(token);\r\n local_identity = identity;\r\n };\r\n\r\n\r\n // called after our local cache is populated from a Subscribe call\r\n SpacetimeDBClient.instance.onSubscriptionApplied += OnSubscriptionApplied;\r\n\r\n // now that we’ve registered all our callbacks, lets connect to\r\n // spacetimedb\r\n SpacetimeDBClient.instance.Connect(AuthToken.Token, hostName, moduleAddress, sslEnabled);\r\n```\r\n\r\n5. 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.\r\n\r\n```csharp\r\nvoid OnSubscriptionApplied()\r\n{\r\n // If we don't have any data for our player, then we are creating a\r\n // new one. Let's show the username dialog, which will then call the\r\n // create player reducer\r\n var player = PlayerComponent.FilterByOwnerId(local_identity);\r\n if (player == null)\r\n {\r\n // Show username selection\r\n UIUsernameChooser.instance.Show();\r\n }\r\n\r\n // Show the Message of the Day in our Config table of the Client Cache\r\n UIChatController.instance.OnChatMessageReceived(\"Message of the Day: \" + Config.FilterByVersion(0).MessageOfTheDay);\r\n\r\n // Now that we've done this work we can unregister this callback\r\n SpacetimeDBClient.instance.onSubscriptionApplied -= OnSubscriptionApplied;\r\n}\r\n```\r\n\r\n### Step 3: Adding the Multiplayer Functionality\r\n\r\n1. 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:\r\n\r\n```csharp\r\n LocalPlayer.instance.username = _usernameField.text;\r\n BitcraftMiniGameManager.instance.StartGame();\r\n```\r\n\r\nwith:\r\n\r\n```csharp\r\n // Call the SpacetimeDB CreatePlayer reducer\r\n Reducer.CreatePlayer(_usernameField.text);\r\n```\r\n\r\n2. 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.\r\n\r\n```csharp\r\n public ulong EntityId;\r\n\r\n public TMP_Text UsernameElement;\r\n\r\n public string Username { set { UsernameElement.text = value; } }\r\n\r\n void Start()\r\n {\r\n // initialize overhead name\r\n UsernameElement = GetComponentInChildren();\r\n var canvas = GetComponentInChildren();\r\n canvas.worldCamera = Camera.main;\r\n\r\n // get the username from the PlayerComponent for this object and set it in the UI\r\n PlayerComponent playerComp = PlayerComponent.FilterByEntityId(EntityId);\r\n Username = playerComp.Username;\r\n\r\n // get the last location for this player and set the initial\r\n // position\r\n MobileLocationComponent mobPos = MobileLocationComponent.FilterByEntityId(EntityId);\r\n Vector3 playerPos = new Vector3(mobPos.Location.X, 0.0f, mobPos.Location.Z);\r\n transform.position = new Vector3(playerPos.x, MathUtil.GetTerrainHeight(playerPos), playerPos.z);\r\n\r\n // register for a callback that is called when the client gets an\r\n // update for a row in the MobileLocationComponent table\r\n MobileLocationComponent.OnUpdate += MobileLocationComponent_OnUpdate;\r\n }\r\n```\r\n\r\n3. 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)\r\n\r\n```csharp\r\n private void MobileLocationComponent_OnUpdate(MobileLocationComponent oldObj, MobileLocationComponent obj, ReducerEvent callInfo)\r\n {\r\n // if the update was made to this object\r\n if(obj.EntityId == EntityId)\r\n {\r\n // update the DirectionVec in the PlayerMovementController component with the updated values\r\n var movementController = GetComponent();\r\n movementController.DirectionVec = new Vector3(obj.Direction.X, 0.0f, obj.Direction.Z);\r\n // if DirectionVec is {0,0,0} then we came to a stop so correct our position to match the server\r\n if (movementController.DirectionVec == Vector3.zero)\r\n {\r\n Vector3 playerPos = new Vector3(obj.Location.X, 0.0f, obj.Location.Z);\r\n transform.position = new Vector3(playerPos.x, MathUtil.GetTerrainHeight(playerPos), playerPos.z);\r\n }\r\n }\r\n }\r\n```\r\n\r\n4. 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`.\r\n\r\n```csharp\r\n PlayerComponent.OnInsert += PlayerComponent_OnInsert;\r\n```\r\n\r\n5. 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.\r\n\r\n```csharp\r\n private void PlayerComponent_OnInsert(PlayerComponent obj, ReducerEvent callInfo)\r\n {\r\n // if the identity of the PlayerComponent matches our user identity then this is the local player\r\n if(obj.OwnerId == local_identity)\r\n {\r\n // Set the local player username\r\n LocalPlayer.instance.Username = obj.Username;\r\n\r\n // Get the MobileLocationComponent for this object and update the position to match the server\r\n MobileLocationComponent mobPos = MobileLocationComponent.FilterByEntityId(obj.EntityId);\r\n Vector3 playerPos = new Vector3(mobPos.Location.X, 0.0f, mobPos.Location.Z);\r\n LocalPlayer.instance.transform.position = new Vector3(playerPos.x, MathUtil.GetTerrainHeight(playerPos), playerPos.z);\r\n\r\n // Now that we have our initial position we can start the game\r\n StartGame();\r\n }\r\n // otherwise this is a remote player\r\n else\r\n {\r\n // spawn the player object and attach the RemotePlayer component\r\n var remotePlayer = Instantiate(PlayerPrefab);\r\n remotePlayer.AddComponent().EntityId = obj.EntityId;\r\n }\r\n }\r\n```\r\n\r\n6. 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\r\n\r\n```csharp\r\n private Vector3? lastUpdateDirection;\r\n\r\n private void FixedUpdate()\r\n {\r\n var directionVec = GetDirectionVec();\r\n PlayerMovementController.Local.DirectionVec = directionVec;\r\n\r\n // first get the position of the player\r\n var ourPos = PlayerMovementController.Local.GetModelTransform().position;\r\n // if we are moving , and we haven't updated our destination yet, or we've moved more than .1 units, update our destination\r\n if (directionVec.sqrMagnitude != 0 && (!lastUpdateDirection.HasValue || (directionVec - lastUpdateDirection.Value).sqrMagnitude > .1f))\r\n {\r\n Reducer.MovePlayer(new StdbVector2() { X = ourPos.x, Z = ourPos.z }, new StdbVector2() { X = directionVec.x, Z = directionVec.z });\r\n lastUpdateDirection = directionVec;\r\n }\r\n // if we stopped moving, send the update\r\n else if(directionVec.sqrMagnitude == 0 && lastUpdateDirection != null)\r\n {\r\n Reducer.StopPlayer(new StdbVector2() { X = ourPos.x, Z = ourPos.z });\r\n lastUpdateDirection = null;\r\n }\r\n }\r\n```\r\n\r\n7. 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.\r\n\r\n![GameManager-Inspector2](/images/unity-tutorial/GameManager-Inspector2.JPG)\r\n\r\n### Step 4: Play the Game!\r\n\r\n1. Go to File -> Build Settings... Replace the SampleScene with the Main scene we have been working in.\r\n\r\n![Unity-AddOpenScenes](/images/unity-tutorial/Unity-AddOpenScenes.JPG)\r\n\r\nWhen 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.\r\n\r\n### Step 5: Implement Player Logout\r\n\r\nSo 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.\r\n\r\n1. Open `BitcraftMiniGameManager.cs` and add the following code to the `Start` function:\r\n\r\n```csharp\r\n PlayerComponent.OnUpdate += PlayerComponent_OnUpdate;\r\n```\r\n\r\n2. 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.\r\n\r\n```csharp\r\n private void PlayerComponent_OnUpdate(PlayerComponent oldValue, PlayerComponent newValue, ReducerEvent dbEvent)\r\n {\r\n OnPlayerComponentChanged(newValue);\r\n }\r\n\r\n private void PlayerComponent_OnInsert(PlayerComponent obj, ReducerEvent dbEvent)\r\n {\r\n OnPlayerComponentChanged(obj);\r\n }\r\n\r\n private void OnPlayerComponentChanged(PlayerComponent obj)\r\n {\r\n // if the identity of the PlayerComponent matches our user identity then this is the local player\r\n if (obj.OwnerId == local_identity)\r\n {\r\n // Set the local player username\r\n LocalPlayer.instance.Username = obj.Username;\r\n\r\n // Get the MobileLocationComponent for this object and update the position to match the server\r\n MobileLocationComponent mobPos = MobileLocationComponent.FilterByEntityId(obj.EntityId);\r\n Vector3 playerPos = new Vector3(mobPos.Location.X, 0.0f, mobPos.Location.Z);\r\n LocalPlayer.instance.transform.position = new Vector3(playerPos.x, MathUtil.GetTerrainHeight(playerPos), playerPos.z);\r\n\r\n // Now that we have our initial position we can start the game\r\n StartGame();\r\n }\r\n // otherwise this is a remote player\r\n else\r\n {\r\n // if the remote player is logged in, spawn it\r\n if (obj.LoggedIn)\r\n {\r\n // spawn the player object and attach the RemotePlayer component\r\n var remotePlayer = Instantiate(PlayerPrefab);\r\n remotePlayer.AddComponent().EntityId = obj.EntityId;\r\n }\r\n // otherwise we need to look for the remote player object in the scene (if it exists) and destroy it\r\n else\r\n {\r\n var remotePlayer = FindObjectsOfType().FirstOrDefault(item => item.EntityId == obj.EntityId);\r\n if (remotePlayer != null)\r\n {\r\n Destroy(remotePlayer.gameObject);\r\n }\r\n }\r\n }\r\n }\r\n```\r\n\r\n3. Now you when you play the game you should see remote players disappear when they log out.\r\n\r\n### Step 6: Add Chat Support\r\n\r\nThe 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.\r\n\r\n1. First lets add a new `ChatMessage` table to the SpacetimeDB module. Add the following code to lib.rs.\r\n\r\n```rust\r\n#[spacetimedb(table)]\r\npub struct ChatMessage {\r\n // The primary key for this table will be auto-incremented\r\n #[primarykey]\r\n #[autoinc]\r\n pub chat_entity_id: u64,\r\n\r\n // The entity id of the player (or NPC) that sent the message\r\n pub source_entity_id: u64,\r\n // Message contents\r\n pub chat_text: String,\r\n // Timestamp of when the message was sent\r\n pub timestamp: Timestamp,\r\n}\r\n```\r\n\r\n2. Now we need to add a reducer to handle inserting new chat messages. Add the following code to lib.rs.\r\n\r\n```rust\r\n#[spacetimedb(reducer)]\r\npub fn chat_message(ctx: ReducerContext, message: String) -> Result<(), String> {\r\n // Add a chat entry to the ChatMessage table\r\n\r\n // Get the player component based on the sender identity\r\n let owner_id = ctx.sender;\r\n if let Some(player) = PlayerComponent::filter_by_owner_id(&owner_id) {\r\n // Now that we have the player we can insert the chat message using the player entity id.\r\n ChatMessage::insert(ChatMessage {\r\n // this column auto-increments so we can set it to 0\r\n chat_entity_id: 0,\r\n source_entity_id: player.entity_id,\r\n chat_text: message,\r\n timestamp: ctx.timestamp,\r\n })\r\n .unwrap();\r\n\r\n return Ok(());\r\n }\r\n\r\n Err(\"Player not found\".into())\r\n}\r\n```\r\n\r\n3. Before updating the client, let's generate the client files and publish our module.\r\n\r\n```bash\r\nspacetime generate --out-dir ../Client/Assets/module_bindings --lang=csharp\r\n\r\nspacetime publish -c yourname-bitcraftmini\r\n```\r\n\r\n4. 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`.\r\n\r\n```csharp\r\npublic void OnChatButtonPress()\r\n{\r\n Reducer.ChatMessage(_chatInput.text);\r\n _chatInput.text = \"\";\r\n}\r\n```\r\n\r\n5. Next let's add the `ChatMessage` table to our list of subscriptions.\r\n\r\n```csharp\r\n SpacetimeDBClient.instance.Subscribe(new List()\r\n {\r\n \"SELECT * FROM Config\",\r\n \"SELECT * FROM SpawnableEntityComponent\",\r\n \"SELECT * FROM PlayerComponent\",\r\n \"SELECT * FROM MobileLocationComponent\",\r\n \"SELECT * FROM ChatMessage\",\r\n });\r\n```\r\n\r\n6. 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:\r\n\r\n```csharp\r\n Reducer.OnChatMessageEvent += OnChatMessageEvent;\r\n```\r\n\r\nThen 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.\r\n\r\n```csharp\r\n private void OnChatMessageEvent(ReducerEvent dbEvent, string message)\r\n {\r\n var player = PlayerComponent.FilterByOwnerId(dbEvent.Identity);\r\n if (player != null)\r\n {\r\n UIChatController.instance.OnChatMessageReceived(player.Username + \": \" + message);\r\n }\r\n }\r\n```\r\n\r\n7. 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.\r\n\r\n## Conclusion\r\n\r\nThis 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.\r\n\r\n---\r\n\r\n### Troubleshooting\r\n\r\n- 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`\r\n\r\n- If you get this exception when running the project:\r\n\r\n```\r\nNullReferenceException: Object reference not set to an instance of an object\r\nBitcraftMiniGameManager.Start () (at Assets/_Project/Game/BitcraftMiniGameManager.cs:26)\r\n```\r\n\r\nCheck to see if your GameManager object in the Scene has the NetworkManager component attached.\r\n\r\n- 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.\r\n\r\n```\r\nConnection error: Unable to connect to the remote server\r\n```\r\n", "hasPages": false, "editUrl": "index.md", "jumpLinks": [ @@ -252,6 +282,7 @@ export const docsConfig = { "identifier": "Part 2 - Resources And Scheduling", "indexIdentifier": "Part 2 - Resources And Scheduling", "hasPages": false, + "content": "# Part 2 - Resources and Scheduling\r\n\r\nIn 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.\r\n\r\n## Add Resource Node Spawner\r\n\r\nIn this section we will add functionality to our server to spawn the resource nodes.\r\n\r\n### Step 1: Add the SpacetimeDB Tables for Resource Nodes\r\n\r\n1. 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.\r\n\r\n```toml\r\nrand = \"0.8.5\"\r\n```\r\n\r\nWe also need to add the `getrandom` feature to our SpacetimeDB crate. Update the `spacetimedb` line to:\r\n\r\n```toml\r\nspacetimedb = { \"0.5\", features = [\"getrandom\"] }\r\n```\r\n\r\n2. 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.\r\n\r\n```rust\r\n#[derive(SpacetimeType, Clone)]\r\npub enum ResourceNodeType {\r\n Iron,\r\n}\r\n\r\n#[spacetimedb(table)]\r\n#[derive(Clone)]\r\npub struct ResourceNodeComponent {\r\n #[primarykey]\r\n pub entity_id: u64,\r\n\r\n // Resource type of this resource node\r\n pub resource_type: ResourceNodeType,\r\n}\r\n```\r\n\r\nBecause 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.\r\n\r\n```rust\r\n#[spacetimedb(table)]\r\n#[derive(Clone)]\r\npub struct StaticLocationComponent {\r\n #[primarykey]\r\n pub entity_id: u64,\r\n\r\n pub location: StdbVector2,\r\n pub rotation: f32,\r\n}\r\n```\r\n\r\n3. 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.\r\n\r\n```rust\r\n#[spacetimedb(table)]\r\npub struct Config {\r\n // Config is a global table with a single row. This table will be used to\r\n // store configuration or global variables\r\n\r\n #[primarykey]\r\n // always 0\r\n // having a table with a primarykey field which is always zero is a way to store singleton global state\r\n pub version: u32,\r\n\r\n pub message_of_the_day: String,\r\n\r\n // new variables for resource node spawner\r\n // X and Z range of the map (-map_extents to map_extents)\r\n pub map_extents: u32,\r\n // maximum number of resource nodes to spawn on the map\r\n pub num_resource_nodes: u32,\r\n}\r\n```\r\n\r\n4. In the `init` reducer, we need to set the initial values of our two new variables. Update the following code:\r\n\r\n```rust\r\n Config::insert(Config {\r\n version: 0,\r\n message_of_the_day: \"Hello, World!\".to_string(),\r\n\r\n // new variables for resource node spawner\r\n map_extents: 25,\r\n num_resource_nodes: 10,\r\n })\r\n .expect(\"Failed to insert config.\");\r\n```\r\n\r\n### Step 2: Write our Resource Spawner Repeating Reducer\r\n\r\n1. 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.\r\n\r\n```rust\r\n#[spacetimedb(reducer, repeat = 1000ms)]\r\npub fn resource_spawner_agent(_ctx: ReducerContext, _prev_time: Timestamp) -> Result<(), String> {\r\n let config = Config::filter_by_version(&0).unwrap();\r\n\r\n // Retrieve the maximum number of nodes we want to spawn from the Config table\r\n let num_resource_nodes = config.num_resource_nodes as usize;\r\n\r\n // Count the number of nodes currently spawned and exit if we have reached num_resource_nodes\r\n let num_resource_nodes_spawned = ResourceNodeComponent::iter().count();\r\n if num_resource_nodes_spawned >= num_resource_nodes {\r\n log::info!(\"All resource nodes spawned. Skipping.\");\r\n return Ok(());\r\n }\r\n\r\n // Pick a random X and Z based off the map_extents\r\n let mut rng = rand::thread_rng();\r\n let map_extents = config.map_extents as f32;\r\n let location = StdbVector2 {\r\n x: rng.gen_range(-map_extents..map_extents),\r\n z: rng.gen_range(-map_extents..map_extents),\r\n };\r\n // Pick a random Y rotation in degrees\r\n let rotation = rng.gen_range(0.0..360.0);\r\n\r\n // Insert our SpawnableEntityComponent which assigns us our entity_id\r\n let entity_id = SpawnableEntityComponent::insert(SpawnableEntityComponent { entity_id: 0 })\r\n .expect(\"Failed to create resource spawnable entity component.\")\r\n .entity_id;\r\n\r\n // Insert our static location with the random position and rotation we selected\r\n StaticLocationComponent::insert(StaticLocationComponent {\r\n entity_id,\r\n location: location.clone(),\r\n rotation,\r\n })\r\n .expect(\"Failed to insert resource static location component.\");\r\n\r\n // Insert our resource node component, so far we only have iron\r\n ResourceNodeComponent::insert(ResourceNodeComponent {\r\n entity_id,\r\n resource_type: ResourceNodeType::Iron,\r\n })\r\n .expect(\"Failed to insert resource node component.\");\r\n\r\n // Log that we spawned a node with the entity_id and location\r\n log::info!(\r\n \"Resource node spawned: {} at ({}, {})\",\r\n entity_id,\r\n location.x,\r\n location.z,\r\n );\r\n\r\n Ok(())\r\n}\r\n```\r\n\r\n2. Since this reducer uses `rand::Rng` we need add include it. Add this `use` statement to the top of lib.rs.\r\n\r\n```rust\r\nuse rand::Rng;\r\n```\r\n\r\n3. 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.\r\n\r\n```rust\r\n // Start our resource spawner repeating reducer\r\n spacetimedb::schedule!(\"1000ms\", resource_spawner_agent(_, Timestamp::now()));\r\n```\r\n\r\n4. 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:\r\n\r\n```bash\r\nspacetime generate --out-dir ../Assets/autogen --lang=csharp\r\n\r\nspacetime publish -c yourname/bitcraftmini\r\n```\r\n\r\nYour 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.\r\n\r\n```bash\r\nspacetime logs -f yourname/bitcraftmini\r\n```\r\n\r\n### Step 3: Spawn the Resource Nodes on the Client\r\n\r\n1. 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`.\r\n\r\n```csharp\r\n public ulong EntityId;\r\n\r\n public ResourceNodeType Type = ResourceNodeType.Iron;\r\n```\r\n\r\n2. 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.\r\n\r\n```csharp\r\n var resourceType = res?.Type ?? ResourceNodeType.Iron;\r\n switch (resourceType)\r\n {\r\n case ResourceNodeType.Iron:\r\n _animator.SetTrigger(\"Mine\");\r\n Interacting = true;\r\n break;\r\n default:\r\n Interacting = false;\r\n break;\r\n }\r\n for (int i = 0; i < _tools.Length; i++)\r\n {\r\n _tools[i].SetActive(((int)resourceType) == i);\r\n }\r\n _target = res;\r\n```\r\n\r\n3. 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:\r\n\r\n```csharp\r\n SpacetimeDBClient.instance.Subscribe(new List()\r\n {\r\n \"SELECT * FROM Config\",\r\n \"SELECT * FROM SpawnableEntityComponent\",\r\n \"SELECT * FROM PlayerComponent\",\r\n \"SELECT * FROM MobileEntityComponent\",\r\n // Our new tables for part 2 of the tutorial\r\n \"SELECT * FROM ResourceNodeComponent\",\r\n \"SELECT * FROM StaticLocationComponent\"\r\n });\r\n```\r\n\r\n4. Next let's add an `OnInsert` handler for the `ResourceNodeComponent`. Add the following line to the `Start` function.\r\n\r\n```csharp\r\n ResourceNodeComponent.OnInsert += ResourceNodeComponent_OnInsert;\r\n```\r\n\r\n5. 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.\r\n\r\nTo get the position and the rotation of the node, we look up the `StaticLocationComponent` for this entity by using the EntityId.\r\n\r\n```csharp\r\n private void ResourceNodeComponent_OnInsert(ResourceNodeComponent insertedValue, ReducerEvent callInfo)\r\n {\r\n switch(insertedValue.ResourceType)\r\n {\r\n case ResourceNodeType.Iron:\r\n var iron = Instantiate(IronPrefab);\r\n StaticLocationComponent loc = StaticLocationComponent.FilterByEntityId(insertedValue.EntityId);\r\n Vector3 nodePos = new Vector3(loc.Location.X, 0.0f, loc.Location.Z);\r\n iron.transform.position = new Vector3(nodePos.x, MathUtil.GetTerrainHeight(nodePos), nodePos.z);\r\n iron.transform.rotation = Quaternion.Euler(0.0f, loc.Rotation, 0.0f);\r\n break;\r\n }\r\n }\r\n```\r\n\r\n### Step 4: Play the Game!\r\n\r\n6. Hit Play in the Unity Editor and you should now see your resource nodes spawning in the world!\r\n", "editUrl": "Part%202%20-%20Resources%20And%20Scheduling.md", "jumpLinks": [ { @@ -292,6 +323,7 @@ export const docsConfig = { "identifier": "Part 3 - BitCraft Mini", "indexIdentifier": "Part 3 - BitCraft Mini", "hasPages": false, + "content": "# Part 3 - BitCraft Mini\r\n\r\nBitCraft 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.\r\n\r\n## 1. Download\r\n\r\nYou can git-clone BitCraftMini from here:\r\n\r\n```plaintext\r\ngit clone ssh://git@github.com/clockworklabs/BitCraftMini\r\n```\r\n\r\nOnce you have downloaded BitCraftMini, you will need to compile the spacetime module.\r\n\r\n## 2. Compile the Spacetime Module\r\n\r\nIn order to compile the BitCraftMini module, you will need to install cargo. You can install cargo from here:\r\n\r\n> https://www.rust-lang.org/tools/install\r\n\r\nOnce you have cargo installed, you can compile and publish the module with these commands:\r\n\r\n```bash\r\ncd BitCraftMini/Server\r\nspacetime publish\r\n```\r\n\r\n`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:\r\n\r\n```plaintext\r\n$ spacetime publish\r\ninfo: component 'rust-std' for target 'wasm32-unknown-unknown' is up to date\r\n Finished release [optimized] target(s) in 0.03s\r\nPublish finished successfully.\r\nCreated new database with address: c91c17ecdcea8a05302be2bad9dd59b3\r\n```\r\n\r\nOptionally, you can specify a name when you publish the module:\r\n\r\n```bash\r\nspacetime publish \"unique-module-name\"\r\n```\r\n\r\nCurrently, 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.\r\n\r\nIn 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:\r\n\r\n```bash\r\nspacetime call \"\" \"initialize\" \"[]\"\r\n```\r\n\r\nHere 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.\r\n\r\nAfter you have called `initialize()` on the spacetime module you shouldgenerate the client files:\r\n\r\n```bash\r\nspacetime generate --out-dir ../Client/Assets/_Project/autogen --lang=cs\r\n```\r\n\r\nHere is some sample output:\r\n\r\n```plaintext\r\n$ spacetime generate --out-dir ../Client/Assets/_Project/autogen --lang cs\r\ninfo: component 'rust-std' for target 'wasm32-unknown-unknown' is up to date\r\n Finished release [optimized] target(s) in 0.03s\r\ncompilation took 234.613518ms\r\nGenerate finished successfully.\r\n```\r\n\r\nIf you've gotten this message then everything should be working properly so far.\r\n\r\n## 3. Replace address in BitCraftMiniGameManager\r\n\r\nThe following settings are exposed in the `BitCraftMiniGameManager` inspector: Module Address, Host Name, and SSL Enabled.\r\n\r\nOpen the Main scene in Unity and click on the `GameManager` object in the heirarchy. The inspector window will look like this:\r\n\r\n![GameManager-Inspector](/images/unity-tutorial/GameManager-Inspector.JPG)\r\n\r\nUpdate 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)\r\n\r\n## 4. Play Mode\r\n\r\nYou 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.\r\n\r\n## 5. Editing the Module\r\n\r\nIf you want to make further updates to the module, make sure to use this publish command instead:\r\n\r\n```bash\r\nspacetime publish \r\n```\r\n\r\nWhere `` is your own address. If you do this instead then you won't have to change the address inside of `BitCraftMiniGameManager.cs`\r\n\r\nWhen you change the server module you should also regenerate the client files as well:\r\n\r\n```bash\r\nspacetime generate --out-dir ../Client/Assets/_Project/autogen --lang=cs\r\n```\r\n\r\nYou may want to consider putting these 2 commands into a simple shell script to make the process a bit cleaner.\r\n", "editUrl": "Part%203%20-%20BitCraft%20Mini.md", "jumpLinks": [ { @@ -327,7 +359,17 @@ export const docsConfig = { ], "pages": [] } - ] + ], + "previousKey": { + "title": "Cloud Testnet", + "route": "index", + "depth": 1 + }, + "nextKey": { + "title": "Server Module Languages", + "route": "index", + "depth": 1 + } }, { "title": "Server Module Languages", @@ -352,6 +394,7 @@ export const docsConfig = { "title": "C# Module Quickstart", "identifier": "index", "indexIdentifier": "index", + "content": "# C# Module Quickstart\r\n\r\nIn this tutorial, we'll implement a simple chat server as a SpacetimeDB module.\r\n\r\nA 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.\r\n\r\nEach SpacetimeDB module defines a set of tables and a set of reducers.\r\n\r\nEach table is defined as a C# `class` annotated with `[SpacetimeDB.Table]`, where an instance represents a row, and each field represents a column.\r\n\r\nA 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.\r\n\r\n## Install SpacetimeDB\r\n\r\nIf 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.\r\n\r\n## Install .NET\r\n\r\nNext we need to [install .NET](https://dotnet.microsoft.com/en-us/download/dotnet) so that we can build and publish our module.\r\n\r\n## Project structure\r\n\r\nCreate and enter a directory `quickstart-chat`:\r\n\r\n```bash\r\nmkdir quickstart-chat\r\ncd quickstart-chat\r\n```\r\n\r\nNow create `server`, our module, which runs in the database:\r\n\r\n```bash\r\nspacetime init --lang csharp server\r\n```\r\n\r\n## Declare imports\r\n\r\n`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.\r\n\r\nTo the top of `server/Lib.cs`, add some imports we'll be using:\r\n\r\n```C#\r\nusing System.Runtime.CompilerServices;\r\nusing SpacetimeDB.Module;\r\nusing static SpacetimeDB.Runtime;\r\n```\r\n\r\n- `System.Runtime.CompilerServices` allows us to use the `ModuleInitializer` attribute, which we'll use to register our `OnConnect` and `OnDisconnect` callbacks.\r\n- `SpacetimeDB.Module` contains the special attributes we'll use to define our module.\r\n- `SpacetimeDB.Runtime` contains the raw API bindings SpacetimeDB uses to communicate with the database.\r\n\r\nWe also need to create our static module class which all of the module code will live in. In `server/Lib.cs`, add:\r\n\r\n```csharp\r\nstatic partial class Module\r\n{\r\n}\r\n```\r\n\r\n## Define tables\r\n\r\nTo 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.\r\n\r\nFor 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.\r\n\r\nIn `server/Lib.cs`, add the definition of the table `User` to the `Module` class:\r\n\r\n```C#\r\n [SpacetimeDB.Table]\r\n public partial class User\r\n {\r\n [SpacetimeDB.Column(ColumnAttrs.PrimaryKey)]\r\n public Identity Identity;\r\n public string? Name;\r\n public bool Online;\r\n }\r\n```\r\n\r\nFor 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.\r\n\r\nIn `server/Lib.cs`, add the definition of the table `Message` to the `Module` class:\r\n\r\n```C#\r\n [SpacetimeDB.Table]\r\n public partial class Message\r\n {\r\n public Identity Sender;\r\n public long Sent;\r\n public string Text = \"\";\r\n }\r\n```\r\n\r\n## Set users' names\r\n\r\nWe 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.\r\n\r\nEach 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`.\r\n\r\nIt'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.\r\n\r\nIn `server/Lib.cs`, add to the `Module` class:\r\n\r\n```C#\r\n [SpacetimeDB.Reducer]\r\n public static void SetName(DbEventArgs dbEvent, string name)\r\n {\r\n name = ValidateName(name);\r\n\r\n var user = User.FindByIdentity(dbEvent.Sender);\r\n if (user is not null)\r\n {\r\n user.Name = name;\r\n User.UpdateByIdentity(dbEvent.Sender, user);\r\n }\r\n }\r\n```\r\n\r\nFor now, we'll just do a bare minimum of validation, rejecting the empty name. You could extend this in various ways, like:\r\n\r\n- Comparing against a blacklist for moderation purposes.\r\n- Unicode-normalizing names.\r\n- Rejecting names that contain non-printable characters, or removing characters or replacing them with a placeholder.\r\n- Rejecting or truncating long names.\r\n- Rejecting duplicate names.\r\n\r\nIn `server/Lib.cs`, add to the `Module` class:\r\n\r\n```C#\r\n /// Takes a name and checks if it's acceptable as a user's name.\r\n public static string ValidateName(string name)\r\n {\r\n if (string.IsNullOrEmpty(name))\r\n {\r\n throw new Exception(\"Names must not be empty\");\r\n }\r\n return name;\r\n }\r\n```\r\n\r\n## Send messages\r\n\r\nWe 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`.\r\n\r\nIn `server/Lib.cs`, add to the `Module` class:\r\n\r\n```C#\r\n [SpacetimeDB.Reducer]\r\n public static void SendMessage(DbEventArgs dbEvent, string text)\r\n {\r\n text = ValidateMessage(text);\r\n Log(text);\r\n new Message\r\n {\r\n Sender = dbEvent.Sender,\r\n Text = text,\r\n Sent = dbEvent.Time.ToUnixTimeMilliseconds(),\r\n }.Insert();\r\n }\r\n```\r\n\r\nWe'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.\r\n\r\nIn `server/Lib.cs`, add to the `Module` class:\r\n\r\n```C#\r\n /// Takes a message's text and checks if it's acceptable to send.\r\n public static string ValidateMessage(string text)\r\n {\r\n if (string.IsNullOrEmpty(text))\r\n {\r\n throw new ArgumentException(\"Messages must not be empty\");\r\n }\r\n return text;\r\n }\r\n```\r\n\r\nYou could extend the validation in `ValidateMessage` in similar ways to `ValidateName`, or add additional checks to `SendMessage`, like:\r\n\r\n- Rejecting messages from senders who haven't set their names.\r\n- Rate-limiting users so they can't send new messages too quickly.\r\n\r\n## Set users' online status\r\n\r\nIn 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.\r\n\r\nWe'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`.\r\n\r\nIn `server/Lib.cs`, add the definition of the connect reducer to the `Module` class:\r\n\r\n```C#\r\n [ModuleInitializer]\r\n public static void Init()\r\n {\r\n OnConnect += (dbEventArgs) =>\r\n {\r\n Log($\"Connect {dbEventArgs.Sender}\");\r\n var user = User.FindByIdentity(dbEventArgs.Sender);\r\n\r\n if (user is not null)\r\n {\r\n // If this is a returning user, i.e., we already have a `User` with this `Identity`,\r\n // set `Online: true`, but leave `Name` and `Identity` unchanged.\r\n user.Online = true;\r\n User.UpdateByIdentity(dbEventArgs.Sender, user);\r\n }\r\n else\r\n {\r\n // If this is a new user, create a `User` object for the `Identity`,\r\n // which is online, but hasn't set a name.\r\n new User\r\n {\r\n Name = null,\r\n Identity = dbEventArgs.Sender,\r\n Online = true,\r\n }.Insert();\r\n }\r\n };\r\n }\r\n```\r\n\r\nSimilarly, 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.\r\n\r\nAdd the following code after the `OnConnect` lambda:\r\n\r\n```C#\r\n OnDisconnect += (dbEventArgs) =>\r\n {\r\n var user = User.FindByIdentity(dbEventArgs.Sender);\r\n\r\n if (user is not null)\r\n {\r\n // This user should exist, so set `Online: false`.\r\n user.Online = false;\r\n User.UpdateByIdentity(dbEventArgs.Sender, user);\r\n }\r\n else\r\n {\r\n // User does not exist, log warning\r\n Log($\"Warning: No user found for disconnected client.\");\r\n }\r\n };\r\n```\r\n\r\n## Publish the module\r\n\r\nAnd 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 ``.\r\n\r\nFrom the `quickstart-chat` directory, run:\r\n\r\n```bash\r\nspacetime publish --project-path server \r\n```\r\n\r\n## Call Reducers\r\n\r\nYou can use the CLI (command line interface) to run reducers. The arguments to the reducer are passed in JSON format.\r\n\r\n```bash\r\nspacetime call send_message '[\"Hello, World!\"]'\r\n```\r\n\r\nOnce we've called our `send_message` reducer, we can check to make sure it ran by running the `logs` command.\r\n\r\n```bash\r\nspacetime logs \r\n```\r\n\r\nYou should now see the output that your module printed in the database.\r\n\r\n```bash\r\ninfo: Hello, World!\r\n```\r\n\r\n## SQL Queries\r\n\r\nSpacetimeDB 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.\r\n\r\n```bash\r\nspacetime sql \"SELECT * FROM Message\"\r\n```\r\n\r\n```bash\r\n text\r\n---------\r\n \"Hello, World!\"\r\n```\r\n\r\n## What's next?\r\n\r\nYou'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).\r\n\r\nIf 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).\r\n", "hasPages": false, "editUrl": "index.md", "jumpLinks": [ @@ -428,6 +471,7 @@ export const docsConfig = { "identifier": "ModuleReference", "indexIdentifier": "ModuleReference", "hasPages": false, + "content": "# SpacetimeDB C# Modules\r\n\r\nYou can use the [C# SpacetimeDB library](https://github.com/clockworklabs/SpacetimeDBLibCSharp) to write modules in C# which interact with the SpacetimeDB database.\r\n\r\nIt 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.\r\n\r\n## Example\r\n\r\nLet's start with a heavily commented version of the default example from the landing page:\r\n\r\n```csharp\r\n// These imports bring into the scope common APIs you'll need to expose items from your module and to interact with the database runtime.\r\nusing SpacetimeDB.Module;\r\nusing static SpacetimeDB.Runtime;\r\n\r\n// Roslyn generators are statically generating extra code as-if they were part of the source tree, so,\r\n// in order to inject new methods, types they operate on as well as their parents have to be marked as `partial`.\r\n//\r\n// We start with the top-level `Module` class for the module itself.\r\nstatic partial class Module\r\n{\r\n // `[SpacetimeDB.Table]` registers a struct or a class as a SpacetimeDB table.\r\n //\r\n // It generates methods to insert, filter, update, and delete rows of the given type in the table.\r\n [SpacetimeDB.Table]\r\n public partial struct Person\r\n {\r\n // `[SpacetimeDB.Column]` allows to specify column attributes / constraints such as\r\n // \"this field should be unique\" or \"this field should get automatically assigned auto-incremented value\".\r\n [SpacetimeDB.Column(ColumnAttrs.Unique | ColumnAttrs.AutoInc)]\r\n public int Id;\r\n public string Name;\r\n public int Age;\r\n }\r\n\r\n // `[SpacetimeDB.Reducer]` marks a static method as a SpacetimeDB reducer.\r\n //\r\n // Reducers are functions that can be invoked from the database runtime.\r\n // They can't return values, but can throw errors that will be caught and reported back to the runtime.\r\n [SpacetimeDB.Reducer]\r\n public static void Add(string name, int age)\r\n {\r\n // We can skip (or explicitly set to zero) auto-incremented fields when creating new rows.\r\n var person = new Person { Name = name, Age = age };\r\n // `Insert()` method is auto-generated and will insert the given row into the table.\r\n person.Insert();\r\n // After insertion, the auto-incremented fields will be populated with their actual values.\r\n //\r\n // `Log()` function is provided by the runtime and will print the message to the database log.\r\n // It should be used instead of `Console.WriteLine()` or similar functions.\r\n Log($\"Inserted {person.Name} under #{person.Id}\");\r\n }\r\n\r\n [SpacetimeDB.Reducer]\r\n public static void SayHello()\r\n {\r\n // Each table type gets a static Iter() method that can be used to iterate over the entire table.\r\n foreach (var person in Person.Iter())\r\n {\r\n Log($\"Hello, {person.Name}!\");\r\n }\r\n Log(\"Hello, World!\");\r\n }\r\n}\r\n```\r\n\r\n## API reference\r\n\r\nNow we'll get into details on all the APIs SpacetimeDB provides for writing modules in C#.\r\n\r\n### Logging\r\n\r\nFirst of all, logging as we're likely going to use it a lot for debugging and reporting errors.\r\n\r\n`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.\r\n\r\nSupported log levels are provided by the `LogLevel` enum:\r\n\r\n```csharp\r\npublic enum LogLevel\r\n{\r\n Error,\r\n Warn,\r\n Info,\r\n Debug,\r\n Trace,\r\n Panic\r\n}\r\n```\r\n\r\nIf omitted, the log level will default to `Info`, so these two forms are equivalent:\r\n\r\n```csharp\r\nLog(\"Hello, World!\");\r\nLog(\"Hello, World!\", LogLevel.Info);\r\n```\r\n\r\n### Supported types\r\n\r\n#### Built-in types\r\n\r\nThe following types are supported out of the box and can be stored in the database tables directly or as part of more complex types:\r\n\r\n- `bool`\r\n- `byte`, `sbyte`\r\n- `short`, `ushort`\r\n- `int`, `uint`\r\n- `long`, `ulong`\r\n- `float`, `double`\r\n- `string`\r\n- [`Int128`](https://learn.microsoft.com/en-us/dotnet/api/system.int128), [`UInt128`](https://learn.microsoft.com/en-us/dotnet/api/system.uint128)\r\n- `T[]` - arrays of supported values.\r\n- [`List`](https://learn.microsoft.com/en-us/dotnet/api/system.collections.generic.list-1)\r\n- [`Dictionary`](https://learn.microsoft.com/en-us/dotnet/api/system.collections.generic.dictionary-2)\r\n\r\nAnd a couple of special custom types:\r\n\r\n- `SpacetimeDB.SATS.Unit` - semantically equivalent to an empty struct, sometimes useful in generic contexts where C# doesn't permit `void`.\r\n- `Identity` (`SpacetimeDB.Runtime.Identity`) - a unique identifier for each connected client; internally a byte blob but can be printed, hashed and compared for equality.\r\n\r\n#### Custom types\r\n\r\n`[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.\r\n\r\nAny `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.\r\n\r\n```csharp\r\n[SpacetimeDB.Type]\r\npublic partial struct Point\r\n{\r\n public int x;\r\n public int y;\r\n}\r\n```\r\n\r\n`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.\r\n\r\n```csharp\r\n[SpacetimeDB.Type]\r\npublic enum Color\r\n{\r\n Red,\r\n Green,\r\n Blue,\r\n}\r\n```\r\n\r\n#### Tagged enums\r\n\r\nSpacetimeDB has support for tagged enums which can be found in languages like Rust, but not C#.\r\n\r\nTo 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.\r\n\r\nIt 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.\r\n\r\n```csharp\r\n// Example declaration:\r\n[SpacetimeDB.Type]\r\npartial struct Option : SpacetimeDB.TaggedEnum<(T Some, Unit None)> { }\r\n\r\n// Usage:\r\nvar option = new Option { Some = 42 };\r\nif (option.IsSome)\r\n{\r\n Log($\"Value: {option.Some}\");\r\n}\r\n```\r\n\r\n### Tables\r\n\r\n`[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.\r\n\r\nIt implies `[SpacetimeDB.Type]`, so you must not specify both attributes on the same type.\r\n\r\n```csharp\r\n[SpacetimeDB.Table]\r\npublic partial struct Person\r\n{\r\n [SpacetimeDB.Column(ColumnAttrs.Unique | ColumnAttrs.AutoInc)]\r\n public int Id;\r\n public string Name;\r\n public int Age;\r\n}\r\n```\r\n\r\nThe example above will generate the following extra methods:\r\n\r\n```csharp\r\npublic partial struct Person\r\n{\r\n // Inserts current instance as a new row into the table.\r\n public void Insert();\r\n\r\n // Returns an iterator over all rows in the table, e.g.:\r\n // `for (var person in Person.Iter()) { ... }`\r\n public static IEnumerable Iter();\r\n\r\n // Returns an iterator over all rows in the table that match the given filter, e.g.:\r\n // `for (var person in Person.Query(p => p.Age >= 18)) { ... }`\r\n public static IEnumerable Query(Expression> filter);\r\n\r\n // Generated for each column:\r\n\r\n // Returns an iterator over all rows in the table that have the given value in the `Name` column.\r\n public static IEnumerable FilterByName(string name);\r\n public static IEnumerable FilterByAge(int age);\r\n\r\n // Generated for each unique column:\r\n\r\n // Finds a row in the table with the given value in the `Id` column and returns it, or `null` if no such row exists.\r\n public static Person? FindById(int id);\r\n // 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.\r\n public static bool DeleteById(int id);\r\n // 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.\r\n public static bool UpdateById(int oldId, Person newValue);\r\n}\r\n```\r\n\r\n#### Column attributes\r\n\r\nAttribute `[SpacetimeDB.Column]` can be used on any field of a `SpacetimeDB.Table`-marked `struct` or `class` to customize column attributes as seen above.\r\n\r\nThe supported column attributes are:\r\n\r\n- `ColumnAttrs.AutoInc` - this column should be auto-incremented.\r\n- `ColumnAttrs.Unique` - this column should be unique.\r\n- `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.\r\n\r\nThese attributes are bitflags and can be combined together, but you can also use some predefined shortcut aliases:\r\n\r\n- `ColumnAttrs.Identity` - same as `ColumnAttrs.Unique | ColumnAttrs.AutoInc`.\r\n- `ColumnAttrs.PrimaryKeyAuto` - same as `ColumnAttrs.PrimaryKey | ColumnAttrs.AutoInc`.\r\n\r\n### Reducers\r\n\r\nAttribute `[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.\r\n\r\n```csharp\r\n[SpacetimeDB.Reducer]\r\npublic static void Add(string name, int age)\r\n{\r\n var person = new Person { Name = name, Age = age };\r\n person.Insert();\r\n Log($\"Inserted {person.Name} under #{person.Id}\");\r\n}\r\n```\r\n\r\nIf 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:\r\n\r\n```csharp\r\n[SpacetimeDB.Reducer]\r\npublic static void PrintInfo(DbEventArgs e)\r\n{\r\n Log($\"Sender: {e.Sender}\");\r\n Log($\"Time: {e.Time}\");\r\n}\r\n```\r\n\r\n`[SpacetimeDB.Reducer]` also generates a function to schedule the given reducer in the future.\r\n\r\nSince 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`.\r\n\r\n```csharp\r\n// Example reducer:\r\n[SpacetimeDB.Reducer]\r\npublic static void Add(string name, int age) { ... }\r\n\r\n// Auto-generated by the codegen:\r\npublic static void ScheduleAdd(DateTimeOffset time, string name, int age) { ... }\r\n\r\n// Usage from another reducer:\r\n[SpacetimeDB.Reducer]\r\npublic static void AddIn5Minutes(DbEventArgs e, string name, int age)\r\n{\r\n // Note that we're using `e.Time` instead of `DateTimeOffset.Now` which is not allowed in modules.\r\n var scheduleToken = ScheduleAdd(e.Time.AddMinutes(5), name, age);\r\n\r\n // We can cancel the scheduled reducer by calling `Cancel()` on the returned token.\r\n scheduleToken.Cancel();\r\n}\r\n```\r\n\r\n#### Special reducers\r\n\r\nThese 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:\r\n\r\n- `ReducerKind.Init` - this reducer will be invoked when the module is first published.\r\n- `ReducerKind.Update` - this reducer will be invoked when the module is updated.\r\n\r\nExample:\r\n\r\n```csharp\r\n[SpacetimeDB.Reducer(ReducerKind.Init)]\r\npublic static void Init()\r\n{\r\n Log(\"...and we're live!\");\r\n}\r\n```\r\n\r\n### Connection events\r\n\r\n`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.\r\n\r\n```csharp\r\n[SpacetimeDB.Reducer(ReducerKind.Init)]\r\npublic static void Init()\r\n{\r\n OnConnect += (e) => Log($\"Client {e.Sender} connected!\");\r\n OnDisconnect += (e) => Log($\"Client {e.Sender} disconnected!\");\r\n}\r\n```\r\n", "editUrl": "ModuleReference.md", "jumpLinks": [ { @@ -504,6 +548,7 @@ export const docsConfig = { "title": "Server Module Overview", "identifier": "index", "indexIdentifier": "index", + "content": "# Server Module Overview\r\n\r\nServer modules are the core of a SpacetimeDB application. They define the structure of the database and the server-side logic that processes and handles client requests. These functions are called reducers and are transactional, meaning they ensure data consistency and integrity. Reducers can perform operations such as inserting, updating, and deleting data in the database.\r\n\r\nIn the following sections, we'll cover the basics of server modules and how to create and deploy them.\r\n\r\n## Supported Languages\r\n\r\n### Rust\r\n\r\nAs 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.\r\n\r\n- [Rust Module Reference](/docs/server-languages/rust/rust-module-reference)\r\n- [Rust Module Quickstart Guide](/docs/server-languages/rust/rust-module-quickstart-guide)\r\n\r\n### C#\r\n\r\nWe 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.\r\n\r\n- [C# Module Reference](/docs/server-languages/csharp/csharp-module-reference)\r\n- [C# Module Quickstart Guide](/docs/server-languages/csharp/csharp-module-quickstart-guide)\r\n\r\n### Coming Soon\r\n\r\nWe have plans to support additional languages in the future.\r\n\r\n- Python\r\n- Typescript\r\n- C++\r\n- Lua\r\n", "hasPages": false, "editUrl": "index.md", "jumpLinks": [ @@ -548,6 +593,7 @@ export const docsConfig = { "title": "Rust Module Quickstart", "identifier": "index", "indexIdentifier": "index", + "content": "# Rust Module Quickstart\r\n\r\nIn this tutorial, we'll implement a simple chat server as a SpacetimeDB module.\r\n\r\nA 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.\r\n\r\nEach SpacetimeDB module defines a set of tables and a set of reducers.\r\n\r\nEach table is defined as a Rust `struct` annotated with `#[spacetimedb(table)]`, where an instance represents a row, and each field represents a column.\r\n\r\nA 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.\r\n\r\n## Install SpacetimeDB\r\n\r\nIf 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.\r\n\r\n## Install Rust\r\n\r\nNext we need to [install Rust](https://www.rust-lang.org/tools/install) so that we can create our database module.\r\n\r\nOn MacOS and Linux run this command to install the Rust compiler:\r\n\r\n```bash\r\ncurl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh\r\n```\r\n\r\nIf you're on Windows, go [here](https://learn.microsoft.com/en-us/windows/dev-environment/rust/setup).\r\n\r\n## Project structure\r\n\r\nCreate and enter a directory `quickstart-chat`:\r\n\r\n```bash\r\nmkdir quickstart-chat\r\ncd quickstart-chat\r\n```\r\n\r\nNow create `server`, our module, which runs in the database:\r\n\r\n```bash\r\nspacetime init --lang rust server\r\n```\r\n\r\n## Declare imports\r\n\r\n`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.\r\n\r\nTo the top of `server/src/lib.rs`, add some imports we'll be using:\r\n\r\n```rust\r\nuse spacetimedb::{spacetimedb, ReducerContext, Identity, Timestamp};\r\n```\r\n\r\nFrom `spacetimedb`, we import:\r\n\r\n- `spacetimedb`, an attribute macro we'll use to define tables and reducers.\r\n- `ReducerContext`, a special argument passed to each reducer.\r\n- `Identity`, a unique identifier for each connected client.\r\n- `Timestamp`, a point in time. Specifically, an unsigned 64-bit count of milliseconds since the UNIX epoch.\r\n\r\n## Define tables\r\n\r\nTo 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.\r\n\r\nFor 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.\r\n\r\nTo `server/src/lib.rs`, add the definition of the table `User`:\r\n\r\n```rust\r\n#[spacetimedb(table)]\r\npub struct User {\r\n #[primarykey]\r\n identity: Identity,\r\n name: Option,\r\n online: bool,\r\n}\r\n```\r\n\r\nFor 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.\r\n\r\nTo `server/src/lib.rs`, add the definition of the table `Message`:\r\n\r\n```rust\r\n#[spacetimedb(table)]\r\npub struct Message {\r\n sender: Identity,\r\n sent: Timestamp,\r\n text: String,\r\n}\r\n```\r\n\r\n## Set users' names\r\n\r\nWe 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.\r\n\r\nEach 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`.\r\n\r\nIt'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.\r\n\r\nTo `server/src/lib.rs`, add:\r\n\r\n```rust\r\n#[spacetimedb(reducer)]\r\n/// Clientss invoke this reducer to set their user names.\r\npub fn set_name(ctx: ReducerContext, name: String) -> Result<(), String> {\r\n let name = validate_name(name)?;\r\n if let Some(user) = User::filter_by_identity(&ctx.sender) {\r\n User::update_by_identity(&ctx.sender, User { name: Some(name), ..user });\r\n Ok(())\r\n } else {\r\n Err(\"Cannot set name for unknown user\".to_string())\r\n }\r\n}\r\n```\r\n\r\nFor now, we'll just do a bare minimum of validation, rejecting the empty name. You could extend this in various ways, like:\r\n\r\n- Comparing against a blacklist for moderation purposes.\r\n- Unicode-normalizing names.\r\n- Rejecting names that contain non-printable characters, or removing characters or replacing them with a placeholder.\r\n- Rejecting or truncating long names.\r\n- Rejecting duplicate names.\r\n\r\nTo `server/src/lib.rs`, add:\r\n\r\n```rust\r\n/// Takes a name and checks if it's acceptable as a user's name.\r\nfn validate_name(name: String) -> Result {\r\n if name.is_empty() {\r\n Err(\"Names must not be empty\".to_string())\r\n } else {\r\n Ok(name)\r\n }\r\n}\r\n```\r\n\r\n## Send messages\r\n\r\nWe 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`.\r\n\r\nTo `server/src/lib.rs`, add:\r\n\r\n```rust\r\n#[spacetimedb(reducer)]\r\n/// Clients invoke this reducer to send messages.\r\npub fn send_message(ctx: ReducerContext, text: String) -> Result<(), String> {\r\n let text = validate_message(text)?;\r\n log::info!(\"{}\", text);\r\n Message::insert(Message {\r\n sender: ctx.sender,\r\n text,\r\n sent: ctx.timestamp,\r\n });\r\n Ok(())\r\n}\r\n```\r\n\r\nWe'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.\r\n\r\nTo `server/src/lib.rs`, add:\r\n\r\n```rust\r\n/// Takes a message's text and checks if it's acceptable to send.\r\nfn validate_message(text: String) -> Result {\r\n if text.is_empty() {\r\n Err(\"Messages must not be empty\".to_string())\r\n } else {\r\n Ok(text)\r\n }\r\n}\r\n```\r\n\r\nYou could extend the validation in `validate_message` in similar ways to `validate_name`, or add additional checks to `send_message`, like:\r\n\r\n- Rejecting messages from senders who haven't set their names.\r\n- Rate-limiting users so they can't send new messages too quickly.\r\n\r\n## Set users' online status\r\n\r\nWhenever 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.\r\n\r\nWe'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`.\r\n\r\nTo `server/src/lib.rs`, add the definition of the connect reducer:\r\n\r\n```rust\r\n#[spacetimedb(connect)]\r\n// Called when a client connects to the SpacetimeDB\r\npub fn identity_connected(ctx: ReducerContext) {\r\n if let Some(user) = User::filter_by_identity(&ctx.sender) {\r\n // If this is a returning user, i.e. we already have a `User` with this `Identity`,\r\n // set `online: true`, but leave `name` and `identity` unchanged.\r\n User::update_by_identity(&ctx.sender, User { online: true, ..user });\r\n } else {\r\n // If this is a new user, create a `User` row for the `Identity`,\r\n // which is online, but hasn't set a name.\r\n User::insert(User {\r\n name: None,\r\n identity: ctx.sender,\r\n online: true,\r\n }).unwrap();\r\n }\r\n}\r\n```\r\n\r\nSimilarly, 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.\r\n\r\n```rust\r\n#[spacetimedb(disconnect)]\r\n// Called when a client disconnects from SpacetimeDB\r\npub fn identity_disconnected(ctx: ReducerContext) {\r\n if let Some(user) = User::filter_by_identity(&ctx.sender) {\r\n User::update_by_identity(&ctx.sender, User { online: false, ..user });\r\n } else {\r\n // This branch should be unreachable,\r\n // as it doesn't make sense for a client to disconnect without connecting first.\r\n log::warn!(\"Disconnect event for unknown user with identity {:?}\", ctx.sender);\r\n }\r\n}\r\n```\r\n\r\n## Publish the module\r\n\r\nAnd 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 ``.\r\n\r\nFrom the `quickstart-chat` directory, run:\r\n\r\n```bash\r\nspacetime publish --project-path server \r\n```\r\n\r\n## Call Reducers\r\n\r\nYou can use the CLI (command line interface) to run reducers. The arguments to the reducer are passed in JSON format.\r\n\r\n```bash\r\nspacetime call send_message '[\"Hello, World!\"]'\r\n```\r\n\r\nOnce we've called our `send_message` reducer, we can check to make sure it ran by running the `logs` command.\r\n\r\n```bash\r\nspacetime logs \r\n```\r\n\r\nYou should now see the output that your module printed in the database.\r\n\r\n```bash\r\ninfo: Hello, World!\r\n```\r\n\r\n## SQL Queries\r\n\r\nSpacetimeDB 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.\r\n\r\n```bash\r\nspacetime sql \"SELECT * FROM Message\"\r\n```\r\n\r\n```bash\r\n text\r\n---------\r\n \"Hello, World!\"\r\n```\r\n\r\n## What's next?\r\n\r\nYou can find the full code for this module [in the SpacetimeDB module examples](https://github.com/clockworklabs/SpacetimeDB/tree/master/modules/quickstart-chat).\r\n\r\nYou'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).\r\n\r\nIf 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).\r\n", "hasPages": false, "editUrl": "index.md", "jumpLinks": [ @@ -624,6 +670,7 @@ export const docsConfig = { "identifier": "ModuleReference", "indexIdentifier": "ModuleReference", "hasPages": false, + "content": "# SpacetimeDB Rust Modules\r\n\r\nRust clients of SpacetimeDB use the [Rust SpacetimeDB module library][module library] to write modules which interact with the SpacetimeDB database.\r\n\r\nFirst, the `spacetimedb` library provides a number of macros for creating tables and Rust `struct`s corresponding to rows in those tables.\r\n\r\nThen the client API allows interacting with the database inside special functions called reducers.\r\n\r\nThis 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.\r\n\r\nDerive 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.\r\n\r\n```rust\r\n#[derive(Debug)]\r\nstruct Location {\r\n x: u32,\r\n y: u32,\r\n}\r\n```\r\n\r\n## SpacetimeDB Macro basics\r\n\r\nLet'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.\r\n\r\n```rust\r\n// In this small example, we have two rust imports:\r\n// |spacetimedb::spacetimedb| is the most important attribute we'll be using.\r\n// |spacetimedb::println| is like regular old |println|, but outputting to the module's logs.\r\nuse spacetimedb::{spacetimedb, println};\r\n\r\n// This macro lets us interact with a SpacetimeDB table of Person rows.\r\n// We can insert and delete into, and query, this table by the collection\r\n// of functions generated by the macro.\r\n#[spacetimedb(table)]\r\npub struct Person {\r\n name: String,\r\n}\r\n\r\n// This is the other key macro we will be using. A reducer is a\r\n// stored procedure that lives in the database, and which can\r\n// be invoked remotely.\r\n#[spacetimedb(reducer)]\r\npub fn add(name: String) {\r\n // |Person| is a totally ordinary Rust struct. We can construct\r\n // one from the given name as we typically would.\r\n let person = Person { name };\r\n\r\n // Here's our first generated function! Given a |Person| object,\r\n // we can insert it into the table:\r\n Person::insert(person)\r\n}\r\n\r\n// Here's another reducer. Notice that this one doesn't take any arguments, while\r\n// |add| did take one. Reducers can take any number of arguments, as long as\r\n// SpacetimeDB knows about all their types. Reducers also have to be top level\r\n// functions, not methods.\r\n#[spacetimedb(reducer)]\r\npub fn say_hello() {\r\n // Here's the next of our generated functions: |iter()|. This\r\n // iterates over all the columns in the |Person| table in SpacetimeDB.\r\n for person in Person::iter() {\r\n // Reducers run in a very constrained and sandboxed environment,\r\n // and in particular, can't do most I/O from the Rust standard library.\r\n // We provide an alternative |spacetimedb::println| which is just like\r\n // the std version, excepted it is redirected out to the module's logs.\r\n println!(\"Hello, {}!\", person.name);\r\n }\r\n println!(\"Hello, World!\");\r\n}\r\n\r\n// Reducers can't return values, but can return errors. To do so,\r\n// the reducer must have a return type of `Result<(), T>`, for any `T` that\r\n// implements `Debug`. Such errors returned from reducers will be formatted and\r\n// printed out to logs.\r\n#[spacetimedb(reducer)]\r\npub fn add_person(name: String) -> Result<(), String> {\r\n if name.is_empty() {\r\n return Err(\"Name cannot be empty\");\r\n }\r\n\r\n Person::insert(Person { name })\r\n}\r\n```\r\n\r\n## Macro API\r\n\r\nNow we'll get into details on all the macro APIs SpacetimeDB provides, starting with all the variants of the `spacetimedb` attribute.\r\n\r\n### Defining tables\r\n\r\n`#[spacetimedb(table)]` takes no further arguments, and is applied to a Rust struct with named fields:\r\n\r\n```rust\r\n#[spacetimedb(table)]\r\nstruct Table {\r\n field1: String,\r\n field2: u32,\r\n}\r\n```\r\n\r\nThis 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.\r\n\r\nThe 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.\r\n\r\nThis is automatically defined for built in numeric types:\r\n\r\n- `bool`\r\n- `u8`, `u16`, `u32`, `u64`, `u128`\r\n- `i8`, `i16`, `i32`, `i64`, `i128`\r\n- `f32`, `f64`\r\n\r\nAnd common data structures:\r\n\r\n- `String` and `&str`, utf-8 string data\r\n- `()`, the unit type\r\n- `Option where T: SpacetimeType`\r\n- `Vec where T: SpacetimeType`\r\n\r\nAll `#[spacetimedb(table)]` types are `SpacetimeType`s, and accordingly, all of their fields have to be.\r\n\r\n```rust\r\n#[spacetimedb(table)]\r\nstruct AnotherTable {\r\n // Fine, some builtin types.\r\n id: u64,\r\n name: Option,\r\n\r\n // Fine, another table type.\r\n table: Table,\r\n\r\n // Fine, another type we explicitly make serializable.\r\n serial: Serial,\r\n}\r\n```\r\n\r\nIf 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.\r\n\r\nWe can derive `SpacetimeType` on `struct`s and `enum`s with members that are themselves `SpacetimeType`s.\r\n\r\n```rust\r\n#[derive(SpacetimeType)]\r\nenum Serial {\r\n Builtin(f64),\r\n Compound {\r\n s: String,\r\n bs: Vec,\r\n }\r\n}\r\n```\r\n\r\nOnce 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.\r\n\r\n```rust\r\n#[spacetimedb(table)]\r\nstruct Person {\r\n #[unique]\r\n id: u64,\r\n\r\n name: String,\r\n address: String,\r\n}\r\n```\r\n\r\n### Defining reducers\r\n\r\n`#[spacetimedb(reducer)]` optionally takes a single argument, which is a frequency at which the reducer will be automatically called by the database.\r\n\r\n`#[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>`.\r\n\r\n```rust\r\n#[spacetimedb(reducer)]\r\nfn give_player_item(player_id: u64, item_id: u64) -> Result<(), GameErr> {\r\n // Notice how the exact name of the filter function derives from\r\n // the name of the field of the struct.\r\n let mut item = Item::filter_by_item_id(id).ok_or(GameErr::InvalidId)?;\r\n item.owner = Some(player_id);\r\n Item::update_by_id(id, item);\r\n Ok(())\r\n}\r\n\r\nstruct Item {\r\n #[unique]\r\n item_id: u64,\r\n\r\n owner: Option,\r\n}\r\n```\r\n\r\nNote that reducers can call non-reducer functions, including standard library functions.\r\n\r\nReducers 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.\r\n\r\nBoth of these examples are invoked every second.\r\n\r\n```rust\r\n#[spacetimedb(reducer, repeat = 1s)]\r\nfn every_second() {}\r\n\r\n#[spacetimedb(reducer, repeat = 1000ms)]\r\nfn every_thousand_milliseconds() {}\r\n```\r\n\r\nFinally, reducers can also receive a ReducerContext object, or the Timestamp at which they are invoked, just by taking parameters of those types first.\r\n\r\n```rust\r\n#[spacetimedb(reducer, repeat = 1s)]\r\nfn tick_timestamp(time: Timestamp) {\r\n println!(\"tick at {time}\");\r\n}\r\n\r\n#[spacetimedb(reducer, repeat = 500ms)]\r\nfn tick_ctx(ctx: ReducerContext) {\r\n println!(\"tick at {}\", ctx.timestamp)\r\n}\r\n```\r\n\r\nNote 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.\r\n\r\nThere 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.\r\n\r\n#[SpacetimeType]\r\n\r\n#[sats]\r\n\r\n## Client API\r\n\r\nBesides 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.\r\n\r\n### `println!` and friends\r\n\r\nBecause 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.\r\n\r\nSpacetimeDB 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.\r\n\r\nLogs for a module can be viewed with the `spacetime logs` command from the CLI.\r\n\r\n```rust\r\nuse spacetimedb::{\r\n println,\r\n print,\r\n eprintln,\r\n eprint,\r\n dbg,\r\n};\r\n\r\n#[spacetimedb(reducer)]\r\nfn output(i: i32) {\r\n // These will be logged at log::Level::Info.\r\n println!(\"an int with a trailing newline: {i}\");\r\n print!(\"some more text...\\n\");\r\n\r\n // These log at log::Level::Error.\r\n eprint!(\"Oops...\");\r\n eprintln!(\", we hit an error\");\r\n\r\n // Just like std::dbg!, this prints its argument and returns the value,\r\n // as a drop-in way to print expressions. So this will print out |i|\r\n // before passing the value of |i| along to the calling function.\r\n //\r\n // The output is logged log::Level::Debug.\r\n OutputtedNumbers::insert(dbg!(i));\r\n}\r\n```\r\n\r\n### Generated functions on a SpacetimeDB table\r\n\r\nWe'll work off these structs to see what functions SpacetimeDB generates:\r\n\r\nThis table has a plain old column.\r\n\r\n```rust\r\n#[spacetimedb(table)]\r\nstruct Ordinary {\r\n ordinary_field: u64,\r\n}\r\n```\r\n\r\nThis 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.\r\n\r\n```rust\r\n#[spacetimedb(table)]\r\nstruct Unique {\r\n // A unique column:\r\n #[unique]\r\n unique_field: u64,\r\n}\r\n```\r\n\r\nThis 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.\r\n\r\nOnly integer types can be `#[unique]`: `u8`, `u16`, `u32`, `u64`, `u128`, `i8`, `i16`, `i32`, `i64` and `i128`.\r\n\r\n```rust\r\n#[spacetimedb(table)]\r\nstruct Autoinc {\r\n #[autoinc]\r\n autoinc_field: u64,\r\n}\r\n```\r\n\r\nThese attributes can be combined, to create an automatically assigned ID usable for filtering.\r\n\r\n```rust\r\n#[spacetimedb(table)]\r\nstruct Identity {\r\n #[autoinc]\r\n #[unique]\r\n id_field: u64,\r\n}\r\n```\r\n\r\n### Insertion\r\n\r\nWe'll talk about insertion first, as there a couple of special semantics to know about.\r\n\r\nWhen we define |Ordinary| as a spacetimedb table, we get the ability to insert into it with the generated `Ordinary::insert` method.\r\n\r\nInserting takes a single argument, the row to insert. When there are no unique fields in the row, the return value is the inserted row.\r\n\r\n```rust\r\n#[spacetimedb(reducer)]\r\nfn insert_ordinary(value: u64) {\r\n let ordinary = Ordinary { ordinary_field: value };\r\n let result = Ordinary::insert(ordinary);\r\n assert_eq!(ordinary.ordinary_field, result.ordinary_field);\r\n}\r\n```\r\n\r\nWhen there is a unique column constraint on the table, insertion can fail if a uniqueness constraint is violated.\r\n\r\nIf we insert two rows which have the same value of a unique column, the second will fail.\r\n\r\n```rust\r\n#[spacetimedb(reducer)]\r\nfn insert_unique(value: u64) {\r\n let result = Ordinary::insert(Unique { unique_field: value });\r\n assert!(result.is_ok());\r\n\r\n let result = Ordinary::insert(Unique { unique_field: value });\r\n assert!(result.is_err());\r\n}\r\n```\r\n\r\nWhen inserting a table with an `#[autoinc]` column, the database will automatically overwrite whatever we give it with an atomically increasing value.\r\n\r\nThe returned row has the `autoinc` column set to the value that was actually written into the database.\r\n\r\n```rust\r\n#[spacetimedb(reducer)]\r\nfn insert_autoinc() {\r\n for i in 1..=10 {\r\n // These will have values of 1, 2, ..., 10\r\n // at rest in the database, regardless of\r\n // what value is actually present in the\r\n // insert call.\r\n let actual = Autoinc::insert(Autoinc { autoinc_field: 23 })\r\n assert_eq!(actual.autoinc_field, i);\r\n }\r\n}\r\n\r\n#[spacetimedb(reducer)]\r\nfn insert_id() {\r\n for _ in 0..10 {\r\n // These also will have values of 1, 2, ..., 10.\r\n // There's no collision and silent failure to insert,\r\n // because the value of the field is ignored and overwritten\r\n // with the automatically incremented value.\r\n Identity::insert(Identity { autoinc_field: 23 })\r\n }\r\n}\r\n```\r\n\r\n### Iterating\r\n\r\nGiven a table, we can iterate over all the rows in it.\r\n\r\n```rust\r\n#[spacetimedb(table)]\r\nstruct Person {\r\n #[unique]\r\n id: u64,\r\n\r\n age: u32,\r\n name: String,\r\n address: String,\r\n}\r\n```\r\n\r\n// Every table structure an iter function, like:\r\n\r\n```rust\r\nfn MyTable::iter() -> TableIter\r\n```\r\n\r\n`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.\r\n\r\n```\r\n#[spacetimedb(reducer)]\r\nfn iteration() {\r\n let mut addresses = HashSet::new();\r\n\r\n for person in Person::iter() {\r\n addresses.insert(person.address);\r\n }\r\n\r\n for address in addresses.iter() {\r\n println!(\"{address}\");\r\n }\r\n}\r\n```\r\n\r\n### Filtering\r\n\r\nOften, we don't need to look at the entire table, and instead are looking for rows with specific values in certain columns.\r\n\r\nOur `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.\r\n\r\nThe name of the filter method just corresponds to the column name.\r\n\r\n```rust\r\n#[spacetimedb(reducer)]\r\nfn filtering(id: u64) {\r\n match Person::filter_by_id(&id) {\r\n Some(person) => println!(\"Found {person}\"),\r\n None => println!(\"No person with id {id}\"),\r\n }\r\n}\r\n```\r\n\r\nOur `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`.\r\n\r\n```rust\r\n#[spacetimedb(reducer)]\r\nfn filtering_non_unique() {\r\n for person in Person::filter_by_age(&21) {\r\n println!(\"{person} has turned 21\");\r\n }\r\n}\r\n```\r\n\r\n### Deleting\r\n\r\nLike filtering, we can delete by a unique column instead of the entire row.\r\n\r\n```rust\r\n#[spacetimedb(reducer)]\r\nfn delete_id(id: u64) {\r\n Person::delete_by_id(&id)\r\n}\r\n```\r\n\r\n[macro library]: https://github.com/clockworklabs/SpacetimeDB/tree/master/crates/bindings-macro\r\n[module library]: https://github.com/clockworklabs/SpacetimeDB/tree/master/crates/lib\r\n[demo]: /#demo\r\n", "editUrl": "ModuleReference.md", "jumpLinks": [ { @@ -691,7 +738,17 @@ export const docsConfig = { } ] } - ] + ], + "previousKey": { + "title": "Unity Tutorial", + "route": "index", + "depth": 1 + }, + "nextKey": { + "title": "Client SDK Languages", + "route": "index", + "depth": 1 + } }, { "title": "Client SDK Languages", @@ -715,6 +772,7 @@ export const docsConfig = { "title": "C# Client SDK Quick Start", "identifier": "index", "indexIdentifier": "index", + "content": "# C# Client SDK Quick Start\r\n\r\nIn this guide we'll show you how to get up and running with a simple SpacetimDB app with a client written in C#.\r\n\r\nWe'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.\r\n\r\n## Project structure\r\n\r\nEnter 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:\r\n\r\n```bash\r\ncd quickstart-chat\r\n```\r\n\r\nWithin it, create a new C# console application project called `client` using either Visual Studio or the .NET CLI:\r\n\r\n```bash\r\ndotnet new console -o client\r\n```\r\n\r\nOpen the project in your IDE of choice.\r\n\r\n## Add the NuGet package for the C# SpacetimeDB SDK\r\n\r\nAdd the `spacetimedbsdk` [NuGet package](https://www.nuget.org/packages/spacetimedbsdk) using Visual Studio NuGet package manager or via the .NET CLI\r\n\r\n```bash\r\ndotnet add package spacetimedbsdk\r\n```\r\n\r\n## Generate your module types\r\n\r\nThe `spacetime` CLI's `generate` command will generate client-side interfaces for the tables, reducers and types defined in your server module.\r\n\r\nIn your `quickstart-chat` directory, run:\r\n\r\n```bash\r\nmkdir -p client/module_bindings\r\nspacetime generate --lang csharp --out-dir client/module_bindings --project-path server\r\n```\r\n\r\nTake a look inside `client/module_bindings`. The CLI should have generated five files:\r\n\r\n```\r\nmodule_bindings\r\n├── Message.cs\r\n├── ReducerEvent.cs\r\n├── SendMessageReducer.cs\r\n├── SetNameReducer.cs\r\n└── User.cs\r\n```\r\n\r\n## Add imports to Program.cs\r\n\r\nOpen `client/Program.cs` and add the following imports:\r\n\r\n```csharp\r\nusing SpacetimeDB;\r\nusing SpacetimeDB.Types;\r\nusing System.Collections.Concurrent;\r\n```\r\n\r\nWe 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`:\r\n\r\n```csharp\r\n// our local client SpacetimeDB identity\r\nIdentity? local_identity = null;\r\n// declare a thread safe queue to store commands in format (command, args)\r\nConcurrentQueue<(string,string)> input_queue = new ConcurrentQueue<(string, string)>();\r\n// declare a threadsafe cancel token to cancel the process loop\r\nCancellationTokenSource cancel_token = new CancellationTokenSource();\r\n```\r\n\r\n## Define Main function\r\n\r\nWe'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:\r\n\r\n1. Initialize the AuthToken module, which loads and stores our authentication token to/from local storage.\r\n2. Create the SpacetimeDBClient instance.\r\n3. 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.\r\n4. 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.\r\n5. Start the input loop, which reads commands from standard input and sends them to the processing thread.\r\n6. When the input loop exits, stop the processing thread and wait for it to exit.\r\n\r\n```csharp\r\nvoid Main()\r\n{\r\n AuthToken.Init(\".spacetime_csharp_quickstart\");\r\n\r\n // create the client, pass in a logger to see debug messages\r\n SpacetimeDBClient.CreateInstance(new ConsoleLogger());\r\n\r\n RegisterCallbacks();\r\n\r\n // spawn a thread to call process updates and process commands\r\n var thread = new Thread(ProcessThread);\r\n thread.Start();\r\n\r\n InputLoop();\r\n\r\n // this signals the ProcessThread to stop\r\n cancel_token.Cancel();\r\n thread.Join();\r\n}\r\n```\r\n\r\n## Register callbacks\r\n\r\nWe need to handle several sorts of events:\r\n\r\n1. `onConnect`: When we connect, we will call `Subscribe` to tell the module what tables we care about.\r\n2. `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.\r\n3. `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.\r\n4. `User.OnInsert`: When a new user joins, we'll print a message introducing them.\r\n5. `User.OnUpdate`: When a user is updated, we'll print their new name, or declare their new online status.\r\n6. `Message.OnInsert`: When we receive a new message, we'll print it.\r\n7. `Reducer.OnSetNameEvent`: If the server rejects our attempt to set our name, we'll print an error.\r\n8. `Reducer.OnSendMessageEvent`: If the server rejects a message we send, we'll print an error.\r\n\r\n```csharp\r\nvoid RegisterCallbacks()\r\n{\r\n SpacetimeDBClient.instance.onConnect += OnConnect;\r\n SpacetimeDBClient.instance.onIdentityReceived += OnIdentityReceived;\r\n SpacetimeDBClient.instance.onSubscriptionApplied += OnSubscriptionApplied;\r\n\r\n User.OnInsert += User_OnInsert;\r\n User.OnUpdate += User_OnUpdate;\r\n\r\n Message.OnInsert += Message_OnInsert;\r\n\r\n Reducer.OnSetNameEvent += Reducer_OnSetNameEvent;\r\n Reducer.OnSendMessageEvent += Reducer_OnSendMessageEvent;\r\n}\r\n```\r\n\r\n### Notify about new users\r\n\r\nFor 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, which are automatically generated for each table by `spacetime generate`.\r\n\r\nThese callbacks can fire in two contexts:\r\n\r\n- After a reducer runs, when the client's cache is updated about changes to subscribed rows.\r\n- After calling `subscribe`, when the client's cache is initialized with all existing matching rows.\r\n\r\nThis 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.\r\n\r\n`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.\r\n\r\nWhenever 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.\r\n\r\n```csharp\r\nstring UserNameOrIdentity(User user) => user.Name ?? Identity.From(user.Identity).ToString()!.Substring(0, 8);\r\n\r\nvoid User_OnInsert(User insertedValue, ReducerEvent? dbEvent)\r\n{\r\n if(insertedValue.Online)\r\n {\r\n Console.WriteLine($\"{UserNameOrIdentity(insertedValue)} is online\");\r\n }\r\n}\r\n```\r\n\r\n### Notify about updated users\r\n\r\nBecause 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.\r\n\r\n`OnUpdate` callbacks take three arguments: the old row, the new row, and a `ReducerEvent`.\r\n\r\nIn our module, users can be updated for three reasons:\r\n\r\n1. They've set their name using the `SetName` reducer.\r\n2. They're an existing user re-connecting, so their `Online` has been set to `true`.\r\n3. They've disconnected, so their `Online` has been set to `false`.\r\n\r\nWe'll print an appropriate message in each of these cases.\r\n\r\n```csharp\r\nvoid User_OnUpdate(User oldValue, User newValue, ReducerEvent dbEvent)\r\n{\r\n if(oldValue.Name != newValue.Name)\r\n {\r\n Console.WriteLine($\"{UserNameOrIdentity(oldValue)} renamed to {newValue.Name}\");\r\n }\r\n if(oldValue.Online != newValue.Online)\r\n {\r\n if(newValue.Online)\r\n {\r\n Console.WriteLine($\"{UserNameOrIdentity(newValue)} connected.\");\r\n }\r\n else\r\n {\r\n Console.WriteLine($\"{UserNameOrIdentity(newValue)} disconnected.\");\r\n }\r\n }\r\n}\r\n```\r\n\r\n### Print messages\r\n\r\nWhen 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.\r\n\r\nTo 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.\r\n\r\nWe'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`.\r\n\r\n```csharp\r\nvoid PrintMessage(Message message)\r\n{\r\n var sender = User.FilterByIdentity(message.Sender);\r\n var senderName = \"unknown\";\r\n if(sender != null)\r\n {\r\n senderName = UserNameOrIdentity(sender);\r\n }\r\n\r\n Console.WriteLine($\"{senderName}: {message.Text}\");\r\n}\r\n\r\nvoid Message_OnInsert(Message insertedValue, ReducerEvent? dbEvent)\r\n{\r\n if(dbEvent != null)\r\n {\r\n PrintMessage(insertedValue);\r\n }\r\n}\r\n```\r\n\r\n### Warn if our name was rejected\r\n\r\nWe can also register callbacks to run each time a reducer is invoked. We register these callbacks using the `OnReducerEvent` method of the `Reducer` namespace, which is automatically implemented for each reducer by `spacetime generate`.\r\n\r\nEach reducer callback takes one fixed argument:\r\n\r\nThe ReducerEvent that triggered the callback. It contains several fields. The ones we care about are:\r\n\r\n1. The `Identity` of the client that called the reducer.\r\n2. The `Status` of the reducer run, one of `Committed`, `Failed` or `OutOfEnergy`.\r\n3. The error message, if any, that the reducer returned.\r\n\r\nIt also takes a variable amount of additional arguments that match the reducer's arguments.\r\n\r\nThese callbacks will be invoked in one of two cases:\r\n\r\n1. If the reducer was successful and altered any of our subscribed rows.\r\n2. If we requested an invocation which failed.\r\n\r\nNote that a status of `Failed` or `OutOfEnergy` implies that the caller identity is our own identity.\r\n\r\nWe already handle successful `SetName` invocations using our `User.OnUpdate` 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 `Reducer_OnSetNameEvent` as a `Reducer.OnSetNameEvent` callback which checks if the reducer failed, and if it did, prints an error message including the rejected name.\r\n\r\nWe'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.\r\n\r\n```csharp\r\nvoid Reducer_OnSetNameEvent(ReducerEvent reducerEvent, string name)\r\n{\r\n if(reducerEvent.Identity == local_identity && reducerEvent.Status == ClientApi.Event.Types.Status.Failed)\r\n {\r\n Console.Write($\"Failed to change name to {name}\");\r\n }\r\n}\r\n```\r\n\r\n### Warn if our message was rejected\r\n\r\nWe handle warnings on rejected messages the same way as rejected names, though the types and the error message are different.\r\n\r\n```csharp\r\nvoid Reducer_OnSendMessageEvent(ReducerEvent reducerEvent, string text)\r\n{\r\n if (reducerEvent.Identity == local_identity && reducerEvent.Status == ClientApi.Event.Types.Status.Failed)\r\n {\r\n Console.Write($\"Failed to send message {text}\");\r\n }\r\n}\r\n```\r\n\r\n## Connect callback\r\n\r\nOnce 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.\r\n\r\n```csharp\r\nvoid OnConnect()\r\n{\r\n SpacetimeDBClient.instance.Subscribe(new List { \"SELECT * FROM User\", \"SELECT * FROM Message\" });\r\n}\r\n```\r\n\r\n## OnIdentityReceived callback\r\n\r\nThis 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.\r\n\r\n```csharp\r\nvoid OnIdentityReceived(string authToken, Identity identity)\r\n{\r\n local_identity = identity;\r\n AuthToken.SaveToken(authToken);\r\n}\r\n```\r\n\r\n## OnSubscriptionApplied callback\r\n\r\nOnce 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.\r\n\r\n```csharp\r\nvoid PrintMessagesInOrder()\r\n{\r\n foreach (Message message in Message.Iter().OrderBy(item => item.Sent))\r\n {\r\n PrintMessage(message);\r\n }\r\n}\r\n\r\nvoid OnSubscriptionApplied()\r\n{\r\n Console.WriteLine(\"Connected\");\r\n PrintMessagesInOrder();\r\n}\r\n```\r\n\r\n\r\n\r\n## Process thread\r\n\r\nSince the input loop will be blocking, we'll run our processing code in a separate thread. This thread will:\r\n\r\n1. 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.\r\n\r\n`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.\r\n\r\n2. 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.\r\n\r\n3. Finally, Close the connection to the module.\r\n\r\n```csharp\r\nconst string HOST = \"localhost:3000\";\r\nconst string DBNAME = \"chat\";\r\nconst bool SSL_ENABLED = false;\r\n\r\nvoid ProcessThread()\r\n{\r\n SpacetimeDBClient.instance.Connect(AuthToken.Token, HOST, DBNAME, SSL_ENABLED);\r\n\r\n // loop until cancellation token\r\n while (!cancel_token.IsCancellationRequested)\r\n {\r\n SpacetimeDBClient.instance.Update();\r\n\r\n ProcessCommands();\r\n\r\n Thread.Sleep(100);\r\n }\r\n\r\n SpacetimeDBClient.instance.Close();\r\n}\r\n```\r\n\r\n## Input loop and ProcessCommands\r\n\r\nThe 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.\r\n\r\nSupported Commands:\r\n\r\n1. Send a message: `message`, send the message to the module by calling `Reducer.SendMessage` which is automatically generated by `spacetime generate`.\r\n\r\n2. Set name: `name`, will send the new name to the module by calling `Reducer.SetName` which is automatically generated by `spacetime generate`.\r\n\r\n```csharp\r\nvoid InputLoop()\r\n{\r\n while (true)\r\n {\r\n var input = Console.ReadLine();\r\n if(input == null)\r\n {\r\n break;\r\n }\r\n\r\n if(input.StartsWith(\"/name \"))\r\n {\r\n input_queue.Enqueue((\"name\", input.Substring(6)));\r\n continue;\r\n }\r\n else\r\n {\r\n input_queue.Enqueue((\"message\", input));\r\n }\r\n }\r\n}\r\n\r\nvoid ProcessCommands()\r\n{\r\n // process input queue commands\r\n while (input_queue.TryDequeue(out var command))\r\n {\r\n switch (command.Item1)\r\n {\r\n case \"message\":\r\n Reducer.SendMessage(command.Item2);\r\n break;\r\n case \"name\":\r\n Reducer.SetName(command.Item2);\r\n break;\r\n }\r\n }\r\n}\r\n```\r\n\r\n## Run the client\r\n\r\nFinally we just need to add a call to `Main` in `Program.cs`:\r\n\r\n```csharp\r\nMain();\r\n```\r\n\r\nNow we can run the client, by hitting start in Visual Studio or running the following command in the `client` directory:\r\n\r\n```bash\r\ndotnet run --project client\r\n```\r\n\r\n## What's next?\r\n\r\nCongratulations! 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.\r\n", "hasPages": false, "editUrl": "index.md", "jumpLinks": [ @@ -821,6 +879,7 @@ export const docsConfig = { "identifier": "SDK Reference", "indexIdentifier": "SDK Reference", "hasPages": false, + "content": "# The SpacetimeDB C# client SDK\r\n\r\nThe SpacetimeDB client C# for Rust contains all the tools you need to build native clients for SpacetimeDB modules using C#.\r\n\r\n## Table of Contents\r\n\r\n- [The SpacetimeDB C# client SDK](#the-spacetimedb-c-client-sdk)\r\n - [Table of Contents](#table-of-contents)\r\n - [Install the SDK](#install-the-sdk)\r\n - [Using the `dotnet` CLI tool](#using-the-dotnet-cli-tool)\r\n - [Using Unity](#using-unity)\r\n - [Generate module bindings](#generate-module-bindings)\r\n - [Initialization](#initialization)\r\n - [Static Method `SpacetimeDBClient.CreateInstance`](#static-method-spacetimedbclientcreateinstance)\r\n - [Property `SpacetimeDBClient.instance`](#property-spacetimedbclientinstance)\r\n - [Class `NetworkManager`](#class-networkmanager)\r\n - [Method `SpacetimeDBClient.Connect`](#method-spacetimedbclientconnect)\r\n - [Event `SpacetimeDBClient.onIdentityReceived`](#event-spacetimedbclientonidentityreceived)\r\n - [Event `SpacetimeDBClient.onConnect`](#event-spacetimedbclientonconnect)\r\n - [Subscribe to queries](#subscribe-to-queries)\r\n - [Method `SpacetimeDBClient.Subscribe`](#method-spacetimedbclientsubscribe)\r\n - [Event `SpacetimeDBClient.onSubscriptionApplied`](#event-spacetimedbclientonsubscriptionapplied)\r\n - [View rows of subscribed tables](#view-rows-of-subscribed-tables)\r\n - [Class `{TABLE}`](#class-table)\r\n - [Static Method `{TABLE}.Iter`](#static-method-tableiter)\r\n - [Static Method `{TABLE}.FilterBy{COLUMN}`](#static-method-tablefilterbycolumn)\r\n - [Static Method `{TABLE}.Count`](#static-method-tablecount)\r\n - [Static Event `{TABLE}.OnInsert`](#static-event-tableoninsert)\r\n - [Static Event `{TABLE}.OnBeforeDelete`](#static-event-tableonbeforedelete)\r\n - [Static Event `{TABLE}.OnDelete`](#static-event-tableondelete)\r\n - [Static Event `{TABLE}.OnUpdate`](#static-event-tableonupdate)\r\n - [Observe and invoke reducers](#observe-and-invoke-reducers)\r\n - [Class `Reducer`](#class-reducer)\r\n - [Static Method `Reducer.{REDUCER}`](#static-method-reducerreducer)\r\n - [Static Event `Reducer.On{REDUCER}`](#static-event-reduceronreducer)\r\n - [Class `ReducerEvent`](#class-reducerevent)\r\n - [Enum `Status`](#enum-status)\r\n - [Variant `Status.Committed`](#variant-statuscommitted)\r\n - [Variant `Status.Failed`](#variant-statusfailed)\r\n - [Variant `Status.OutOfEnergy`](#variant-statusoutofenergy)\r\n - [Identity management](#identity-management)\r\n - [Class `AuthToken`](#class-authtoken)\r\n - [Static Method `AuthToken.Init`](#static-method-authtokeninit)\r\n - [Static Property `AuthToken.Token`](#static-property-authtokentoken)\r\n - [Static Method `AuthToken.SaveToken`](#static-method-authtokensavetoken)\r\n - [Class `Identity`](#class-identity)\r\n - [Customizing logging](#customizing-logging)\r\n - [Interface `ISpacetimeDBLogger`](#interface-ispacetimedblogger)\r\n - [Class `ConsoleLogger`](#class-consolelogger)\r\n - [Class `UnityDebugLogger`](#class-unitydebuglogger)\r\n\r\n## Install the SDK\r\n\r\n### Using the `dotnet` CLI tool\r\n\r\nIf you would like to create a console application using .NET, you can create a new project using `dotnet new console` and add the SpacetimeDB SDK to your dependencies:\r\n\r\n```bash\r\ndotnet add package spacetimedbsdk\r\n```\r\n\r\n(See also the [CSharp Quickstart](./CSharpSDKQuickStart) for an in-depth example of such a console application.)\r\n\r\n### Using Unity\r\n\r\nTo install the SpacetimeDB SDK into a Unity project, download the SpacetimeDB SDK from the following link.\r\n\r\nhttps://sdk.spacetimedb.com/SpacetimeDBUnitySDK.unitypackage\r\n\r\nIn Unity navigate to the `Assets > Import Package > Custom Package...` menu in the menu bar. Select your `SpacetimeDBUnitySDK.unitypackage` file and leave all folders checked.\r\n\r\n(See also the [Unity Quickstart](./UnityQuickStart) and [Unity Tutorial](./UnityTutorialPart1).)\r\n\r\n## Generate module bindings\r\n\r\nEach SpacetimeDB client depends on some bindings specific to your module. Create a `module_bindings` directory in your project's directory and generate the C# interface files using the Spacetime CLI. From your project directory, run:\r\n\r\n```bash\r\nmkdir -p module_bindings\r\nspacetime generate --lang cs --out-dir module_bindings --project-path PATH-TO-MODULE-DIRECTORY\r\n```\r\n\r\nReplace `PATH-TO-MODULE-DIRECTORY` with the path to your SpacetimeDB module.\r\n\r\n## Initialization\r\n\r\n### Static Method `SpacetimeDBClient.CreateInstance`\r\n\r\n```cs\r\nnamespace SpacetimeDB {\r\n\r\npublic class SpacetimeDBClient {\r\n public static void CreateInstance(ISpacetimeDBLogger loggerToUse);\r\n}\r\n\r\n}\r\n```\r\n\r\nCreate a global SpacetimeDBClient instance, accessible via [`SpacetimeDBClient.instance`](#property-spacetimedbclientinstance)\r\n\r\n| Argument | Type | Meaning |\r\n| ------------- | ----------------------------------------------------- | --------------------------------- |\r\n| `loggerToUse` | [`ISpacetimeDBLogger`](#interface-ispacetimedblogger) | The logger to use to log messages |\r\n\r\nThere is a provided logger called [`ConsoleLogger`](#class-consolelogger) which logs to `System.Console`, and can be used as follows:\r\n\r\n```cs\r\nusing SpacetimeDB;\r\nusing SpacetimeDB.Types;\r\nSpacetimeDBClient.CreateInstance(new ConsoleLogger());\r\n```\r\n\r\n### Property `SpacetimeDBClient.instance`\r\n\r\n```cs\r\nnamespace SpacetimeDB {\r\n\r\npublic class SpacetimeDBClient {\r\n public static SpacetimeDBClient instance;\r\n}\r\n\r\n}\r\n```\r\n\r\nThis is the global instance of a SpacetimeDB client in a particular .NET/Unity process. Much of the SDK is accessible through this instance.\r\n\r\n### Class `NetworkManager`\r\n\r\nThe 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.\r\n\r\n![Unity-AddNetworkManager](/images/unity-tutorial/Unity-AddNetworkManager.JPG)\r\n\r\nThis 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.\r\n\r\n### Method `SpacetimeDBClient.Connect`\r\n\r\n```cs\r\nnamespace SpacetimeDB {\r\n\r\nclass SpacetimeDBClient {\r\n public void Connect(\r\n string? token,\r\n string host,\r\n string addressOrName,\r\n bool sslEnabled = true\r\n );\r\n}\r\n\r\n}\r\n```\r\n\r\n\r\n\r\nConnect to a database named `addressOrName` accessible over the internet at the URI `host`.\r\n\r\n| Argument | Type | Meaning |\r\n| --------------- | --------- | -------------------------------------------------------------------------- |\r\n| `token` | `string?` | Identity token to use, if one is available. |\r\n| `host` | `string` | URI of the SpacetimeDB instance running the module. |\r\n| `addressOrName` | `string` | Address or name of the module. |\r\n| `sslEnabled` | `bool` | Whether or not to use SSL when connecting to SpacetimeDB. Default: `true`. |\r\n\r\nIf 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).\r\n\r\n```cs\r\nusing SpacetimeDB;\r\nusing SpacetimeDB.Types;\r\n\r\nconst string DBNAME = \"chat\";\r\n\r\n// Connect to a local DB with a fresh identity\r\nSpacetimeDBClient.instance.Connect(null, \"localhost:3000\", DBNAME, false);\r\n\r\n// Connect to cloud with a fresh identity\r\nSpacetimeDBClient.instance.Connect(null, \"dev.spacetimedb.net\", DBNAME, true);\r\n\r\n// Connect to cloud using a saved identity from the filesystem, or get a new one and save it\r\nAuthToken.Init();\r\nIdentity localIdentity;\r\nSpacetimeDBClient.instance.Connect(AuthToken.Token, \"dev.spacetimedb.net\", DBNAME, true);\r\nSpacetimeDBClient.instance.onIdentityReceived += (string authToken, Identity identity) {\r\n AuthToken.SaveToken(authToken);\r\n localIdentity = identity;\r\n}\r\n```\r\n\r\n(You should probably also store the returned `Identity` somewhere; see the [`onIdentityReceived`](#event-spacetimedbclientonidentityreceived) event.)\r\n\r\n### Event `SpacetimeDBClient.onIdentityReceived`\r\n\r\n```cs\r\nnamespace SpacetimeDB {\r\n\r\nclass SpacetimeDBClient {\r\n public event Action onIdentityReceived;\r\n}\r\n\r\n}\r\n```\r\n\r\nCalled 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.\r\n\r\nTo 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.\r\n\r\nIf 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`.\r\n\r\n```cs\r\n// Connect to cloud using a saved identity from the filesystem, or get a new one and save it\r\nAuthToken.Init();\r\nIdentity localIdentity;\r\nSpacetimeDBClient.instance.Connect(AuthToken.Token, \"dev.spacetimedb.net\", DBNAME, true);\r\nSpacetimeDBClient.instance.onIdentityReceived += (string authToken, Identity identity) {\r\n AuthToken.SaveToken(authToken);\r\n localIdentity = identity;\r\n}\r\n```\r\n\r\n### Event `SpacetimeDBClient.onConnect`\r\n\r\n```cs\r\nnamespace SpacetimeDB {\r\n\r\nclass SpacetimeDBClient {\r\n public event Action onConnect;\r\n}\r\n\r\n}\r\n```\r\n\r\nAllows registering delegates to be invoked upon authentication with the database.\r\n\r\nOnce this occurs, the SDK is prepared for calls to [`SpacetimeDBClient.Subscribe`](#method-spacetimedbclientsubscribe).\r\n\r\n## Subscribe to queries\r\n\r\n### Method `SpacetimeDBClient.Subscribe`\r\n\r\n```cs\r\nnamespace SpacetimeDB {\r\n\r\nclass SpacetimeDBClient {\r\n public void Subscribe(List queries);\r\n}\r\n\r\n}\r\n```\r\n\r\n| Argument | Type | Meaning |\r\n| --------- | -------------- | ---------------------------- |\r\n| `queries` | `List` | SQL queries to subscribe to. |\r\n\r\nSubscribe to a set of queries, to be notified when rows which match those queries are altered.\r\n\r\n`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.\r\n\r\nThe `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.\r\n\r\nA 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.\r\n\r\n```cs\r\nusing SpacetimeDB;\r\nusing SpacetimeDB.Types;\r\n\r\nvoid Main()\r\n{\r\n AuthToken.Init();\r\n SpacetimeDBClient.CreateInstance(new ConsoleLogger());\r\n\r\n SpacetimeDBClient.instance.onConnect += OnConnect;\r\n\r\n // Our module contains a table named \"Loot\"\r\n Loot.OnInsert += Loot_OnInsert;\r\n\r\n SpacetimeDBClient.instance.Connect(/* ... */);\r\n}\r\n\r\nvoid OnConnect()\r\n{\r\n SpacetimeDBClient.instance.Subscribe(new List {\r\n \"SELECT * FROM Loot\"\r\n });\r\n}\r\n\r\nvoid Loot_OnInsert(\r\n Loot loot,\r\n ReducerEvent? event\r\n) {\r\n Console.Log($\"Loaded loot {loot.itemType} at coordinates {loot.position}\");\r\n}\r\n```\r\n\r\n### Event `SpacetimeDBClient.onSubscriptionApplied`\r\n\r\n```cs\r\nnamespace SpacetimeDB {\r\n\r\nclass SpacetimeDBClient {\r\n public event Action onSubscriptionApplied;\r\n}\r\n\r\n}\r\n```\r\n\r\nRegister a delegate to be invoked when a subscription is registered with the database.\r\n\r\n```cs\r\nusing SpacetimeDB;\r\n\r\nvoid OnSubscriptionApplied()\r\n{\r\n Console.WriteLine(\"Now listening on queries.\");\r\n}\r\n\r\nvoid Main()\r\n{\r\n // ...initialize...\r\n SpacetimeDBClient.instance.onSubscriptionApplied += OnSubscriptionApplied;\r\n}\r\n```\r\n\r\n## View rows of subscribed tables\r\n\r\nThe 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).\r\n\r\nONLY 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.\r\n\r\nIn 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.\r\n\r\nTo 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.\r\n\r\n### Class `{TABLE}`\r\n\r\nFor 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.\r\n\r\nStatic Methods:\r\n\r\n- [`{TABLE}.Iter()`](#static-method-tableiter) iterates all subscribed rows in the client cache.\r\n- [`{TABLE}.FilterBy{COLUMN}(value)`](#static-method-tablefilterbycolumn) filters subscribed rows in the client cache by a column value.\r\n- [`{TABLE}.Count()`](#static-method-tablecount) counts the number of subscribed rows in the client cache.\r\n\r\nStatic Events:\r\n\r\n- [`{TABLE}.OnInsert`](#static-event-tableoninsert) is called when a row is inserted into the client cache.\r\n- [`{TABLE}.OnBeforeDelete`](#static-event-tableonbeforedelete) is called when a row is about to be removed from the client cache.\r\n- If the table has a primary key attribute, [`{TABLE}.OnUpdate`](#static-event-tableonupdate) is called when a row is updated.\r\n- [`{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.\r\n\r\nNote 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.\r\n\r\n#### Static Method `{TABLE}.Iter`\r\n\r\n```cs\r\nnamespace SpacetimeDB.Types {\r\n\r\nclass TABLE {\r\n public static System.Collections.Generic.IEnumerable
Iter();\r\n}\r\n\r\n}\r\n```\r\n\r\nIterate over all the subscribed rows in the table. This method is only available after [`SpacetimeDBClient.onSubscriptionApplied`](#event-spacetimedbclientonsubscriptionapplied) has occurred.\r\n\r\nWhen iterating over rows and filtering for those containing a particular column, [`TableType::filter`](#method-filter) will be more efficient, so prefer it when possible.\r\n\r\n```cs\r\nusing SpacetimeDB;\r\nusing SpacetimeDB.Types;\r\n\r\nSpacetimeDBClient.instance.onConnect += (string authToken, Identity identity) => {\r\n SpacetimeDBClient.instance.Subscribe(new List { \"SELECT * FROM User\" });\r\n};\r\nSpacetimeDBClient.instance.onSubscriptionApplied += () => {\r\n // Will print a line for each `User` row in the database.\r\n foreach (var user in User.Iter()) {\r\n Console.WriteLine($\"User: {user.Name}\");\r\n }\r\n};\r\nSpacetimeDBClient.instance.connect(/* ... */);\r\n```\r\n\r\n#### Static Method `{TABLE}.FilterBy{COLUMN}`\r\n\r\n```cs\r\nnamespace SpacetimeDB.Types {\r\n\r\nclass TABLE {\r\n // If the column has no #[unique] or #[primarykey] constraint\r\n public static System.Collections.Generic.IEnumerable
FilterBySender(COLUMNTYPE value);\r\n\r\n // If the column has a #[unique] or #[primarykey] constraint\r\n public static TABLE? FilterBySender(COLUMNTYPE value);\r\n}\r\n\r\n}\r\n```\r\n\r\nFor 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`.\r\n\r\nThe method's return type depends on the column's attributes:\r\n\r\n- For unique columns, including those annotated `#[unique]` and `#[primarykey]`, the `filterBy{COLUMN}` method returns a `{TABLE}?`, where `{TABLE}` is the [table class](#class-table).\r\n- For non-unique columns, the `filter_by` method returns an `IEnumerator<{TABLE}>`.\r\n\r\n#### Static Method `{TABLE}.Count`\r\n\r\n```cs\r\nnamespace SpacetimeDB.Types {\r\n\r\nclass TABLE {\r\n public static int Count();\r\n}\r\n\r\n}\r\n```\r\n\r\nReturn the number of subscribed rows in the table, or 0 if there is no active connection.\r\n\r\n```cs\r\nusing SpacetimeDB;\r\nusing SpacetimeDB.Types;\r\n\r\nSpacetimeDBClient.instance.onConnect += (string authToken, Identity identity) => {\r\n SpacetimeDBClient.instance.Subscribe(new List { \"SELECT * FROM User\" });\r\n};\r\nSpacetimeDBClient.instance.onSubscriptionApplied += () => {\r\n Console.WriteLine($\"There are {User.Count()} users in the database.\");\r\n};\r\nSpacetimeDBClient.instance.connect(/* ... */);\r\n```\r\n\r\n#### Static Event `{TABLE}.OnInsert`\r\n\r\n```cs\r\nnamespace SpacetimeDB.Types {\r\n\r\nclass TABLE {\r\n public delegate void InsertEventHandler(\r\n TABLE insertedValue,\r\n ReducerEvent? dbEvent\r\n );\r\n public static event InsertEventHandler OnInsert;\r\n}\r\n\r\n}\r\n```\r\n\r\nRegister a delegate for when a subscribed row is newly inserted into the database.\r\n\r\nThe delegate takes two arguments:\r\n\r\n- A [`{TABLE}`](#class-table) instance with the data of the inserted row\r\n- 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.\r\n\r\n```cs\r\nusing SpacetimeDB;\r\nusing SpacetimeDB.Types;\r\n\r\n/* initialize, subscribe to table User... */\r\n\r\nUser.OnInsert += (User user, ReducerEvent? reducerEvent) => {\r\n if (reducerEvent == null) {\r\n Console.WriteLine($\"New user '{user.Name}' received during subscription update.\");\r\n } else {\r\n Console.WriteLine($\"New user '{user.Name}' inserted by reducer {reducerEvent.Reducer}.\");\r\n }\r\n};\r\n```\r\n\r\n#### Static Event `{TABLE}.OnBeforeDelete`\r\n\r\n```cs\r\nnamespace SpacetimeDB.Types {\r\n\r\nclass TABLE {\r\n public delegate void DeleteEventHandler(\r\n TABLE deletedValue,\r\n ReducerEvent dbEvent\r\n );\r\n public static event DeleteEventHandler OnBeforeDelete;\r\n}\r\n\r\n}\r\n```\r\n\r\nRegister 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.\r\n\r\nThe delegate takes two arguments:\r\n\r\n- A [`{TABLE}`](#class-table) instance with the data of the deleted row\r\n- A [`ReducerEvent`](#class-reducerevent), which contains the data of the reducer that deleted the row.\r\n\r\nThis 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.\r\n\r\n```cs\r\nusing SpacetimeDB;\r\nusing SpacetimeDB.Types;\r\n\r\n/* initialize, subscribe to table User... */\r\n\r\nUser.OnBeforeDelete += (User user, ReducerEvent reducerEvent) => {\r\n Console.WriteLine($\"User '{user.Name}' deleted by reducer {reducerEvent.Reducer}.\");\r\n};\r\n```\r\n\r\n#### Static Event `{TABLE}.OnDelete`\r\n\r\n```cs\r\nnamespace SpacetimeDB.Types {\r\n\r\nclass TABLE {\r\n public delegate void DeleteEventHandler(\r\n TABLE deletedValue,\r\n SpacetimeDB.ReducerEvent dbEvent\r\n );\r\n public static event DeleteEventHandler OnDelete;\r\n}\r\n\r\n}\r\n```\r\n\r\nRegister 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).\r\n\r\nThe delegate takes two arguments:\r\n\r\n- A [`{TABLE}`](#class-table) instance with the data of the deleted row\r\n- A [`ReducerEvent`](#class-reducerevent), which contains the data of the reducer that deleted the row.\r\n\r\n```cs\r\nusing SpacetimeDB;\r\nusing SpacetimeDB.Types;\r\n\r\n/* initialize, subscribe to table User... */\r\n\r\nUser.OnBeforeDelete += (User user, ReducerEvent reducerEvent) => {\r\n Console.WriteLine($\"User '{user.Name}' deleted by reducer {reducerEvent.Reducer}.\");\r\n};\r\n```\r\n\r\n#### Static Event `{TABLE}.OnUpdate`\r\n\r\n```cs\r\nnamespace SpacetimeDB.Types {\r\n\r\nclass TABLE {\r\n public delegate void UpdateEventHandler(\r\n TABLE oldValue,\r\n TABLE newValue,\r\n ReducerEvent dbEvent\r\n );\r\n public static event UpdateEventHandler OnUpdate;\r\n}\r\n\r\n}\r\n```\r\n\r\nRegister 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.\r\n\r\nThe delegate takes three arguments:\r\n\r\n- A [`{TABLE}`](#class-table) instance with the old data of the updated row\r\n- A [`{TABLE}`](#class-table) instance with the new data of the updated row\r\n- A [`ReducerEvent`](#class-reducerevent), which contains the data of the reducer that updated the row.\r\n\r\n```cs\r\nusing SpacetimeDB;\r\nusing SpacetimeDB.Types;\r\n\r\n/* initialize, subscribe to table User... */\r\n\r\nUser.OnUpdate += (User oldUser, User newUser, ReducerEvent reducerEvent) => {\r\n Debug.Assert(oldUser.UserId == newUser.UserId, \"Primary key never changes in an update\");\r\n\r\n Console.WriteLine($\"User with ID {oldUser.UserId} had name changed \"+\r\n $\"from '{oldUser.Name}' to '{newUser.Name}' by reducer {reducerEvent.Reducer}.\");\r\n};\r\n```\r\n\r\n## Observe and invoke reducers\r\n\r\n\"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.\r\n\r\n`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).\r\n\r\n### Class `Reducer`\r\n\r\n```cs\r\nnamespace SpacetimeDB.Types {\r\n\r\nclass Reducer {}\r\n\r\n}\r\n```\r\n\r\nThis class contains a static method and event for each reducer defined in a module.\r\n\r\n#### Static Method `Reducer.{REDUCER}`\r\n\r\n```cs\r\nnamespace SpacetimeDB.Types {\r\nclass Reducer {\r\n\r\n/* void {REDUCER_NAME}(...ARGS...) */\r\n\r\n}\r\n}\r\n```\r\n\r\nFor 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`.\r\n\r\nReducers 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.\r\n\r\nFor reducers which accept a `ReducerContext` as their first argument, the `ReducerContext` is not included in the generated function's argument list.\r\n\r\nFor example, if we define a reducer in Rust as follows:\r\n\r\n```rust\r\n#[spacetimedb(reducer)]\r\npub fn set_name(\r\n ctx: ReducerContext,\r\n user_id: u64,\r\n name: String\r\n) -> Result<(), Error>;\r\n```\r\n\r\nThe following C# static method will be generated:\r\n\r\n```cs\r\nnamespace SpacetimeDB.Types {\r\nclass Reducer {\r\n\r\npublic static void SendMessage(UInt64 userId, string name);\r\n\r\n}\r\n}\r\n```\r\n\r\n#### Static Event `Reducer.On{REDUCER}`\r\n\r\n```cs\r\nnamespace SpacetimeDB.Types {\r\nclass Reducer {\r\n\r\npublic delegate void /*{REDUCER}*/Handler(ReducerEvent reducerEvent, /* {ARGS...} */);\r\n\r\npublic static event /*{REDUCER}*/Handler On/*{REDUCER}*/Event;\r\n\r\n}\r\n}\r\n```\r\n\r\nFor 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`.\r\n\r\nThe 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.\r\n\r\nFor example, if we define a reducer in Rust as follows:\r\n\r\n```rust\r\n#[spacetimedb(reducer)]\r\npub fn set_name(\r\n ctx: ReducerContext,\r\n user_id: u64,\r\n name: String\r\n) -> Result<(), Error>;\r\n```\r\n\r\nThe following C# static method will be generated:\r\n\r\n```cs\r\nnamespace SpacetimeDB.Types {\r\nclass Reducer {\r\n\r\npublic delegate void SetNameHandler(\r\n ReducerEvent reducerEvent,\r\n UInt64 userId,\r\n string name\r\n);\r\npublic static event SetNameHandler OnSetNameEvent;\r\n\r\n}\r\n}\r\n```\r\n\r\nWhich can be used as follows:\r\n\r\n```cs\r\n/* initialize, wait for onSubscriptionApplied... */\r\n\r\nReducer.SetNameHandler += (\r\n ReducerEvent reducerEvent,\r\n UInt64 userId,\r\n string name\r\n) => {\r\n if (reducerEvent.Status == ClientApi.Event.Types.Status.Committed) {\r\n Console.WriteLine($\"User with id {userId} set name to {name}\");\r\n } else if (reducerEvent.Status == ClientApi.Event.Types.Status.Failed) {\r\n Console.WriteLine(\r\n $\"User with id {userId} failed to set name to {name}:\"\r\n + reducerEvent.ErrMessage\r\n );\r\n } else if (reducerEvent.Status == ClientApi.Event.Types.Status.OutOfEnergy) {\r\n Console.WriteLine(\r\n $\"User with id {userId} failed to set name to {name}:\"\r\n + \"Invoker ran out of energy\"\r\n );\r\n }\r\n};\r\nReducer.SetName(USER_ID, NAME);\r\n```\r\n\r\n### Class `ReducerEvent`\r\n\r\n`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`.\r\n\r\nFor example, the example project shown in the Rust Module quickstart will generate the following (abridged) code.\r\n\r\n```cs\r\nnamespace SpacetimeDB.Types {\r\n\r\npublic enum ReducerType\r\n{\r\n /* A member for each reducer in the module, with names converted to PascalCase */\r\n None,\r\n SendMessage,\r\n SetName,\r\n}\r\npublic partial class SendMessageArgsStruct\r\n{\r\n /* A member for each argument of the reducer SendMessage, with names converted to PascalCase. */\r\n public string Text;\r\n}\r\npublic partial class SetNameArgsStruct\r\n{\r\n /* A member for each argument of the reducer SetName, with names converted to PascalCase. */\r\n public string Name;\r\n}\r\npublic partial class ReducerEvent : ReducerEventBase {\r\n // Which reducer was invoked\r\n public ReducerType Reducer { get; }\r\n // If event.Reducer == ReducerType.SendMessage, the arguments\r\n // sent to the SendMessage reducer. Otherwise, accesses will\r\n // throw a runtime error.\r\n public SendMessageArgsStruct SendMessageArgs { get; }\r\n // If event.Reducer == ReducerType.SetName, the arguments\r\n // passed to the SetName reducer. Otherwise, accesses will\r\n // throw a runtime error.\r\n public SetNameArgsStruct SetNameArgs { get; }\r\n\r\n /* Additional information, present on any ReducerEvent */\r\n // The name of the reducer.\r\n public string ReducerName { get; }\r\n // The timestamp of the reducer invocation inside the database.\r\n public ulong Timestamp { get; }\r\n // The identity of the client that invoked the reducer.\r\n public SpacetimeDB.Identity Identity { get; }\r\n // Whether the reducer succeeded, failed, or ran out of energy.\r\n public ClientApi.Event.Types.Status Status { get; }\r\n // If event.Status == Status.Failed, the error message returned from inside the module.\r\n public string ErrMessage { get; }\r\n}\r\n\r\n}\r\n```\r\n\r\n#### Enum `Status`\r\n\r\n```cs\r\nnamespace ClientApi {\r\npublic sealed partial class Event {\r\npublic static partial class Types {\r\n\r\npublic enum Status {\r\n Committed = 0,\r\n Failed = 1,\r\n OutOfEnergy = 2,\r\n}\r\n\r\n}\r\n}\r\n}\r\n```\r\n\r\nAn enum whose variants represent possible reducer completion statuses of a reducer invocation.\r\n\r\n##### Variant `Status.Committed`\r\n\r\nThe reducer finished successfully, and its row changes were committed to the database.\r\n\r\n##### Variant `Status.Failed`\r\n\r\nThe reducer failed, either by panicking or returning a `Err`.\r\n\r\n##### Variant `Status.OutOfEnergy`\r\n\r\nThe reducer was canceled because the module owner had insufficient energy to allow it to run to completion.\r\n\r\n## Identity management\r\n\r\n### Class `AuthToken`\r\n\r\nThe AuthToken helper class handles creating and saving SpacetimeDB identity tokens in the filesystem.\r\n\r\n#### Static Method `AuthToken.Init`\r\n\r\n```cs\r\nnamespace SpacetimeDB {\r\n\r\nclass AuthToken {\r\n public static void Init(\r\n string configFolder = \".spacetime_csharp_sdk\",\r\n string configFile = \"settings.ini\",\r\n string? configRoot = null\r\n );\r\n}\r\n\r\n}\r\n```\r\n\r\nCreates a file `$\"{configRoot}/{configFolder}/{configFile}\"` to store tokens.\r\nIf no arguments are passed, the default is `\"%HOME%/.spacetime_csharp_sdk/settings.ini\"`.\r\n\r\n| Argument | Type | Meaning |\r\n| -------------- | -------- | ---------------------------------------------------------------------------------- |\r\n| `configFolder` | `string` | The folder to store the config file in. Default is `\"spacetime_csharp_sdk\"`. |\r\n| `configFile` | `string` | The name of the config file. Default is `\"settings.ini\"`. |\r\n| `configRoot` | `string` | The root folder to store the config file in. Default is the user's home directory. |\r\n\r\n#### Static Property `AuthToken.Token`\r\n\r\n```cs\r\nnamespace SpacetimeDB {\r\n\r\nclass AuthToken {\r\n public static string? Token { get; }\r\n}\r\n\r\n}\r\n```\r\n\r\nThe auth token stored on the filesystem, if one exists.\r\n\r\n#### Static Method `AuthToken.SaveToken`\r\n\r\n```cs\r\nnamespace SpacetimeDB {\r\n\r\nclass AuthToken {\r\n public static void SaveToken(string token);\r\n}\r\n\r\n}\r\n```\r\n\r\nSave a token to the filesystem.\r\n\r\n### Class `Identity`\r\n\r\n```cs\r\nnamespace SpacetimeDB {\r\n\r\npublic struct Identity : IEquatable\r\n{\r\n public byte[] Bytes { get; }\r\n public static Identity From(byte[] bytes);\r\n public bool Equals(Identity other);\r\n public static bool operator ==(Identity a, Identity b);\r\n public static bool operator !=(Identity a, Identity b);\r\n}\r\n\r\n}\r\n```\r\n\r\nA unique public identifier for a client connected to a database.\r\n\r\nColumns 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.\r\n\r\n## Customizing logging\r\n\r\nThe 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.\r\n\r\nThis 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).\r\n\r\nOutside of unity, all you need to do is the following:\r\n\r\n```cs\r\nusing SpacetimeDB;\r\nusing SpacetimeDB.Types;\r\nSpacetimeDBClient.CreateInstance(new ConsoleLogger());\r\n```\r\n\r\n### Interface `ISpacetimeDBLogger`\r\n\r\n```cs\r\nnamespace SpacetimeDB\r\n{\r\n\r\npublic interface ISpacetimeDBLogger\r\n{\r\n void Log(string message);\r\n void LogError(string message);\r\n void LogWarning(string message);\r\n void LogException(Exception e);\r\n}\r\n\r\n}\r\n```\r\n\r\nThis 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.\r\n\r\n### Class `ConsoleLogger`\r\n\r\n```cs\r\nnamespace SpacetimeDB {\r\n\r\npublic class ConsoleLogger : ISpacetimeDBLogger {}\r\n\r\n}\r\n```\r\n\r\nAn `ISpacetimeDBLogger` implementation for regular .NET applications, using `Console.Write` when logs are received.\r\n\r\n### Class `UnityDebugLogger`\r\n\r\n```cs\r\nnamespace SpacetimeDB {\r\n\r\npublic class UnityDebugLogger : ISpacetimeDBLogger {}\r\n\r\n}\r\n```\r\n\r\nAn `ISpacetimeDBLogger` implementation for Unity, using the Unity `Debug.Log` api.\r\n", "editUrl": "SDK%20Reference.md", "jumpLinks": [ { @@ -1052,6 +1111,7 @@ export const docsConfig = { "title": "Welcome to Client SDK Languages# SpacetimeDB Client SDKs Overview", "identifier": "index", "indexIdentifier": "index", + "content": "# Welcome to Client SDK Languages# SpacetimeDB Client SDKs Overview\r\n\r\nThe SpacetimeDB Client SDKs provide a comprehensive interface to interact with the SpacetimeDB server engine from various programming languages. Currently, SDKs are available for\r\n\r\n- [Rust](/docs/client-languages/rust/rust-sdk-reference) - [(Quickstart)](/docs/client-languages/rust/rust-sdk-quickstart-guide)\r\n- [C#](/docs/client-languages/csharp/csharp-sdk-reference) - [(Quickstart)](/docs/client-languages/csharp/csharp-sdk-quickstart-guide)\r\n- [TypeScript](/docs/client-languages/typescript/typescript-sdk-reference) - [(Quickstart)](client-languages/typescript/typescript-sdk-quickstart-guide)\r\n- [Python](/docs/client-languages/python/python-sdk-reference) - [(Quickstart)](/docs/python/python-sdk-quickstart-guide)\r\n\r\n## Key Features\r\n\r\nThe SpacetimeDB Client SDKs offer the following key functionalities:\r\n\r\n### Connection Management\r\n\r\nThe SDKs handle the process of connecting and disconnecting from the SpacetimeDB server, simplifying this process for the client applications.\r\n\r\n### Authentication\r\n\r\nThe SDKs support authentication using an auth token, allowing clients to securely establish a session with the SpacetimeDB server.\r\n\r\n### Local Database View\r\n\r\nEach client can define a local view of the database via a subscription consisting of a set of queries. This local view is maintained by the server and populated into a local cache on the client side.\r\n\r\n### Reducer Calls\r\n\r\nThe SDKs allow clients to call transactional functions (reducers) on the server.\r\n\r\n### Callback Registrations\r\n\r\nThe SpacetimeDB Client SDKs offer powerful callback functionality that allow clients to monitor changes in their local database view. These callbacks come in two forms:\r\n\r\n#### Connection and Subscription Callbacks\r\n\r\nClients 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.\r\n\r\n#### Row Update Callbacks\r\n\r\nClients can register callbacks that trigger when any row in their local cache is updated by the server. These callbacks contain information about the reducer that triggered the change. This feature enables clients to react to changes in data that they're interested in.\r\n\r\n#### Reducer Call Callbacks\r\n\r\nClients can also register callbacks that fire when a reducer call modifies something in the client's local view. This allows the client to know when a transactional function it has executed has had an effect on the data it cares about.\r\n\r\nAdditionally, when a client makes a reducer call that fails, the SDK triggers the registered reducer callback on the client that initiated the failed call with the error message that was returned from the server. This allows for appropriate error handling or user notifications.\r\n\r\n## Choosing a Language\r\n\r\nWhen selecting a language for your client application with SpacetimeDB, a variety of factors come into play. While the functionality of the SDKs remains consistent across different languages, the choice of language will often depend on the specific needs and context of your application. Here are a few considerations:\r\n\r\n### Team Expertise\r\n\r\nThe familiarity of your development team with a particular language can greatly influence your choice. You might want to choose a language that your team is most comfortable with to increase productivity and reduce development time.\r\n\r\n### Application Type\r\n\r\nDifferent 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.\r\n\r\n### Performance\r\n\r\nThe performance characteristics of the different languages can also be a factor. If your application is performance-critical, you might opt for Rust, known for its speed and memory efficiency.\r\n\r\n### Platform Support\r\n\r\nThe platform you're targeting can also influence your choice. For instance, if you're developing a game or a 3D application using the Unity engine, you'll want to choose the C# SDK, as Unity uses C# as its primary scripting language.\r\n\r\n### Ecosystem and Libraries\r\n\r\nEach language has its own ecosystem of libraries and tools that can help in developing your application. If there's a library in a particular language that you want to use, it may influence your choice.\r\n\r\nRemember, 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.\r\n\r\nYou 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.\r\n", "hasPages": false, "editUrl": "index.md", "jumpLinks": [ @@ -1151,6 +1211,7 @@ export const docsConfig = { "title": "Python Client SDK Quick Start", "identifier": "index", "indexIdentifier": "index", + "content": "# Python Client SDK Quick Start\r\n\r\nIn this guide, we'll show you how to get up and running with a simple SpacetimDB app with a client written in Python.\r\n\r\nWe'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.\r\n\r\n## Install the SpacetimeDB SDK Python Package\r\n\r\n1. Run pip install\r\n\r\n```bash\r\npip install spacetimedb_sdk\r\n```\r\n\r\n## Project structure\r\n\r\nEnter the directory `quickstart-chat` you created in the Rust or C# Module Quickstart guides and create a `client` folder:\r\n\r\n```bash\r\ncd quickstart-chat\r\nmkdir client\r\n```\r\n\r\n## Create the Python main file\r\n\r\nCreate a file called `main.py` in the `client` and open it in your favorite editor. We prefer [VS Code](https://code.visualstudio.com/).\r\n\r\n## Add imports\r\n\r\nWe need to add several imports for this quickstart:\r\n\r\n- [`asyncio`](https://docs.python.org/3/library/asyncio.html) is required to run the async code in the SDK.\r\n- [`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.\r\n- [`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.\r\n\r\n- `spacetimedb_sdk.spacetimedb_async_client.SpacetimeDBAsyncClient` is the async wrapper around the SpacetimeDB client which we use to interact with our SpacetimeDB module.\r\n- `spacetimedb_sdk.local_config` is an optional helper module to load the auth token from local storage.\r\n\r\n```python\r\nimport asyncio\r\nfrom multiprocessing import Queue\r\nimport threading\r\n\r\nfrom spacetimedb_sdk.spacetimedb_async_client import SpacetimeDBAsyncClient\r\nimport spacetimedb_sdk.local_config as local_config\r\n```\r\n\r\n## Generate your module types\r\n\r\nThe `spacetime` CLI's `generate` command will generate client-side interfaces for the tables, reducers and types defined in your server module.\r\n\r\nIn your `client` directory, run:\r\n\r\n```bash\r\nmkdir -p module_bindings\r\nspacetime generate --lang python --out-dir src/module_bindings --project_path ../server\r\n```\r\n\r\nTake a look inside `client/module_bindings`. The CLI should have generated five files:\r\n\r\n```\r\nmodule_bindings\r\n+-- message.py\r\n+-- send_message_reducer.py\r\n+-- set_name_reducer.py\r\n+-- user.py\r\n```\r\n\r\nNow we import these types by adding the following lines to `main.py`:\r\n\r\n```python\r\nimport module_bindings\r\nfrom module_bindings.user import User\r\nfrom module_bindings.message import Message\r\nimport module_bindings.send_message_reducer as send_message_reducer\r\nimport module_bindings.set_name_reducer as set_name_reducer\r\n```\r\n\r\n## Global variables\r\n\r\nNext we will add our global `input_queue` and `local_identity` variables which we will explain later when they are used.\r\n\r\n```python\r\ninput_queue = Queue()\r\nlocal_identity = None\r\n```\r\n\r\n## Define main function\r\n\r\nWe'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:\r\n\r\n1. Init the optional local config module. The first parameter is the directory name to be created in the user home directory.\r\n1. Create our async SpacetimeDB client.\r\n1. Register our callbacks.\r\n1. Start the async client in a thread.\r\n1. Run a loop to read user input and send it to a repeating event in the async client.\r\n1. When the user exits, stop the async client and exit the program.\r\n\r\n```python\r\nif __name__ == \"__main__\":\r\n local_config.init(\".spacetimedb-python-quickstart\")\r\n\r\n spacetime_client = SpacetimeDBAsyncClient(module_bindings)\r\n\r\n register_callbacks(spacetime_client)\r\n\r\n thread = threading.Thread(target=run_client, args=(spacetime_client,))\r\n thread.start()\r\n\r\n input_loop()\r\n\r\n spacetime_client.force_close()\r\n thread.join()\r\n```\r\n\r\n## Register callbacks\r\n\r\nWe need to handle several sorts of events:\r\n\r\n1. OnSubscriptionApplied is a special callback that is executed when the local client cache is populated. We will talk more about this later.\r\n2. When a new user joins or a user is updated, we'll print an appropriate message.\r\n3. When we receive a new message, we'll print it.\r\n4. If the server rejects our attempt to set our name, we'll print an error.\r\n5. If the server rejects a message we send, we'll print an error.\r\n6. 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.\r\n\r\nBecause python requires functions to be defined before they're used, the following code must be added to `main.py` before main block:\r\n\r\n```python\r\ndef register_callbacks(spacetime_client):\r\n spacetime_client.client.register_on_subscription_applied(on_subscription_applied)\r\n\r\n User.register_row_update(on_user_row_update)\r\n Message.register_row_update(on_message_row_update)\r\n\r\n set_name_reducer.register_on_set_name(on_set_name_reducer)\r\n send_message_reducer.register_on_send_message(on_send_message_reducer)\r\n\r\n spacetime_client.schedule_event(0.1, check_commands)\r\n```\r\n\r\n### Handling User row updates\r\n\r\nFor 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`.\r\n\r\nThese callbacks can fire in two contexts:\r\n\r\n- After a reducer runs, when the client's cache is updated about changes to subscribed rows.\r\n- After calling `subscribe`, when the client's cache is initialized with all existing matching rows.\r\n\r\nThis 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.\r\n\r\nWe are also going to check for updates to the user row. This can happen for three reasons:\r\n\r\n1. They've set their name using the `set_name` reducer.\r\n2. They're an existing user re-connecting, so their `online` has been set to `true`.\r\n3. They've disconnected, so their `online` has been set to `false`.\r\n\r\nWe'll print an appropriate message in each of these cases.\r\n\r\n`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.\r\n\r\nWhenever 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.\r\n\r\nAdd these functions before the `register_callbacks` function:\r\n\r\n```python\r\ndef user_name_or_identity(user):\r\n if user.name:\r\n return user.name\r\n else:\r\n return (str(user.identity))[:8]\r\n\r\ndef on_user_row_update(row_op, user_old, user, reducer_event):\r\n if row_op == \"insert\":\r\n if user.online:\r\n print(f\"User {user_name_or_identity(user)} connected.\")\r\n elif row_op == \"update\":\r\n if user_old.online and not user.online:\r\n print(f\"User {user_name_or_identity(user)} disconnected.\")\r\n elif not user_old.online and user.online:\r\n print(f\"User {user_name_or_identity(user)} connected.\")\r\n\r\n if user_old.name != user.name:\r\n print(\r\n f\"User {user_name_or_identity(user_old)} renamed to {user_name_or_identity(user)}.\"\r\n )\r\n```\r\n\r\n### Print messages\r\n\r\nWhen 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.\r\n\r\nTo 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.\r\n\r\nWe'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`.\r\n\r\nAdd these functions before the `register_callbacks` function:\r\n\r\n```python\r\ndef on_message_row_update(row_op, message_old, message, reducer_event):\r\n if reducer_event is not None and row_op == \"insert\":\r\n print_message(message)\r\n\r\ndef print_message(message):\r\n user = User.filter_by_identity(message.sender)\r\n user_name = \"unknown\"\r\n if user is not None:\r\n user_name = user_name_or_identity(user)\r\n\r\n print(f\"{user_name}: {message.text}\")\r\n```\r\n\r\n### Warn if our name was rejected\r\n\r\nWe 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`.\r\n\r\nEach reducer callback takes three fixed arguments:\r\n\r\n1. The `Identity` of the client who requested the reducer invocation.\r\n2. The `Status` of the reducer run, one of `committed`, `failed` or `outofenergy`.\r\n3. The `Message` returned by the reducer in error cases, or `None` if the reducer succeeded.\r\n\r\nIt also takes a variable number of arguments which match the calling arguments of the reducer.\r\n\r\nThese callbacks will be invoked in one of two cases:\r\n\r\n1. If the reducer was successful and altered any of our subscribed rows.\r\n2. If we requested an invocation which failed.\r\n\r\nNote that a status of `failed` or `outofenergy` implies that the caller identity is our own identity.\r\n\r\nWe 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.\r\n\r\nWe'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.\r\n\r\nAdd this function before the `register_callbacks` function:\r\n\r\n```python\r\ndef on_set_name_reducer(sender, status, message, name):\r\n if sender == local_identity:\r\n if status == \"failed\":\r\n print(f\"Failed to set name: {message}\")\r\n```\r\n\r\n### Warn if our message was rejected\r\n\r\nWe handle warnings on rejected messages the same way as rejected names, though the types and the error message are different.\r\n\r\nAdd this function before the `register_callbacks` function:\r\n\r\n```python\r\ndef on_send_message_reducer(sender, status, message, msg):\r\n if sender == local_identity:\r\n if status == \"failed\":\r\n print(f\"Failed to send message: {message}\")\r\n```\r\n\r\n### OnSubscriptionApplied callback\r\n\r\nThis 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.\r\n\r\nIn 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.\r\n\r\nAdd these functions before the `register_callbacks` function:\r\n\r\n```python\r\ndef print_messages_in_order():\r\n all_messages = sorted(Message.iter(), key=lambda x: x.sent)\r\n for entry in all_messages:\r\n print(f\"{user_name_or_identity(User.filter_by_identity(entry.sender))}: {entry.text}\")\r\n\r\ndef on_subscription_applied():\r\n print(f\"\\nSYSTEM: Connected.\")\r\n print_messages_in_order()\r\n```\r\n\r\n### Check commands repeating event\r\n\r\nWe'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.\r\n\r\nIf 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.\r\n\r\nAdd these functions before the `register_callbacks` function:\r\n\r\n```python\r\ndef check_commands():\r\n global input_queue\r\n\r\n if not input_queue.empty():\r\n choice = input_queue.get()\r\n if choice[0] == \"name\":\r\n set_name_reducer.set_name(choice[1])\r\n else:\r\n send_message_reducer.send_message(choice[1])\r\n\r\n spacetime_client.schedule_event(0.1, check_commands)\r\n```\r\n\r\n### OnConnect callback\r\n\r\nThis 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.\r\n\r\nThe `on_connect` callback takes two arguments:\r\n\r\n1. The `Auth Token` is the equivalent of your private key. This is the only way to authenticate with the SpacetimeDB module as this user.\r\n2. 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.\r\n\r\nTo 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.\r\n\r\nThe `on_connect` callback is passed to the client connect function so it just needs to be defined before the `run_client` described next.\r\n\r\n```python\r\ndef on_connect(auth_token, identity):\r\n global local_identity\r\n local_identity = identity\r\n\r\n local_config.set_string(\"auth_token\", auth_token)\r\n```\r\n\r\n## Async client thread\r\n\r\nWe are going to write a function that starts the async client, which will be executed on a separate thread.\r\n\r\n```python\r\ndef run_client(spacetime_client):\r\n asyncio.run(\r\n spacetime_client.run(\r\n local_config.get_string(\"auth_token\"),\r\n \"localhost:3000\",\r\n \"chat\",\r\n False,\r\n on_connect,\r\n [\"SELECT * FROM User\", \"SELECT * FROM Message\"],\r\n )\r\n )\r\n```\r\n\r\n## Input loop\r\n\r\nFinally, we need a function to be executed on the main loop which listens for user input and adds it to the queue.\r\n\r\n```python\r\ndef input_loop():\r\n global input_queue\r\n\r\n while True:\r\n user_input = input()\r\n if len(user_input) == 0:\r\n return\r\n elif user_input.startswith(\"/name \"):\r\n input_queue.put((\"name\", user_input[6:]))\r\n else:\r\n input_queue.put((\"message\", user_input))\r\n```\r\n\r\n## Run the client\r\n\r\nMake 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.\r\n\r\nRun the client:\r\n\r\n```bash\r\npython main.py\r\n```\r\n\r\nIf 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.\r\n\r\n```bash\r\npython main.py --client 2\r\n```\r\n\r\n## Next steps\r\n\r\nCongratulations! 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.\r\n\r\nFor 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.\r\n", "hasPages": false, "editUrl": "index.md", "jumpLinks": [ @@ -1262,6 +1323,7 @@ export const docsConfig = { "identifier": "SDK Reference", "indexIdentifier": "SDK Reference", "hasPages": false, + "content": "# The SpacetimeDB Python client SDK\r\n\r\nThe SpacetimeDB client SDK for Python contains all the tools you need to build native clients for SpacetimeDB modules using Python.\r\n\r\n## Install the SDK\r\n\r\nUse pip to install the SDK:\r\n\r\n```bash\r\npip install spacetimedb-sdk\r\n```\r\n\r\n## Generate module bindings\r\n\r\nEach 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:\r\n\r\n```bash\r\nmkdir -p module_bindings\r\nspacetime generate --lang python \\\r\n --out-dir module_bindings \\\r\n --project-path PATH-TO-MODULE-DIRECTORY\r\n```\r\n\r\nReplace `PATH-TO-MODULE-DIRECTORY` with the path to your SpacetimeDB module.\r\n\r\nImport your bindings in your client's code:\r\n\r\n```python\r\nimport module_bindings\r\n```\r\n\r\n## Basic vs Async SpacetimeDB Client\r\n\r\nThis SDK provides two different client modules for interacting with your SpacetimeDB module.\r\n\r\nThe 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.\r\n\r\nThe 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.\r\n\r\n## Common Client Reference\r\n\r\nThe following functions and types are used in both the Basic and Async clients.\r\n\r\n### API at a glance\r\n\r\n| Definition | Description |\r\n| ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |\r\n| Type [`Identity`](#type-identity) | A unique public identifier for a client. |\r\n| Type [`ReducerEvent`](#type-reducerevent) | `class` containing information about the reducer that triggered a row update event. |\r\n| Type [`module_bindings::{TABLE}`](#type-table) | Autogenerated `class` type for a table, holding one row. |\r\n| 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. |\r\n| Method [`module_bindings::{TABLE}::iter`](#method-iter) | Autogenerated method to iterate over all subscribed rows. |\r\n| Method [`module_bindings::{TABLE}::register_row_update`](#method-register_row_update) | Autogenerated method to register a callback that fires when a row changes. |\r\n| Function [`module_bindings::{REDUCER_NAME}::{REDUCER_NAME}`](#function-reducer) | Autogenerated function to invoke a reducer. |\r\n| 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. |\r\n\r\n### Type `Identity`\r\n\r\n```python\r\nclass Identity:\r\n @staticmethod\r\n def from_string(string)\r\n\r\n @staticmethod\r\n def from_bytes(data)\r\n\r\n def __str__(self)\r\n\r\n def __eq__(self, other)\r\n```\r\n\r\n| Member | Args | Meaning |\r\n| ------------- | ---------- | ------------------------------------ |\r\n| `from_string` | `str` | Create an Identity from a hex string |\r\n| `from_bytes` | `bytes` | Create an Identity from raw bytes |\r\n| `__str__` | `None` | Convert the Identity to a hex string |\r\n| `__eq__` | `Identity` | Compare two Identities for equality |\r\n\r\nA unique public identifier for a client connected to a database.\r\n\r\n### Type `ReducerEvent`\r\n\r\n```python\r\nclass ReducerEvent:\r\n def __init__(self, caller_identity, reducer_name, status, message, args):\r\n self.caller_identity = caller_identity\r\n self.reducer_name = reducer_name\r\n self.status = status\r\n self.message = message\r\n self.args = args\r\n```\r\n\r\n| Member | Args | Meaning |\r\n| ----------------- | ----------- | --------------------------------------------------------------------------- |\r\n| `caller_identity` | `Identity` | The identity of the user who invoked the reducer |\r\n| `reducer_name` | `str` | The name of the reducer that was invoked |\r\n| `status` | `str` | The status of the reducer invocation (\"committed\", \"failed\", \"outofenergy\") |\r\n| `message` | `str` | The message returned by the reducer if it fails |\r\n| `args` | `List[str]` | The arguments passed to the reducer |\r\n\r\nThis class contains the information about a reducer event to be passed to row update callbacks.\r\n\r\n### Type `{TABLE}`\r\n\r\n```python\r\nclass TABLE:\r\n\tis_table_class = True\r\n\r\n\tprimary_key = \"identity\"\r\n\r\n\t@classmethod\r\n\tdef register_row_update(cls, callback: Callable[[str,TABLE,TABLE,ReducerEvent], None])\r\n\r\n\t@classmethod\r\n\tdef iter(cls) -> Iterator[User]\r\n\r\n\t@classmethod\r\n\tdef filter_by_COLUMN_NAME(cls, COLUMN_VALUE) -> TABLE\r\n```\r\n\r\nThis class is autogenerated for each table in your module. It contains methods for filtering and iterating over subscribed rows.\r\n\r\n### Method `filter_by_{COLUMN}`\r\n\r\n```python\r\ndef filter_by_COLUMN(self, COLUMN_VALUE) -> TABLE\r\n```\r\n\r\n| Argument | Type | Meaning |\r\n| -------------- | ------------- | ---------------------- |\r\n| `column_value` | `COLUMN_TYPE` | The value to filter by |\r\n\r\nFor 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`.\r\n\r\nThe method's return type depends on the column's attributes:\r\n\r\n- 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).\r\n- For non-unique columns, the `filter_by` method returns an `Iterator` that can be used in a `for` loop.\r\n\r\n### Method `iter`\r\n\r\n```python\r\ndef iter(self) -> Iterator[TABLE]\r\n```\r\n\r\nIterate over all the subscribed rows in the table.\r\n\r\n### Method `register_row_update`\r\n\r\n```python\r\ndef register_row_update(self, callback: Callable[[str,TABLE,TABLE,ReducerEvent], None])\r\n```\r\n\r\n| Argument | Type | Meaning |\r\n| ---------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------ |\r\n| `callback` | `Callable[[str,TABLE,TABLE,ReducerEvent]` | Callback to be invoked when a row is updated (Args: row_op, old_value, new_value, reducer_event) |\r\n\r\nRegister a callback function to be executed when a row is updated. Callback arguments are:\r\n\r\n- `row_op`: The type of row update event. One of `\"insert\"`, `\"delete\"`, or `\"update\"`.\r\n- `old_value`: The previous value of the row, `None` if the row was inserted.\r\n- `new_value`: The new value of the row, `None` if the row was deleted.\r\n- `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.\r\n\r\n### Function `{REDUCER_NAME}`\r\n\r\n```python\r\ndef {REDUCER_NAME}(arg1, arg2)\r\n```\r\n\r\nThis 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.\r\n\r\n### Function `register_on_{REDUCER_NAME}`\r\n\r\n```python\r\ndef register_on_{REDUCER_NAME}(callback: Callable[[Identity, str, str, ARG1_TYPE, ARG1_TYPE], None])\r\n```\r\n\r\n| Argument | Type | Meaning |\r\n| ---------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- |\r\n| `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) |\r\n\r\nRegister a callback function to be executed when the reducer is invoked. Callback arguments are:\r\n\r\n- `caller_identity`: The identity of the user who invoked the reducer.\r\n- `status`: The status of the reducer invocation (\"committed\", \"failed\", \"outofenergy\").\r\n- `message`: The message returned by the reducer if it fails.\r\n- `args`: Variable number of arguments passed to the reducer.\r\n\r\n## Async Client Reference\r\n\r\n### API at a glance\r\n\r\n| Definition | Description |\r\n| ----------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |\r\n| Function [`SpacetimeDBAsyncClient::run`](#function-run) | Run the client. This function will not return until the client is closed. |\r\n| Function [`SpacetimeDBAsyncClient::subscribe`](#function-subscribe) | Subscribe to receive data and transaction updates for the provided queries. |\r\n| 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. |\r\n| Function [`SpacetimeDBAsyncClient::force_close`](#function-force_close) | Signal the client to stop processing events and close the connection to the server. |\r\n| Function [`SpacetimeDBAsyncClient::schedule_event`](#function-schedule_event) | Schedule an event to be fired after a delay |\r\n\r\n### Function `run`\r\n\r\n```python\r\nasync def run(\r\n self,\r\n auth_token,\r\n host,\r\n address_or_name,\r\n ssl_enabled,\r\n on_connect,\r\n subscription_queries=[],\r\n )\r\n```\r\n\r\nRun the client. This function will not return until the client is closed.\r\n\r\n| Argument | Type | Meaning |\r\n| ---------------------- | --------------------------------- | -------------------------------------------------------------- |\r\n| `auth_token` | `str` | Auth token to authenticate the user. (None if new user) |\r\n| `host` | `str` | Hostname of SpacetimeDB server |\r\n| `address_or_name` | `&str` | Name or address of the module. |\r\n| `ssl_enabled` | `bool` | Whether to use SSL when connecting to the server. |\r\n| `on_connect` | `Callable[[str, Identity], None]` | Callback to be invoked when the client connects to the server. |\r\n| `subscription_queries` | `List[str]` | List of queries to subscribe to. |\r\n\r\nIf `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.\r\n\r\nIf 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)\r\n\r\n```python\r\nasyncio.run(\r\n spacetime_client.run(\r\n AUTH_TOKEN,\r\n \"localhost:3000\",\r\n \"my-module-name\",\r\n False,\r\n on_connect,\r\n [\"SELECT * FROM User\", \"SELECT * FROM Message\"],\r\n )\r\n)\r\n```\r\n\r\n### Function `subscribe`\r\n\r\n```rust\r\ndef subscribe(self, queries: List[str])\r\n```\r\n\r\nSubscribe to a set of queries, to be notified when rows which match those queries are altered.\r\n\r\n| Argument | Type | Meaning |\r\n| --------- | ----------- | ---------------------------- |\r\n| `queries` | `List[str]` | SQL queries to subscribe to. |\r\n\r\nThe `queries` should be a slice of strings representing SQL queries.\r\n\r\nA 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`.\r\n\r\nThis should be called before the async client is started with [`run`](#function-run).\r\n\r\n```python\r\nspacetime_client.subscribe([\"SELECT * FROM User;\", \"SELECT * FROM Message;\"])\r\n```\r\n\r\nSubscribe to a set of queries, to be notified when rows which match those queries are altered.\r\n\r\n### Function `register_on_subscription_applied`\r\n\r\n```python\r\ndef register_on_subscription_applied(self, callback)\r\n```\r\n\r\nRegister a callback function to be executed when the local cache is updated as a result of a change to the subscription queries.\r\n\r\n| Argument | Type | Meaning |\r\n| ---------- | -------------------- | ------------------------------------------------------ |\r\n| `callback` | `Callable[[], None]` | Callback to be invoked when subscriptions are applied. |\r\n\r\nThe callback will be invoked after a successful [`subscribe`](#function-subscribe) call when the initial set of matching rows becomes available.\r\n\r\n```python\r\nspacetime_client.register_on_subscription_applied(on_subscription_applied)\r\n```\r\n\r\n### Function `force_close`\r\n\r\n```python\r\ndef force_close(self)\r\n)\r\n```\r\n\r\nSignal the client to stop processing events and close the connection to the server.\r\n\r\n```python\r\nspacetime_client.force_close()\r\n```\r\n\r\n### Function `schedule_event`\r\n\r\n```python\r\ndef schedule_event(self, delay_secs, callback, *args)\r\n```\r\n\r\nSchedule an event to be fired after a delay\r\n\r\nTo create a repeating event, call schedule_event() again from within the callback function.\r\n\r\n| Argument | Type | Meaning |\r\n| ------------ | -------------------- | -------------------------------------------------------------- |\r\n| `delay_secs` | `float` | number of seconds to wait before firing the event |\r\n| `callback` | `Callable[[], None]` | Callback to be invoked when the event fires. |\r\n| `args` | `*args` | Variable number of arguments to pass to the callback function. |\r\n\r\n```python\r\ndef application_tick():\r\n # ... do some work\r\n\r\n spacetime_client.schedule_event(0.1, application_tick)\r\n\r\nspacetime_client.schedule_event(0.1, application_tick)\r\n```\r\n\r\n## Basic Client Reference\r\n\r\n### API at a glance\r\n\r\n| Definition | Description |\r\n| ---------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |\r\n| Function [`SpacetimeDBClient::init`](#function-init) | Create a network manager instance. |\r\n| Function [`SpacetimeDBClient::subscribe`](#function-subscribe) | Subscribe to receive data and transaction updates for the provided queries. |\r\n| Function [`SpacetimeDBClient::register_on_event`](#function-register_on_event) | Register a callback function to handle transaction update events. |\r\n| Function [`SpacetimeDBClient::unregister_on_event`](#function-unregister_on_event) | Unregister a callback function that was previously registered using `register_on_event`. |\r\n| 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. |\r\n| Function [`SpacetimeDBClient::unregister_on_subscription_applied`](#function-unregister_on_subscription_applied) | Unregister a callback function from the subscription update event. |\r\n| Function [`SpacetimeDBClient::update`](#function-update) | Process all pending incoming messages from the SpacetimeDB module. |\r\n| Function [`SpacetimeDBClient::close`](#function-close) | Close the WebSocket connection. |\r\n| Type [`TransactionUpdateMessage`](#type-transactionupdatemessage) | Represents a transaction update message. |\r\n\r\n### Function `init`\r\n\r\n```python\r\n@classmethod\r\ndef init(\r\n auth_token: str,\r\n host: str,\r\n address_or_name: str,\r\n ssl_enabled: bool,\r\n autogen_package: module,\r\n on_connect: Callable[[], NoneType] = None,\r\n on_disconnect: Callable[[str], NoneType] = None,\r\n on_identity: Callable[[str, Identity], NoneType] = None,\r\n on_error: Callable[[str], NoneType] = None\r\n)\r\n```\r\n\r\nCreate a network manager instance.\r\n\r\n| Argument | Type | Meaning |\r\n| ----------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |\r\n| `auth_token` | `str` | This is the token generated by SpacetimeDB that matches the user's identity. If None, token will be generated |\r\n| `host` | `str` | Hostname:port for SpacetimeDB connection |\r\n| `address_or_name` | `str` | The name or address of the database to connect to |\r\n| `ssl_enabled` | `bool` | Whether to use SSL when connecting to the server. |\r\n| `autogen_package` | `ModuleType` | Python package where SpacetimeDB module generated files are located. |\r\n| `on_connect` | `Callable[[], None]` | Optional callback called when a connection is made to the SpacetimeDB module. |\r\n| `on_disconnect` | `Callable[[str], None]` | Optional callback called when the Python client is disconnected from the SpacetimeDB module. The argument is the close message. |\r\n| `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. |\r\n| `on_error` | `Callable[[str], None]` | Optional callback called when the Python client connection encounters an error. The argument is the error message. |\r\n\r\nThis 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.\r\n\r\n```python\r\nSpacetimeDBClient.init(autogen, on_connect=self.on_connect)\r\n```\r\n\r\n### Function `subscribe`\r\n\r\n```python\r\ndef subscribe(queries: List[str])\r\n```\r\n\r\nSubscribe to receive data and transaction updates for the provided queries.\r\n\r\n| Argument | Type | Meaning |\r\n| --------- | ----------- | -------------------------------------------------------------------------------------------------------- |\r\n| `queries` | `List[str]` | A list of queries to subscribe to. Each query is a string representing an sql formatted query statement. |\r\n\r\nThis 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.\r\n\r\n```python\r\nqueries = [\"SELECT * FROM table1\", \"SELECT * FROM table2 WHERE col2 = 0\"]\r\nSpacetimeDBClient.instance.subscribe(queries)\r\n```\r\n\r\n### Function `register_on_event`\r\n\r\n```python\r\ndef register_on_event(callback: Callable[[TransactionUpdateMessage], NoneType])\r\n```\r\n\r\nRegister a callback function to handle transaction update events.\r\n\r\n| Argument | Type | Meaning |\r\n| ---------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\r\n| `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. |\r\n\r\nThis 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.\r\n\r\n```python\r\ndef handle_event(transaction_update):\r\n # Code to handle the transaction update event\r\n\r\nSpacetimeDBClient.instance.register_on_event(handle_event)\r\n```\r\n\r\n### Function `unregister_on_event`\r\n\r\n```python\r\ndef unregister_on_event(callback: Callable[[TransactionUpdateMessage], NoneType])\r\n```\r\n\r\nUnregister a callback function that was previously registered using `register_on_event`.\r\n\r\n| Argument | Type | Meaning |\r\n| ---------- | -------------------------------------------- | ------------------------------------ |\r\n| `callback` | `Callable[[TransactionUpdateMessage], None]` | The callback function to unregister. |\r\n\r\n```python\r\nSpacetimeDBClient.instance.unregister_on_event(handle_event)\r\n```\r\n\r\n### Function `register_on_subscription_applied`\r\n\r\n```python\r\ndef register_on_subscription_applied(callback: Callable[[], NoneType])\r\n```\r\n\r\nRegister a callback function to be executed when the local cache is updated as a result of a change to the subscription queries.\r\n\r\n| Argument | Type | Meaning |\r\n| ---------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |\r\n| `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. |\r\n\r\n```python\r\ndef subscription_callback():\r\n # Code to be executed on each subscription update\r\n\r\nSpacetimeDBClient.instance.register_on_subscription_applied(subscription_callback)\r\n```\r\n\r\n### Function `unregister_on_subscription_applied`\r\n\r\n```python\r\ndef unregister_on_subscription_applied(callback: Callable[[], NoneType])\r\n```\r\n\r\nUnregister a callback function from the subscription update event.\r\n\r\n| Argument | Type | Meaning |\r\n| ---------- | -------------------- | -------------------------------------------------------------------------------------------------------- |\r\n| `callback` | `Callable[[], None]` | A callback function that was previously registered with the `register_on_subscription_applied` function. |\r\n\r\n```python\r\ndef subscription_callback():\r\n # Code to be executed on each subscription update\r\n\r\nSpacetimeDBClient.instance.register_on_subscription_applied(subscription_callback)\r\n```\r\n\r\n### Function `update`\r\n\r\n```python\r\ndef update()\r\n```\r\n\r\nProcess all pending incoming messages from the SpacetimeDB module.\r\n\r\nThis function must be called on a regular interval in the main loop to process incoming messages.\r\n\r\n```python\r\nwhile True:\r\n SpacetimeDBClient.instance.update() # Call the update function in a loop to process incoming messages\r\n # Additional logic or code can be added here\r\n```\r\n\r\n### Function `close`\r\n\r\n```python\r\ndef close()\r\n```\r\n\r\nClose the WebSocket connection.\r\n\r\nThis function closes the WebSocket connection to the SpacetimeDB module.\r\n\r\n```python\r\nSpacetimeDBClient.instance.close()\r\n```\r\n\r\n### Type `TransactionUpdateMessage`\r\n\r\n```python\r\nclass TransactionUpdateMessage:\r\n def __init__(\r\n self,\r\n caller_identity: Identity,\r\n status: str,\r\n message: str,\r\n reducer_name: str,\r\n args: Dict\r\n )\r\n```\r\n\r\n| Member | Args | Meaning |\r\n| ----------------- | ---------- | ------------------------------------------------- |\r\n| `caller_identity` | `Identity` | The identity of the caller. |\r\n| `status` | `str` | The status of the transaction. |\r\n| `message` | `str` | A message associated with the transaction update. |\r\n| `reducer_name` | `str` | The reducer used for the transaction. |\r\n| `args` | `Dict` | Additional arguments for the transaction. |\r\n\r\nRepresents a transaction update message. Used in on_event callbacks.\r\n\r\nFor more details, see [`register_on_event`](#function-register_on_event).\r\n", "editUrl": "SDK%20Reference.md", "jumpLinks": [ { @@ -1442,6 +1504,7 @@ export const docsConfig = { "title": "Rust Client SDK Quick Start", "identifier": "index", "indexIdentifier": "index", + "content": "# Rust Client SDK Quick Start\r\n\r\nIn this guide we'll show you how to get up and running with a simple SpacetimDB app with a client written in Rust.\r\n\r\nWe'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.\r\n\r\n## Project structure\r\n\r\nEnter 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:\r\n\r\n```bash\r\ncd quickstart-chat\r\n```\r\n\r\nWithin it, create a `client` crate, our client application, which users run locally:\r\n\r\n```bash\r\ncargo new client\r\n```\r\n\r\n## Depend on `spacetimedb-sdk` and `hex`\r\n\r\n`client/Cargo.toml` should be initialized without any dependencies. We'll need two:\r\n\r\n- [`spacetimedb-sdk`](https://crates.io/crates/spacetimedb-sdk), which defines client-side interfaces for interacting with a remote SpacetimeDB module.\r\n- [`hex`](https://crates.io/crates/hex), which we'll use to print unnamed users' identities as hexadecimal strings.\r\n\r\nBelow the `[dependencies]` line in `client/Cargo.toml`, add:\r\n\r\n```toml\r\nspacetimedb-sdk = \"0.6\"\r\nhex = \"0.4\"\r\n```\r\n\r\nMake sure you depend on the same version of `spacetimedb-sdk` as is reported by the SpacetimeDB CLI tool's `spacetime version`!\r\n\r\n## Clear `client/src/main.rs`\r\n\r\n`client/src/main.rs` should be initialized with a trivial \"Hello world\" program. Clear it out so we can write our chat client.\r\n\r\nIn your `quickstart-chat` directory, run:\r\n\r\n```bash\r\nrm client/src/main.rs\r\ntouch client/src/main.rs\r\n```\r\n\r\n## Generate your module types\r\n\r\nThe `spacetime` CLI's `generate` command will generate client-side interfaces for the tables, reducers and types defined in your server module.\r\n\r\nIn your `quickstart-chat` directory, run:\r\n\r\n```bash\r\nmkdir -p client/src/module_bindings\r\nspacetime generate --lang rust --out-dir client/src/module_bindings --project-path server\r\n```\r\n\r\nTake a look inside `client/src/module_bindings`. The CLI should have generated five files:\r\n\r\n```\r\nmodule_bindings\r\n├── message.rs\r\n├── mod.rs\r\n├── send_message_reducer.rs\r\n├── set_name_reducer.rs\r\n└── user.rs\r\n```\r\n\r\nWe need to declare the module in our client crate, and we'll want to import its definitions.\r\n\r\nTo `client/src/main.rs`, add:\r\n\r\n```rust\r\nmod module_bindings;\r\nuse module_bindings::*;\r\n```\r\n\r\n## Add more imports\r\n\r\nWe'll need a whole boatload of imports from `spacetimedb_sdk`, which we'll describe when we use them.\r\n\r\nTo `client/src/main.rs`, add:\r\n\r\n```rust\r\nuse spacetimedb_sdk::{\r\n disconnect,\r\n identity::{load_credentials, once_on_connect, save_credentials, Credentials, Identity},\r\n on_disconnect, on_subscription_applied,\r\n reducer::Status,\r\n subscribe,\r\n table::{TableType, TableWithPrimaryKey},\r\n};\r\n```\r\n\r\n## Define main function\r\n\r\nWe'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:\r\n\r\n1. 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.\r\n2. Establish a connection to the database. This will involve authenticating with our credentials, if we're a returning user.\r\n3. Subscribe to receive updates on tables.\r\n4. Loop, processing user input from standard input. This will be how we enable users to set their names and send messages.\r\n5. Close our connection. This one is easy; we just call `spacetimedb_sdk::disconnect`.\r\n\r\nTo `client/src/main.rs`, add:\r\n\r\n```rust\r\nfn main() {\r\n register_callbacks();\r\n connect_to_db();\r\n subscribe_to_tables();\r\n user_input_loop();\r\n}\r\n```\r\n\r\n## Register callbacks\r\n\r\nWe need to handle several sorts of events:\r\n\r\n1. 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.\r\n2. When a new user joins, we'll print a message introducing them.\r\n3. When a user is updated, we'll print their new name, or declare their new online status.\r\n4. When we receive a new message, we'll print it.\r\n5. When we're informed of the backlog of past messages, we'll sort them and print them in order.\r\n6. If the server rejects our attempt to set our name, we'll print an error.\r\n7. If the server rejects a message we send, we'll print an error.\r\n8. When our connection ends, we'll print a note, then exit the process.\r\n\r\nTo `client/src/main.rs`, add:\r\n\r\n```rust\r\n/// Register all the callbacks our app will use to respond to database events.\r\nfn register_callbacks() {\r\n // When we receive our `Credentials`, save them to a file.\r\n once_on_connect(on_connected);\r\n\r\n // When a new user joins, print a notification.\r\n User::on_insert(on_user_inserted);\r\n\r\n // When a user's status changes, print a notification.\r\n User::on_update(on_user_updated);\r\n\r\n // When a new message is received, print it.\r\n Message::on_insert(on_message_inserted);\r\n\r\n // When we receive the message backlog, print it in timestamp order.\r\n on_subscription_applied(on_sub_applied);\r\n\r\n // When we fail to set our name, print a warning.\r\n on_set_name(on_name_set);\r\n\r\n // When we fail to send a message, print a warning.\r\n on_send_message(on_message_sent);\r\n\r\n // When our connection closes, inform the user and exit.\r\n on_disconnect(on_disconnected);\r\n}\r\n```\r\n\r\n### Save credentials\r\n\r\nEach client has a `Credentials`, which consists of two parts:\r\n\r\n- An `Identity`, a unique public identifier. We're using these to identify `User` rows.\r\n- A `Token`, a private key which SpacetimeDB uses to authenticate the client.\r\n\r\n`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.\r\n\r\nTo `client/src/main.rs`, add:\r\n\r\n```rust\r\n/// Our `on_connect` callback: save our credentials to a file.\r\nfn on_connected(creds: &Credentials) {\r\n if let Err(e) = save_credentials(CREDS_DIR, creds) {\r\n eprintln!(\"Failed to save credentials: {:?}\", e);\r\n }\r\n}\r\n\r\nconst CREDS_DIR: &str = \".spacetime_chat\";\r\n```\r\n\r\n### Notify about new users\r\n\r\nFor 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`.\r\n\r\nThese callbacks can fire in two contexts:\r\n\r\n- After a reducer runs, when the client's cache is updated about changes to subscribed rows.\r\n- After calling `subscribe`, when the client's cache is initialized with all existing matching rows.\r\n\r\nThis 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.\r\n\r\n`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.\r\n\r\nWhenever 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.\r\n\r\nTo `client/src/main.rs`, add:\r\n\r\n```rust\r\n/// Our `User::on_insert` callback:\r\n/// if the user is online, print a notification.\r\nfn on_user_inserted(user: &User, _: Option<&ReducerEvent>) {\r\n if user.online {\r\n println!(\"User {} connected.\", user_name_or_identity(user));\r\n }\r\n}\r\n\r\nfn user_name_or_identity(user: &User) -> String {\r\n user.name\r\n .clone()\r\n .unwrap_or_else(|| identity_leading_hex(&user.identity))\r\n}\r\n\r\nfn identity_leading_hex(id: &Identity) -> String {\r\n hex::encode(&id.bytes()[0..8])\r\n}\r\n```\r\n\r\n### Notify about updated users\r\n\r\nBecause 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.\r\n\r\n`on_update` callbacks take three arguments: the old row, the new row, and an `Option<&ReducerEvent>`.\r\n\r\nIn our module, users can be updated for three reasons:\r\n\r\n1. They've set their name using the `set_name` reducer.\r\n2. They're an existing user re-connecting, so their `online` has been set to `true`.\r\n3. They've disconnected, so their `online` has been set to `false`.\r\n\r\nWe'll print an appropriate message in each of these cases.\r\n\r\nTo `client/src/main.rs`, add:\r\n\r\n```rust\r\n/// Our `User::on_update` callback:\r\n/// print a notification about name and status changes.\r\nfn on_user_updated(old: &User, new: &User, _: Option<&ReducerEvent>) {\r\n if old.name != new.name {\r\n println!(\r\n \"User {} renamed to {}.\",\r\n user_name_or_identity(old),\r\n user_name_or_identity(new)\r\n );\r\n }\r\n if old.online && !new.online {\r\n println!(\"User {} disconnected.\", user_name_or_identity(new));\r\n }\r\n if !old.online && new.online {\r\n println!(\"User {} connected.\", user_name_or_identity(new));\r\n }\r\n}\r\n```\r\n\r\n### Print messages\r\n\r\nWhen 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.\r\n\r\nTo 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`.\r\n\r\nWe'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`.\r\n\r\nTo `client/src/main.rs`, add:\r\n\r\n```rust\r\n/// Our `Message::on_insert` callback: print new messages.\r\nfn on_message_inserted(message: &Message, reducer_event: Option<&ReducerEvent>) {\r\n if reducer_event.is_some() {\r\n print_message(message);\r\n }\r\n}\r\n\r\nfn print_message(message: &Message) {\r\n let sender = User::filter_by_identity(message.sender.clone())\r\n .map(|u| user_name_or_identity(&u))\r\n .unwrap_or_else(|| \"unknown\".to_string());\r\n println!(\"{}: {}\", sender, message.text);\r\n}\r\n```\r\n\r\n### Print past messages in order\r\n\r\nMessages 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.\r\n\r\nWe'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.\r\n\r\nTo `client/src/main.rs`, add:\r\n\r\n```rust\r\n/// Our `on_subscription_applied` callback:\r\n/// sort all past messages and print them in timestamp order.\r\nfn on_sub_applied() {\r\n let mut messages = Message::iter().collect::>();\r\n messages.sort_by_key(|m| m.sent);\r\n for message in messages {\r\n print_message(&message);\r\n }\r\n}\r\n```\r\n\r\n### Warn if our name was rejected\r\n\r\nWe 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`.\r\n\r\nEach reducer callback takes at least two arguments:\r\n\r\n1. The `Identity` of the client who requested the reducer invocation.\r\n2. 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`.\r\n\r\nIn addition, it takes a reference to each of the arguments passed to the reducer itself.\r\n\r\nThese callbacks will be invoked in one of two cases:\r\n\r\n1. If the reducer was successful and altered any of our subscribed rows.\r\n2. If we requested an invocation which failed.\r\n\r\nNote that a status of `Failed` or `OutOfEnergy` implies that the caller identity is our own identity.\r\n\r\nWe 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.\r\n\r\nTo `client/src/main.rs`, add:\r\n\r\n```rust\r\n/// Our `on_set_name` callback: print a warning if the reducer failed.\r\nfn on_name_set(_sender: &Identity, status: &Status, name: &String) {\r\n if let Status::Failed(err) = status {\r\n eprintln!(\"Failed to change name to {:?}: {}\", name, err);\r\n }\r\n}\r\n```\r\n\r\n### Warn if our message was rejected\r\n\r\nWe handle warnings on rejected messages the same way as rejected names, though the types and the error message are different.\r\n\r\nTo `client/src/main.rs`, add:\r\n\r\n```rust\r\n/// Our `on_send_message` callback: print a warning if the reducer failed.\r\nfn on_message_sent(_sender: &Identity, status: &Status, text: &String) {\r\n if let Status::Failed(err) = status {\r\n eprintln!(\"Failed to send message {:?}: {}\", text, err);\r\n }\r\n}\r\n```\r\n\r\n### Exit on disconnect\r\n\r\nWe 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.\r\n\r\nTo `client/src/main.rs`, add:\r\n\r\n```rust\r\n/// Our `on_disconnect` callback: print a note, then exit the process.\r\nfn on_disconnected() {\r\n eprintln!(\"Disconnected!\");\r\n std::process::exit(0)\r\n}\r\n```\r\n\r\n## Connect to the database\r\n\r\nNow 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.\r\n\r\n`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`.\r\n\r\nTo `client/src/main.rs`, add:\r\n\r\n```rust\r\n/// The URL of the SpacetimeDB instance hosting our chat module.\r\nconst SPACETIMEDB_URI: &str = \"http://localhost:3000\";\r\n\r\n/// The module name we chose when we published our module.\r\nconst DB_NAME: &str = \"\";\r\n\r\n/// Load credentials from a file and connect to the database.\r\nfn connect_to_db() {\r\n connect(\r\n SPACETIMEDB_URI,\r\n DB_NAME,\r\n load_credentials(CREDS_DIR).expect(\"Error reading stored credentials\"),\r\n )\r\n .expect(\"Failed to connect\");\r\n}\r\n```\r\n\r\n## Subscribe to queries\r\n\r\nSpacetimeDB 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.\r\n\r\nTo `client/src/main.rs`, add:\r\n\r\n```rust\r\n/// Register subscriptions for all rows of both tables.\r\nfn subscribe_to_tables() {\r\n subscribe(&[\"SELECT * FROM User;\", \"SELECT * FROM Message;\"]).unwrap();\r\n}\r\n```\r\n\r\n## Handle user input\r\n\r\nA 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.\r\n\r\n`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`.\r\n\r\nTo `client/src/main.rs`, add:\r\n\r\n```rust\r\n/// Read each line of standard input, and either set our name or send a message as appropriate.\r\nfn user_input_loop() {\r\n for line in std::io::stdin().lines() {\r\n let Ok(line) = line else {\r\n panic!(\"Failed to read from stdin.\");\r\n };\r\n if let Some(name) = line.strip_prefix(\"/name \") {\r\n set_name(name.to_string());\r\n } else {\r\n send_message(line);\r\n }\r\n }\r\n}\r\n```\r\n\r\n## Run it\r\n\r\nChange your directory to the client app, then compile and run it. From the `quickstart-chat` directory, run:\r\n\r\n```bash\r\ncd client\r\ncargo run\r\n```\r\n\r\nYou should see something like:\r\n\r\n```\r\nUser d9e25c51996dea2f connected.\r\n```\r\n\r\nNow try sending a message. Type `Hello, world!` and press enter. You should see something like:\r\n\r\n```\r\nd9e25c51996dea2f: Hello, world!\r\n```\r\n\r\nNext, set your name. Type `/name `, replacing `` with your name. You should see something like:\r\n\r\n```\r\nUser d9e25c51996dea2f renamed to .\r\n```\r\n\r\nThen send another message. Type `Hello after naming myself.` and press enter. You should see:\r\n\r\n```\r\n: Hello after naming myself.\r\n```\r\n\r\nNow, 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:\r\n\r\n```\r\nUser connected.\r\n: Hello, world!\r\n: Hello after naming myself.\r\n```\r\n\r\n## What's next?\r\n\r\nYou 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).\r\n\r\nCheck out the [Rust SDK Reference](/docs/client-languages/rust/rust-sdk-reference) for a more comprehensive view of the SpacetimeDB Rust SDK.\r\n\r\nOur 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).\r\n\r\nOnce 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.\r\n\r\nYou 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).\r\n\r\nOr, you could extend the module and the client together, perhaps:\r\n\r\n- Adding a `moderator: bool` flag to `User` and allowing moderators to time-out or ban naughty chatters.\r\n- Adding a message of the day which gets shown to users whenever they connect, or some rules which get shown only to new users.\r\n- Supporting separate rooms or channels which users can join or leave, and maybe even direct messages.\r\n- Allowing users to set their status, which could be displayed alongside their username.\r\n", "hasPages": false, "editUrl": "index.md", "jumpLinks": [ @@ -1558,6 +1621,7 @@ export const docsConfig = { "identifier": "SDK Reference", "indexIdentifier": "SDK Reference", "hasPages": false, + "content": "# The SpacetimeDB Rust client SDK\r\n\r\nThe SpacetimeDB client SDK for Rust contains all the tools you need to build native clients for SpacetimeDB modules using Rust.\r\n\r\n## Install the SDK\r\n\r\nFirst, create a new project using `cargo new` and add the SpacetimeDB SDK to your dependencies:\r\n\r\n```bash\r\ncargo add spacetimedb\r\n```\r\n\r\n## Generate module bindings\r\n\r\nEach SpacetimeDB client depends on some bindings specific to your module. Create a `module_bindings` directory in your project's `src` directory and generate the Rust interface files using the Spacetime CLI. From your project directory, run:\r\n\r\n```bash\r\nmkdir -p src/module_bindings\r\nspacetime generate --lang rust \\\r\n --out-dir src/module_bindings \\\r\n --project-path PATH-TO-MODULE-DIRECTORY\r\n```\r\n\r\nReplace `PATH-TO-MODULE-DIRECTORY` with the path to your SpacetimeDB module.\r\n\r\nDeclare a `mod` for the bindings in your client's `src/main.rs`:\r\n\r\n```rust\r\nmod module_bindings;\r\n```\r\n\r\n## API at a glance\r\n\r\n| Definition | Description |\r\n| ------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |\r\n| Function [`module_bindings::connect`](#function-connect) | Autogenerated function to connect to a database. |\r\n| Function [`spacetimedb_sdk::disconnect`](#function-disconnect) | Close the active connection. |\r\n| Function [`spacetimedb_sdk::on_disconnect`](#function-on_disconnect) | Register a `FnMut` callback to run when a connection ends. |\r\n| Function [`spacetimedb_sdk::once_on_disconnect`](#function-once_on_disconnect) | Register a `FnOnce` callback to run the next time a connection ends. |\r\n| Function [`spacetimedb_sdk::remove_on_disconnect`](#function-remove_on_disconnect) | Cancel an `on_disconnect` or `once_on_disconnect` callback. |\r\n| Function [`spacetimedb_sdk::subscribe`](#function-subscribe) | Subscribe to queries with a `&[&str]`. |\r\n| Function [`spacetimedb_sdk::subscribe_owned`](#function-subscribe_owned) | Subscribe to queries with a `Vec`. |\r\n| Function [`spacetimedb_sdk::on_subscription_applied`](#function-on_subscription_applied) | Register a `FnMut` callback to run when a subscription's initial rows become available. |\r\n| 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. |\r\n| Function [`spacetimedb_sdk::remove_on_subscription_applied`](#function-remove_on_subscription_applied) | Cancel an `on_subscription_applied` or `once_on_subscription_applied` callback. |\r\n| Type [`spacetimedb_sdk::identity::Identity`](#type-identity) | A unique public identifier for a client. |\r\n| Type [`spacetimedb_sdk::identity::Token`](#type-token) | A private authentication token corresponding to an `Identity`. |\r\n| Type [`spacetimedb_sdk::identity::Credentials`](#type-credentials) | An `Identity` paired with its `Token`. |\r\n| Function [`spacetimedb_sdk::identity::identity`](#function-identity) | Return the current connection's `Identity`. |\r\n| Function [`spacetimedb_sdk::identity::token`](#function-token) | Return the current connection's `Token`. |\r\n| Function [`spacetimedb_sdk::identity::credentials`](#function-credentials) | Return the current connection's [`Credentials`](#type-credentials). |\r\n| 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. |\r\n| 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. |\r\n| Function [`spacetimedb_sdk::identity::remove_on_connect`](#function-remove_on_connect) | Cancel an `on_connect` or `once_on_connect` callback. |\r\n| Function [`spacetimedb_sdk::identity::load_credentials`](#function-load_credentials) | Load a saved [`Credentials`](#type-credentials) from a file. |\r\n| Function [`spacetimedb_sdk::identity::save_credentials`](#function-save_credentials) | Save a [`Credentials`](#type-credentials) to a file. |\r\n| Type [`module_bindings::{TABLE}`](#type-table) | Autogenerated `struct` type for a table, holding one row. |\r\n| 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. |\r\n| Trait [`spacetimedb_sdk::table::TableType`](#trait-tabletype) | Automatically implemented for all tables defined by a module. |\r\n| Method [`spacetimedb_sdk::table::TableType::count`](#method-count) | Count the number of subscribed rows in a table. |\r\n| Method [`spacetimedb_sdk::table::TableType::iter`](#method-iter) | Iterate over all subscribed rows. |\r\n| Method [`spacetimedb_sdk::table::TableType::filter`](#method-filter) | Iterate over a subset of subscribed rows matching a predicate. |\r\n| Method [`spacetimedb_sdk::table::TableType::find`](#method-find) | Return one subscribed row matching a predicate. |\r\n| Method [`spacetimedb_sdk::table::TableType::on_insert`](#method-on_insert) | Register a `FnMut` callback to run whenever a new subscribed row is inserted. |\r\n| Method [`spacetimedb_sdk::table::TableType::remove_on_insert`](#method-remove_on_insert) | Cancel an `on_insert` callback. |\r\n| Method [`spacetimedb_sdk::table::TableType::on_delete`](#method-on_delete) | Register a `FnMut` callback to run whenever a subscribed row is deleted. |\r\n| Method [`spacetimedb_sdk::table::TableType::remove_on_delete`](#method-remove_on_delete) | Cancel an `on_delete` callback. |\r\n| Trait [`spacetimedb_sdk::table::TableWithPrimaryKey`](#trait-tablewithprimarykey) | Automatically implemented for tables with a column designated `#[primarykey]`. |\r\n| Method [`spacetimedb_sdk::table::TableWithPrimaryKey::on_update`](#method-on_update) | Register a `FnMut` callback to run whenever an existing subscribed row is updated. |\r\n| Method [`spacetimedb_sdk::table::TableWithPrimaryKey::remove_on_update`](#method-remove_on_update) | Cancel an `on_update` callback. |\r\n| Type [`module_bindings::ReducerEvent`](#type-reducerevent) | Autogenerated enum with a variant for each reducer defined by the module. |\r\n| Type [`module_bindings::{REDUCER}Args`](#type-reducerargs) | Autogenerated `struct` type for a reducer, holding its arguments. |\r\n| Function [`module_bindings::{REDUCER}`](#function-reducer) | Autogenerated function to invoke a reducer. |\r\n| Function [`module_bindings::on_{REDUCER}`](#function-on_reducer) | Autogenerated function to register a `FnMut` callback to run whenever the reducer is invoked. |\r\n| 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. |\r\n| Function [`module_bindings::remove_on_{REDUCER}`](#function-remove_on_reducer) | Autogenerated function to cancel an `on_{REDUCER}` or `once_on_{REDUCER}` callback. |\r\n| Type [`spacetimedb_sdk::reducer::Status`](#type-status) | Enum representing reducer completion statuses. |\r\n\r\n## Connect to a database\r\n\r\n### Function `connect`\r\n\r\n```rust\r\nmodule_bindings::connect(\r\n spacetimedb_uri: impl TryInto,\r\n db_name: &str,\r\n credentials: Option,\r\n) -> anyhow::Result<()>\r\n```\r\n\r\nConnect to a database named `db_name` accessible over the internet at the URI `spacetimedb_uri`.\r\n\r\n| Argument | Type | Meaning |\r\n| ----------------- | --------------------- | ------------------------------------------------------------ |\r\n| `spacetimedb_uri` | `impl TryInto` | URI of the SpacetimeDB instance running the module. |\r\n| `db_name` | `&str` | Name of the module. |\r\n| `credentials` | `Option` | [`Credentials`](#type-credentials) to authenticate the user. |\r\n\r\nIf `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.\r\n\r\n```rust\r\nconst MODULE_NAME: &str = \"my-module-name\";\r\n\r\n// Connect to a local DB with a fresh identity\r\nconnect(\"http://localhost:3000\", MODULE_NAME, None)\r\n .expect(\"Connection failed\");\r\n\r\n// Connect to cloud with a fresh identity.\r\nconnect(\"https://testnet.spacetimedb.com\", MODULE_NAME, None)\r\n .expect(\"Connection failed\");\r\n\r\n// Connect with a saved identity\r\nconst CREDENTIALS_DIR: &str = \".my-module\";\r\nconnect(\r\n \"https://testnet.spacetimedb.com\",\r\n MODULE_NAME,\r\n load_credentials(CREDENTIALS_DIR)\r\n .expect(\"Error while loading credentials\"),\r\n).expect(\"Connection failed\");\r\n```\r\n\r\n### Function `disconnect`\r\n\r\n```rust\r\nspacetimedb_sdk::disconnect()\r\n```\r\n\r\nGracefully close the current WebSocket connection.\r\n\r\nIf there is no active connection, this operation does nothing.\r\n\r\n```rust\r\nconnect(SPACETIMEDB_URI, MODULE_NAME, credentials)\r\n .expect(\"Connection failed\");\r\n\r\nrun_app();\r\n\r\ndisconnect();\r\n```\r\n\r\n### Function `on_disconnect`\r\n\r\n```rust\r\nspacetimedb_sdk::on_disconnect(\r\n callback: impl FnMut() + Send + 'static,\r\n) -> DisconnectCallbackId\r\n```\r\n\r\nRegister a callback to be invoked when a connection ends.\r\n\r\n| Argument | Type | Meaning |\r\n| ---------- | ------------------------------- | ------------------------------------------------------ |\r\n| `callback` | `impl FnMut() + Send + 'static` | Callback to be invoked when subscriptions are applied. |\r\n\r\nThe callback will be invoked after calling [`disconnect`](#function-disconnect), or when a connection is closed by the server.\r\n\r\nThe returned `DisconnectCallbackId` can be passed to [`remove_on_disconnect`](#function-remove_on_disconnect) to unregister the callback.\r\n\r\n```rust\r\non_disconnect(|| println!(\"Disconnected!\"));\r\n\r\nconnect(SPACETIMEDB_URI, MODULE_NAME, credentials)\r\n .expect(\"Connection failed\");\r\n\r\ndisconnect();\r\n\r\n// Will print \"Disconnected!\"\r\n```\r\n\r\n### Function `once_on_disconnect`\r\n\r\n```rust\r\nspacetimedb_sdk::once_on_disconnect(\r\n callback: impl FnOnce() + Send + 'static,\r\n) -> DisconnectCallbackId\r\n```\r\n\r\nRegister a callback to be invoked the next time a connection ends.\r\n\r\n| Argument | Type | Meaning |\r\n| ---------- | ------------------------------- | ------------------------------------------------------ |\r\n| `callback` | `impl FnMut() + Send + 'static` | Callback to be invoked when subscriptions are applied. |\r\n\r\nThe callback will be invoked after calling [`disconnect`](#function-disconnect), or when a connection is closed by the server.\r\n\r\nThe callback will be unregistered after running.\r\n\r\nThe returned `DisconnectCallbackId` can be passed to [`remove_on_disconnect`](#function-remove_on_disconnect) to unregister the callback.\r\n\r\n```rust\r\nonce_on_disconnect(|| println!(\"Disconnected!\"));\r\n\r\nconnect(SPACETIMEDB_URI, MODULE_NAME, credentials)\r\n .expect(\"Connection failed\");\r\n\r\ndisconnect();\r\n\r\n// Will print \"Disconnected!\"\r\n\r\nconnect(SPACETIMEDB_URI, MODULE_NAME, credentials)\r\n .expect(\"Connection failed\");\r\n\r\ndisconnect();\r\n\r\n// Nothing printed this time.\r\n```\r\n\r\n### Function `remove_on_disconnect`\r\n\r\n```rust\r\nspacetimedb_sdk::remove_on_disconnect(\r\n id: DisconnectCallbackId,\r\n)\r\n```\r\n\r\nUnregister a previously-registered [`on_disconnect`](#function-on_disconnect) callback.\r\n\r\n| Argument | Type | Meaning |\r\n| -------- | ---------------------- | ------------------------------------------ |\r\n| `id` | `DisconnectCallbackId` | Identifier for the callback to be removed. |\r\n\r\nIf `id` does not refer to a currently-registered callback, this operation does nothing.\r\n\r\n```rust\r\nlet id = on_disconnect(|| unreachable!());\r\n\r\nremove_on_disconnect(id);\r\n\r\ndisconnect();\r\n\r\n// No `unreachable` panic.\r\n```\r\n\r\n## Subscribe to queries\r\n\r\n### Function `subscribe`\r\n\r\n```rust\r\nspacetimedb_sdk::subscribe(queries: &[&str]) -> anyhow::Result<()>\r\n```\r\n\r\nSubscribe to a set of queries, to be notified when rows which match those queries are altered.\r\n\r\n| Argument | Type | Meaning |\r\n| --------- | --------- | ---------------------------- |\r\n| `queries` | `&[&str]` | SQL queries to subscribe to. |\r\n\r\nThe `queries` should be a slice of strings representing SQL queries.\r\n\r\n`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.\r\n\r\n`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.\r\n\r\nA 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.\r\n\r\n```rust\r\nsubscribe(&[\"SELECT * FROM User;\", \"SELECT * FROM Message;\"])\r\n .expect(\"Called `subscribe` before `connect`\");\r\n```\r\n\r\n### Function `subscribe_owned`\r\n\r\n```rust\r\nspacetimedb_sdk::subscribe_owned(queries: Vec) -> anyhow::Result<()>\r\n```\r\n\r\nSubscribe to a set of queries, to be notified when rows which match those queries are altered.\r\n\r\n| Argument | Type | Meaning |\r\n| --------- | ------------- | ---------------------------- |\r\n| `queries` | `Vec` | SQL queries to subscribe to. |\r\n\r\nThe `queries` should be a `Vec` of `String`s representing SQL queries.\r\n\r\nA new call to `subscribe_owned` (or [`subscribe`](#function-subscribe)) will remove all previous subscriptions and replace them with the new `queries`.\r\nIf 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.\r\n\r\n`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.\r\n\r\n```rust\r\nlet query = format!(\"SELECT * FROM User WHERE name = '{}';\", compute_my_name());\r\n\r\nsubscribe_owned(vec![query])\r\n .expect(\"Called `subscribe_owned` before `connect`\");\r\n```\r\n\r\n### Function `on_subscription_applied`\r\n\r\n```rust\r\nspacetimedb_sdk::on_subscription_applied(\r\n callback: impl FnMut() + Send + 'static,\r\n) -> SubscriptionCallbackId\r\n```\r\n\r\nRegister a callback to be invoked the first time a subscription's matching rows becoming available.\r\n\r\n| Argument | Type | Meaning |\r\n| ---------- | ------------------------------- | ------------------------------------------------------ |\r\n| `callback` | `impl FnMut() + Send + 'static` | Callback to be invoked when subscriptions are applied. |\r\n\r\nThe 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.\r\n\r\nThe returned `SubscriptionCallbackId` can be passed to [`remove_on_subscription_applied`](#function-remove_on_subscription_applied) to unregister the callback.\r\n\r\n```rust\r\non_subscription_applied(|| println!(\"Subscription applied!\"));\r\n\r\nsubscribe(&[\"SELECT * FROM User;\"])\r\n .expect(\"Called `subscribe` before `connect`\");\r\n\r\nsleep(Duration::from_secs(1));\r\n\r\n// Will print \"Subscription applied!\"\r\n\r\nsubscribe(&[\"SELECT * FROM User;\", \"SELECT * FROM Message;\"])\r\n .expect(\"Called `subscribe` before `connect`\");\r\n\r\n// Will print again.\r\n```\r\n\r\n### Function `once_on_subscription_applied`\r\n\r\n```rust\r\nspacetimedb_sdk::once_on_subscription_applied(\r\n callback: impl FnOnce() + Send + 'static,\r\n) -> SubscriptionCallbackId\r\n```\r\n\r\nRegister a callback to be invoked the next time a subscription's matching rows become available.\r\n\r\n| Argument | Type | Meaning |\r\n| ---------- | ------------------------------- | ------------------------------------------------------ |\r\n| `callback` | `impl FnMut() + Send + 'static` | Callback to be invoked when subscriptions are applied. |\r\n\r\nThe 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.\r\n\r\nThe callback will be unregistered after running.\r\n\r\nThe returned `SubscriptionCallbackId` can be passed to [`remove_on_subscription_applied`](#function-remove_on_subscription_applied) to unregister the callback.\r\n\r\n```rust\r\nonce_on_subscription_applied(|| println!(\"Subscription applied!\"));\r\n\r\nsubscribe(&[\"SELECT * FROM User;\"])\r\n .expect(\"Called `subscribe` before `connect`\");\r\n\r\nsleep(Duration::from_secs(1));\r\n\r\n// Will print \"Subscription applied!\"\r\n\r\nsubscribe(&[\"SELECT * FROM User;\", \"SELECT * FROM Message;\"])\r\n .expect(\"Called `subscribe` before `connect`\");\r\n\r\n// Nothing printed this time.\r\n```\r\n\r\n### Function `remove_on_subscription_applied`\r\n\r\n```rust\r\nspacetimedb_sdk::remove_on_subscription_applied(\r\n id: SubscriptionCallbackId,\r\n)\r\n```\r\n\r\nUnregister a previously-registered [`on_subscription_applied`](#function-on_subscription_applied) callback.\r\n\r\n| Argument | Type | Meaning |\r\n| -------- | ------------------------ | ------------------------------------------ |\r\n| `id` | `SubscriptionCallbackId` | Identifier for the callback to be removed. |\r\n\r\nIf `id` does not refer to a currently-registered callback, this operation does nothing.\r\n\r\n```rust\r\nlet id = on_subscription_applied(|| println!(\"Subscription applied!\"));\r\n\r\nsubscribe(&[\"SELECT * FROM User;\"])\r\n .expect(\"Called `subscribe` before `connect`\");\r\n\r\nsleep(Duration::from_secs(1));\r\n\r\n// Will print \"Subscription applied!\"\r\n\r\nremove_on_subscription_applied(id);\r\n\r\nsubscribe(&[\"SELECT * FROM User;\", \"SELECT * FROM Message;\"])\r\n .expect(\"Called `subscribe` before `connect`\");\r\n\r\n// Nothing printed this time.\r\n```\r\n\r\n## Identify a client\r\n\r\n### Type `Identity`\r\n\r\n```rust\r\nspacetimedb_sdk::identity::Identity\r\n```\r\n\r\nA unique public identifier for a client connected to a database.\r\n\r\n### Type `Token`\r\n\r\n```rust\r\nspacetimedb_sdk::identity::Token\r\n```\r\n\r\nA private access token for a client connected to a database.\r\n\r\n### Type `Credentials`\r\n\r\n```rust\r\nspacetimedb_sdk::identity::Credentials\r\n```\r\n\r\nCredentials, including a private access token, sufficient to authenticate a client connected to a database.\r\n\r\n| Field | Type |\r\n| ---------- | ---------------------------- |\r\n| `identity` | [`Identity`](#type-identity) |\r\n| `token` | [`Token`](#type-token) |\r\n\r\n### Function `identity`\r\n\r\n```rust\r\nspacetimedb_sdk::identity::identity() -> Result\r\n```\r\n\r\nRead the current connection's public [`Identity`](#type-identity).\r\n\r\nReturns an error if:\r\n\r\n- [`connect`](#function-connect) has not yet been called.\r\n- We connected anonymously, and we have not yet received our credentials.\r\n\r\n```rust\r\nconnect(SPACETIMEDB_URI, DB_NAME, None)\r\n .expect(\"Failed to connect\");\r\n\r\nsleep(Duration::from_secs(1));\r\n\r\nprintln!(\"My identity is {:?}\", identity());\r\n\r\n// Prints \"My identity is Ok(Identity { bytes: [...several u8s...] })\"\r\n```\r\n\r\n### Function `token`\r\n\r\n```rust\r\nspacetimedb_sdk::identity::token() -> Result\r\n```\r\n\r\nRead the current connection's private [`Token`](#type-token).\r\n\r\nReturns an error if:\r\n\r\n- [`connect`](#function-connect) has not yet been called.\r\n- We connected anonymously, and we have not yet received our credentials.\r\n\r\n```rust\r\nconnect(SPACETIMEDB_URI, DB_NAME, None)\r\n .expect(\"Failed to connect\");\r\n\r\nsleep(Duration::from_secs(1));\r\n\r\nprintln!(\"My token is {:?}\", token());\r\n\r\n// Prints \"My token is Ok(Token {string: \"...several Base64 digits...\" })\"\r\n```\r\n\r\n### Function `credentials`\r\n\r\n```rust\r\nspacetimedb_sdk::identity::credentials() -> Result\r\n```\r\n\r\nRead the current connection's [`Credentials`](#type-credentials), including a public [`Identity`](#type-identity) and a private [`Token`](#type-token).\r\n\r\nReturns an error if:\r\n\r\n- [`connect`](#function-connect) has not yet been called.\r\n- We connected anonymously, and we have not yet received our credentials.\r\n\r\n```rust\r\nconnect(SPACETIMEDB_URI, DB_NAME, None)\r\n .expect(\"Failed to connect\");\r\n\r\nsleep(Duration::from_secs(1));\r\n\r\nprintln!(\"My credentials are {:?}\", credentials());\r\n\r\n// Prints \"My credentials are Ok(Credentials {\r\n// identity: Identity { bytes: [...several u8s...] },\r\n// token: Token { string: \"...several Base64 digits...\"},\r\n// })\"\r\n```\r\n\r\n### Function `on_connect`\r\n\r\n```rust\r\nspacetimedb_sdk::identity::on_connect(\r\n callback: impl FnMut(&Credentials) + Send + 'static,\r\n) -> ConnectCallbackId\r\n```\r\n\r\nRegister a callback to be invoked upon authentication with the database.\r\n\r\n| Argument | Type | Meaning |\r\n| ---------- | ----------------------------------------- | ------------------------------------------------------ |\r\n| `callback` | `impl FnMut(&Credentials) + Send + 'sync` | Callback to be invoked upon successful authentication. |\r\n\r\nThe 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.\r\n\r\nThe [`Credentials`](#type-credentials) passed to the callback can be saved and used to authenticate the same user in future connections.\r\n\r\nThe returned `ConnectCallbackId` can be passed to [`remove_on_connect`](#function-remove_on_connect) to unregister the callback.\r\n\r\n```rust\r\non_connect(\r\n |creds| println!(\"Successfully connected! My credentials are: {:?}\", creds)\r\n);\r\n\r\nconnect(SPACETIMEDB_URI, DB_NAME, None)\r\n .expect(\"Failed to connect\");\r\n\r\nsleep(Duration::from_secs(1));\r\n\r\n// Will print \"Successfully connected! My credentials are: \"\r\n// followed by a printed representation of the client's `Credentials`.\r\n```\r\n\r\n### Function `once_on_connect`\r\n\r\n```rust\r\nspacetimedb_sdk::identity::once_on_connect(\r\n callback: impl FnOnce(&Credentials) + Send + 'static,\r\n) -> ConnectCallbackId\r\n```\r\n\r\nRegister a callback to be invoked once upon authentication with the database.\r\n\r\n| Argument | Type | Meaning |\r\n| ---------- | ------------------------------------------ | ---------------------------------------------------------------- |\r\n| `callback` | `impl FnOnce(&Credentials) + Send + 'sync` | Callback to be invoked once upon next successful authentication. |\r\n\r\nThe 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.\r\n\r\nThe [`Credentials`](#type-credentials) passed to the callback can be saved and used to authenticate the same user in future connections.\r\n\r\nThe callback will be unregistered after running.\r\n\r\nThe returned `ConnectCallbackId` can be passed to [`remove_on_connect`](#function-remove_on_connect) to unregister the callback.\r\n\r\n### Function `remove_on_connect`\r\n\r\n```rust\r\nspacetimedb_sdk::identity::remove_on_connect(id: ConnectCallbackId)\r\n```\r\n\r\nUnregister a previously-registered [`on_connect`](#function-on_connect) or [`once_on_connect`](#function-once_on_connect) callback.\r\n\r\n| Argument | Type | Meaning |\r\n| -------- | ------------------- | ------------------------------------------ |\r\n| `id` | `ConnectCallbackId` | Identifier for the callback to be removed. |\r\n\r\nIf `id` does not refer to a currently-registered callback, this operation does nothing.\r\n\r\n```rust\r\nlet id = on_connect(|_creds| unreachable!());\r\n\r\nremove_on_connect(id);\r\n\r\nconnect(SPACETIMEDB_URI, DB_NAME, None)\r\n .expect(\"Failed to connect\");\r\n\r\nsleep(Duration::from_secs(1));\r\n\r\n// No `unreachable` panic.\r\n```\r\n\r\n### Function `load_credentials`\r\n\r\n```rust\r\nspacetimedb_sdk::identity::load_credentials(\r\n dirname: &str,\r\n) -> Result>\r\n```\r\n\r\nLoad a saved [`Credentials`](#type-credentials) from a file within `~/dirname`, if one exists.\r\n\r\n| Argument | Type | Meaning |\r\n| --------- | ------ | ----------------------------------------------------- |\r\n| `dirname` | `&str` | Name of a sub-directory in the user's home directory. |\r\n\r\n`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.\r\n\r\nReturns `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).\r\n\r\n```rust\r\nconst CREDENTIALS_DIR = \".my-module\";\r\n\r\nlet creds = load_credentials(CREDENTIALS_DIR)\r\n .expect(\"Error while loading credentials\");\r\n\r\nconnect(SPACETIMEDB_URI, DB_NAME, creds)\r\n .expect(\"Failed to connect\");\r\n```\r\n\r\n### Function `save_credentials`\r\n\r\n```rust\r\nspacetimedb_sdk::identity::save_credentials(\r\n dirname: &str,\r\n credentials: &Credentials,\r\n) -> Result<()>\r\n```\r\n\r\nStore a [`Credentials`](#type-credentials) to a file within `~/dirname`, to be later loaded with [`load_credentials`](#function-load_credentials).\r\n\r\n| Argument | Type | Meaning |\r\n| ------------- | -------------- | ----------------------------------------------------- |\r\n| `dirname` | `&str` | Name of a sub-directory in the user's home directory. |\r\n| `credentials` | `&Credentials` | [`Credentials`](#type-credentials) to store. |\r\n\r\n`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.\r\n\r\nReturns `Err` when IO or serialization fails.\r\n\r\n```rust\r\nconst CREDENTIALS_DIR = \".my-module\";\r\n\r\nlet creds = load_credentials(CREDENTIALS_DIRectory)\r\n .expect(\"Error while loading credentials\");\r\n\r\non_connect(|creds| {\r\n if let Err(e) = save_credentials(CREDENTIALS_DIR, creds) {\r\n eprintln!(\"Error while saving credentials: {:?}\", e);\r\n }\r\n});\r\n\r\nconnect(SPACETIMEDB_URI, DB_NAME, creds)\r\n .expect(\"Failed to connect\");\r\n```\r\n\r\n## View subscribed rows of tables\r\n\r\n### Type `{TABLE}`\r\n\r\n```rust\r\nmodule_bindings::{TABLE}\r\n```\r\n\r\nFor 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`.\r\n\r\n### Method `filter_by_{COLUMN}`\r\n\r\n```rust\r\nmodule_bindings::{TABLE}::filter_by_{COLUMN}(\r\n value: {COLUMN_TYPE},\r\n) -> {FILTER_RESULT}<{TABLE}>\r\n```\r\n\r\nFor 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`.\r\n\r\nThe method's return type depends on the column's attributes:\r\n\r\n- 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).\r\n- For non-unique columns, the `filter_by` method returns an `impl Iterator`.\r\n\r\n### Trait `TableType`\r\n\r\n```rust\r\nspacetimedb_sdk::table::TableType\r\n```\r\n\r\nEvery [generated table struct](#type-table) implements the trait `TableType`.\r\n\r\n#### Method `count`\r\n\r\n```rust\r\nTableType::count() -> usize\r\n```\r\n\r\nReturn the number of subscribed rows in the table, or 0 if there is no active connection.\r\n\r\nThis method acquires a global lock.\r\n\r\n```rust\r\nconnect(SPACETIMEDB_URI, DB_NAME, None)\r\n .expect(\"Failed to connect\");\r\n\r\non_subscription_applied(|| println!(\"There are {} users\", User::count()));\r\n\r\nsubscribe(&[\"SELECT * FROM User;\"])\r\n .unwrap();\r\n\r\nsleep(Duration::from_secs(1));\r\n\r\n// Will the number of `User` rows in the database.\r\n```\r\n\r\n#### Method `iter`\r\n\r\n```rust\r\nTableType::iter() -> impl Iterator\r\n```\r\n\r\nIterate over all the subscribed rows in the table.\r\n\r\nThis method acquires a global lock, but the iterator does not hold it.\r\n\r\nThis 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.\r\n\r\n```rust\r\nconnect(SPACETIMEDB_URI, DB_NAME, None)\r\n .expect(\"Failed to connect\");\r\n\r\non_subscription_applied(|| for user in User::iter() {\r\n println!(\"{:?}\", user);\r\n});\r\n\r\nsubscribe(&[\"SELECT * FROM User;\"])\r\n .unwrap();\r\n\r\nsleep(Duration::from_secs(1));\r\n\r\n// Will print a line for each `User` row in the database.\r\n```\r\n\r\n#### Method `filter`\r\n\r\n```rust\r\nTableType::filter(\r\n predicate: impl FnMut(&Self) -> bool,\r\n) -> impl Iterator\r\n```\r\n\r\nIterate over the subscribed rows in the table for which `predicate` returns `true`.\r\n\r\n| Argument | Type | Meaning |\r\n| ----------- | --------------------------- | ------------------------------------------------------------------------------- |\r\n| `predicate` | `impl FnMut(&Self) -> bool` | Test which returns `true` if a row should be included in the filtered iterator. |\r\n\r\nThis method acquires a global lock, and the `predicate` runs while the lock is held. The returned iterator does not hold the lock.\r\n\r\nThe `predicate` is called eagerly for each subscribed row in the table, even if the returned iterator is never consumed.\r\n\r\nThis 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`.\r\n\r\nClient authors should prefer calling [tables' generated `filter_by_{COLUMN}` methods](#method-filter_by_column) when possible rather than calling `TableType::filter`.\r\n\r\n```rust\r\nconnect(SPACETIMEDB_URI, DB_NAME, None)\r\n .expect(\"Failed to connect\");\r\n\r\non_subscription_applied(|| {\r\n for user in User::filter(|user| user.age >= 30\r\n && user.country == Country::USA) {\r\n println!(\"{:?}\", user);\r\n }\r\n});\r\n\r\nsubscribe(&[\"SELECT * FROM User;\"])\r\n .unwrap();\r\n\r\nsleep(Duration::from_secs(1));\r\n\r\n// Will print a line for each `User` row in the database\r\n// who is at least 30 years old and who lives in the United States.\r\n```\r\n\r\n#### Method `find`\r\n\r\n```rust\r\nTableType::find(\r\n predicate: impl FnMut(&Self) -> bool,\r\n) -> Option\r\n```\r\n\r\nLocate a subscribed row for which `predicate` returns `true`, if one exists.\r\n\r\n| Argument | Type | Meaning |\r\n| ----------- | --------------------------- | ------------------------------------------------------ |\r\n| `predicate` | `impl FnMut(&Self) -> bool` | Test which returns `true` if a row should be returned. |\r\n\r\nThis method acquires a global lock.\r\n\r\nIf multiple subscribed rows match `predicate`, one is chosen arbitrarily. The choice may not be stable across different calls to `find` with the same `predicate`.\r\n\r\nClient authors should prefer calling [tables' generated `filter_by_{COLUMN}` methods](#method-filter_by_column) when possible rather than calling `TableType::find`.\r\n\r\n```rust\r\nconnect(SPACETIMEDB_URI, DB_NAME, None)\r\n .expect(\"Failed to connect\");\r\n\r\non_subscription_applied(|| {\r\n if let Some(tyler) = User::find(|user| user.first_name == \"Tyler\"\r\n && user.surname == \"Cloutier\") {\r\n println!(\"Found Tyler: {:?}\", tyler);\r\n } else {\r\n println!(\"Tyler isn't registered :(\");\r\n }\r\n});\r\n\r\nsubscribe(&[\"SELECT * FROM User;\"])\r\n .unwrap();\r\n\r\nsleep(Duration::from_secs(1));\r\n\r\n// Will tell us whether Tyler Cloutier is registered in the database.\r\n```\r\n\r\n#### Method `on_insert`\r\n\r\n```rust\r\nTableType::on_insert(\r\n callback: impl FnMut(&Self, Option<&ReducerEvent>) + Send + 'static,\r\n) -> InsertCallbackId\r\n```\r\n\r\nRegister an `on_insert` callback for when a subscribed row is newly inserted into the database.\r\n\r\n| Argument | Type | Meaning |\r\n| ---------- | ----------------------------------------------------------- | ------------------------------------------------------ |\r\n| `callback` | `impl FnMut(&Self, Option<&ReducerEvent>) + Send + 'static` | Callback to run whenever a subscribed row is inserted. |\r\n\r\nThe callback takes two arguments:\r\n\r\n- `row: &Self`, the newly-inserted row value.\r\n- `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.\r\n\r\nThe returned `InsertCallbackId` can be passed to [`remove_on_insert`](#method-remove_on_insert) to remove the callback.\r\n\r\n```rust\r\nconnect(SPACETIMEDB_URI, DB_NAME, None)\r\n .expect(\"Failed to connect\");\r\n\r\nUser::on_insert(|user, reducer_event| {\r\n if let Some(reducer_event) = reducer_event {\r\n println!(\"New user inserted by reducer {:?}: {:?}\", reducer_event, user);\r\n } else {\r\n println!(\"New user received during subscription update: {:?}\", user);\r\n }\r\n});\r\n\r\nsubscribe(&[\"SELECT * FROM User;\"])\r\n .unwrap();\r\n\r\nsleep(Duration::from_secs(1));\r\n\r\n// Will print a note whenever a new `User` row is inserted.\r\n```\r\n\r\n#### Method `remove_on_insert`\r\n\r\n```rust\r\nTableType::remove_on_insert(id: InsertCallbackId)\r\n```\r\n\r\nUnregister a previously-registered [`on_insert`](#method-on_insert) callback.\r\n\r\n| Argument | Type | Meaning |\r\n| -------- | ------------------------ | ----------------------------------------------------------------------- |\r\n| `id` | `InsertCallbackId` | Identifier for the [`on_insert`](#method-on_insert) callback to remove. |\r\n\r\nIf `id` does not refer to a currently-registered callback, this operation does nothing.\r\n\r\n```rust\r\nconnect(SPACETIMEDB_URI, DB_NAME, None)\r\n .expect(\"Failed to connect\");\r\n\r\nlet id = User::on_insert(|_, _| unreachable!());\r\n\r\nUser::remove_on_insert(id);\r\n\r\nsubscribe(&[\"SELECT * FROM User;\"])\r\n .unwrap();\r\n\r\nsleep(Duration::from_secs(1));\r\n\r\n// No `unreachable` panic.\r\n```\r\n\r\n#### Method `on_delete`\r\n\r\n```rust\r\nTableType::on_delete(\r\n callback: impl FnMut(&Self, Option<&ReducerEvent>) + Send + 'static,\r\n) -> DeleteCallbackId\r\n```\r\n\r\nRegister an `on_delete` callback for when a subscribed row is removed from the database.\r\n\r\n| Argument | Type | Meaning |\r\n| ---------- | ----------------------------------------------------------- | ----------------------------------------------------- |\r\n| `callback` | `impl FnMut(&Self, Option<&ReducerEvent>) + Send + 'static` | Callback to run whenever a subscribed row is deleted. |\r\n\r\nThe callback takes two arguments:\r\n\r\n- `row: &Self`, the previously-present row which is no longer resident in the database.\r\n- `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.\r\n\r\nThe returned `DeleteCallbackId` can be passed to [`remove_on_delete`](#method-remove_on_delete) to remove the callback.\r\n\r\n```rust\r\nconnect(SPACETIMEDB_URI, DB_NAME, None)\r\n .expect(\"Failed to connect\");\r\n\r\nUser::on_delete(|user, reducer_event| {\r\n if let Some(reducer_event) = reducer_event {\r\n println!(\"User deleted by reducer {:?}: {:?}\", reducer_event, user);\r\n } else {\r\n println!(\"User no longer subscribed during subscription update: {:?}\", user);\r\n }\r\n});\r\n\r\nsubscribe(&[\"SELECT * FROM User;\"])\r\n .unwrap();\r\n\r\n// Invoke a reducer which will delete a `User` row.\r\ndelete_user_by_name(\"Tyler Cloutier\".to_string());\r\n\r\nsleep(Duration::from_secs(1));\r\n\r\n// Will print a note whenever a `User` row is inserted,\r\n// including \"User deleted by reducer ReducerEvent::DeleteUserByName(\r\n// DeleteUserByNameArgs { name: \"Tyler Cloutier\" }\r\n// ): User { first_name: \"Tyler\", surname: \"Cloutier\" }\"\r\n```\r\n\r\n#### Method `remove_on_delete`\r\n\r\n```rust\r\nTableType::remove_on_delete(id: DeleteCallbackId)\r\n```\r\n\r\nUnregister a previously-registered [`on_delete`](#method-on_delete) callback.\r\n\r\n| Argument | Type | Meaning |\r\n| -------- | ------------------------ | ----------------------------------------------------------------------- |\r\n| `id` | `DeleteCallbackId` | Identifier for the [`on_delete`](#method-on_delete) callback to remove. |\r\n\r\nIf `id` does not refer to a currently-registered callback, this operation does nothing.\r\n\r\n```rust\r\nconnect(SPACETIMEDB_URI, DB_NAME, None)\r\n .expect(\"Failed to connect\");\r\n\r\nlet id = User::on_delete(|_, _| unreachable!());\r\n\r\nUser::remove_on_delete(id);\r\n\r\nsubscribe(&[\"SELECT * FROM User;\"])\r\n .unwrap();\r\n\r\n// Invoke a reducer which will delete a `User` row.\r\ndelete_user_by_name(\"Tyler Cloutier\".to_string());\r\n\r\nsleep(Duration::from_secs(1));\r\n\r\n// No `unreachable` panic.\r\n```\r\n\r\n### Trait `TableWithPrimaryKey`\r\n\r\n```rust\r\nspacetimedb_sdk::table::TableWithPrimaryKey\r\n```\r\n\r\n[Generated table structs](#type-table) with a column designated `#[primarykey]` implement the trait `TableWithPrimaryKey`.\r\n\r\n#### Method `on_update`\r\n\r\n```rust\r\nTableWithPrimaryKey::on_update(\r\n callback: impl FnMut(&Self, &Self, Option<&Self::ReducerEvent>) + Send + 'static,\r\n) -> UpdateCallbackId\r\n```\r\n\r\nRegister an `on_update` callback for when an existing row is modified.\r\n\r\n| Argument | Type | Meaning |\r\n| ---------- | ------------------------------------------------------------------ | ----------------------------------------------------- |\r\n| `callback` | `impl FnMut(&Self, &Self, Option<&ReducerEvent>) + Send + 'static` | Callback to run whenever a subscribed row is updated. |\r\n\r\nThe callback takes three arguments:\r\n\r\n- `old: &Self`, the previous row value which has been replaced in the database.\r\n- `new: &Self`, the updated row value which is now resident in the database.\r\n- `reducer_event: Option<&ReducerEvent>`, the [`ReducerEvent`](#type-reducerevent) which caused this row to be inserted.\r\n\r\nThe returned `UpdateCallbackId` can be passed to [`remove_on_update`](#method-remove_on_update) to remove the callback.\r\n\r\n```rust\r\nconnect(SPACETIMEDB_URI, DB_NAME, None)\r\n .expect(\"Failed to connect\");\r\n\r\nUser::on_update(|old, new, reducer_event| {\r\n println!(\"User updated by reducer {:?}: from {:?} to {:?}\", reducer_event, old, new);\r\n});\r\n\r\nsubscribe(&[\"SELECT * FROM User;\"])\r\n .unwrap();\r\n\r\n// Prints a line whenever a `User` row is updated by primary key.\r\n```\r\n\r\n#### Method `remove_on_update`\r\n\r\n```rust\r\nTableWithPrimaryKey::remove_on_update(id: UpdateCallbackId)\r\n```\r\n\r\n| Argument | Type | Meaning |\r\n| -------- | ------------------------ | ----------------------------------------------------------------------- |\r\n| `id` | `UpdateCallbackId` | Identifier for the [`on_update`](#method-on_update) callback to remove. |\r\n\r\nUnregister a previously-registered [`on_update`](#method-on_update) callback.\r\n\r\nIf `id` does not refer to a currently-registered callback, this operation does nothing.\r\n\r\n```rust\r\nconnect(SPACETIMEDB_URI, DB_NAME, None)\r\n .expect(\"Failed to connect\");\r\n\r\nlet id = User::on_update(|_, _, _| unreachable!);\r\n\r\nUser::remove_on_update(id);\r\n\r\nsubscribe(&[\"SELECT * FROM User;\"])\r\n .unwrap();\r\n\r\n// No `unreachable` panic.\r\n```\r\n\r\n## Observe and request reducer invocations\r\n\r\n### Type `ReducerEvent`\r\n\r\n```rust\r\nmodule_bindings::ReducerEvent\r\n```\r\n\r\n`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).\r\n\r\n[`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.\r\n\r\n### Type `{REDUCER}Args`\r\n\r\n```rust\r\nmodule_bindings::{REDUCER}Args\r\n```\r\n\r\nFor 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`.\r\n\r\nFor reducers which accept a `ReducerContext` as their first argument, the `ReducerContext` is not included in the arguments struct.\r\n\r\n### Function `{REDUCER}`\r\n\r\n```rust\r\nmodule_bindings::{REDUCER}({ARGS...})\r\n```\r\n\r\nFor 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`.\r\n\r\nFor reducers which accept a `ReducerContext` as their first argument, the `ReducerContext` is not included in the generated function's argument list.\r\n\r\n### Function `on_{REDUCER}`\r\n\r\n```rust\r\nmodule_bindings::on_{REDUCER}(\r\n callback: impl FnMut(&Identity, Status, {&ARGS...}) + Send + 'static,\r\n) -> ReducerCallbackId<{REDUCER}Args>\r\n```\r\n\r\nFor 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`.\r\n\r\n| Argument | Type | Meaning |\r\n| ---------- | ------------------------------------------------------------- | ------------------------------------------------ |\r\n| `callback` | `impl FnMut(&Identity, &Status, {&ARGS...}) + Send + 'static` | Callback to run whenever the reducer is invoked. |\r\n\r\nThe callback always accepts two arguments:\r\n\r\n- `caller: &Identity`, the [`Identity`](#type-identity) of the client which invoked the reducer.\r\n- `status: &Status`, the termination [`Status`](#type-status) of the reducer run.\r\n\r\nIn addition, the callback accepts a reference to each of the reducer's arguments.\r\n\r\nClients will only be notified of reducer runs if either of two criteria is met:\r\n\r\n- The reducer inserted, deleted or updated at least one row to which the client is subscribed.\r\n- The reducer invocation was requested by this client, and the run failed.\r\n\r\nThe `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.\r\n\r\n### Function `once_on_{REDUCER}`\r\n\r\n```rust\r\nmodule_bindings::once_on_{REDUCER}(\r\n callback: impl FnOnce(&Identity, &Status, {&ARGS...}) + Send + 'static,\r\n) -> ReducerCallbackId<{REDUCER}Args>\r\n```\r\n\r\nFor 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`.\r\n\r\n| Argument | Type | Meaning |\r\n| ---------- | -------------------------------------------------------------- | ----------------------------------------------------- |\r\n| `callback` | `impl FnOnce(&Identity, &Status, {&ARGS...}) + Send + 'static` | Callback to run the next time the reducer is invoked. |\r\n\r\nThe callback accepts the same arguments as an [on-reducer callback](#function-on_reducer), but may be a `FnOnce` rather than a `FnMut`.\r\n\r\nThe callback will be invoked in the same circumstances as an on-reducer callback.\r\n\r\nThe `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.\r\n\r\n### Function `remove_on_{REDUCER}`\r\n\r\n```rust\r\nmodule_bindings::remove_on_{REDUCER}(id: ReducerCallbackId<{REDUCER}Args>)\r\n```\r\n\r\nFor 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.\r\n\r\n| Argument | Type | Meaning |\r\n| -------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------- |\r\n| `id` | `UpdateCallbackId` | Identifier for the [`on_{REDUCER}`](#function-on_reducer) or [`once_on_{REDUCER}`](#function-once_on_reducer) callback to remove. |\r\n\r\nIf `id` does not refer to a currently-registered callback, this operation does nothing.\r\n\r\n### Type `Status`\r\n\r\n```rust\r\nspacetimedb_sdk::reducer::Status\r\n```\r\n\r\nAn enum whose variants represent possible reducer completion statuses.\r\n\r\nA `Status` is passed as the second argument to [`on_{REDUCER}`](#function-on_reducer) and [`once_on_{REDUCER}`](#function-once_on_reducer) callbacks.\r\n\r\n#### Variant `Status::Committed`\r\n\r\nThe reducer finished successfully, and its row changes were committed to the database.\r\n\r\n#### Variant `Status::Failed(String)`\r\n\r\nThe reducer failed, either by panicking or returning an `Err`.\r\n\r\n| Field | Type | Meaning |\r\n| ----- | -------- | --------------------------------------------------- |\r\n| 0 | `String` | The error message which caused the reducer to fail. |\r\n\r\n#### Variant `Status::OutOfEnergy`\r\n\r\nThe reducer was canceled because the module owner had insufficient energy to allow it to run to completion.\r\n", "editUrl": "SDK%20Reference.md", "jumpLinks": [ { @@ -1848,6 +1912,7 @@ export const docsConfig = { "title": "Typescript Client SDK Quick Start", "identifier": "index", "indexIdentifier": "index", + "content": "# Typescript Client SDK Quick Start\r\n\r\nIn this guide we'll show you how to get up and running with a simple SpacetimDB app with a client written in Typescript.\r\n\r\nWe'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.**\r\n\r\n## Project structure\r\n\r\nEnter 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:\r\n\r\n```bash\r\ncd quickstart-chat\r\n```\r\n\r\nWithin it, create a `client` react app:\r\n\r\n```bash\r\nnpx create-react-app client --template typescript\r\n```\r\n\r\nWe also need to install the `spacetime-client-sdk` package:\r\n\r\n```bash\r\ncd client\r\nnpm install @clockworklabs/spacetimedb-sdk\r\n```\r\n\r\n## Basic layout\r\n\r\nWe are going to start by creating a basic layout for our app. The page contains four sections:\r\n\r\n1. A profile section, where we can set our name.\r\n2. A message section, where we can see all the messages.\r\n3. A system section, where we can see system messages.\r\n4. A new message section, where we can send a new message.\r\n\r\nThe `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.\r\n\r\nReplace the entire contents of `client/src/App.tsx` with the following:\r\n\r\n```typescript\r\nimport React, { useEffect, useState } from \"react\";\r\nimport logo from \"./logo.svg\";\r\nimport \"./App.css\";\r\n\r\nexport type MessageType = {\r\n name: string;\r\n message: string;\r\n};\r\n\r\nfunction App() {\r\n const [newName, setNewName] = useState(\"\");\r\n const [settingName, setSettingName] = useState(false);\r\n const [name, setName] = useState(\"\");\r\n const [systemMessage, setSystemMessage] = useState(\"\");\r\n const [messages, setMessages] = useState([]);\r\n\r\n const [newMessage, setNewMessage] = useState(\"\");\r\n\r\n const onSubmitNewName = (e: React.FormEvent) => {\r\n e.preventDefault();\r\n setSettingName(false);\r\n // Fill in app logic here\r\n };\r\n\r\n const onMessageSubmit = (e: React.FormEvent) => {\r\n e.preventDefault();\r\n // Fill in app logic here\r\n setNewMessage(\"\");\r\n };\r\n\r\n return (\r\n
\r\n
\r\n

Profile

\r\n {!settingName ? (\r\n <>\r\n

{name}

\r\n {\r\n setSettingName(true);\r\n setNewName(name);\r\n }}\r\n >\r\n Edit Name\r\n \r\n \r\n ) : (\r\n
\r\n setNewName(e.target.value)}\r\n />\r\n \r\n \r\n )}\r\n
\r\n
\r\n

Messages

\r\n {messages.length < 1 &&

No messages

}\r\n
\r\n {messages.map((message, key) => (\r\n
\r\n

\r\n {message.name}\r\n

\r\n

{message.message}

\r\n
\r\n ))}\r\n
\r\n
\r\n
\r\n

System

\r\n
\r\n

{systemMessage}

\r\n
\r\n
\r\n
\r\n \r\n

New Message

\r\n setNewMessage(e.target.value)}\r\n >\r\n \r\n \r\n
\r\n
\r\n );\r\n}\r\n\r\nexport default App;\r\n```\r\n\r\nNow when you run `npm start`, you should see a basic chat app that does not yet send or receive messages.\r\n\r\n## Generate your module types\r\n\r\nThe `spacetime` CLI's `generate` command will generate client-side interfaces for the tables, reducers and types defined in your server module.\r\n\r\nIn your `quickstart-chat` directory, run:\r\n\r\n```bash\r\nmkdir -p client/src/module_bindings\r\nspacetime generate --lang typescript --out-dir client/src/module_bindings --project_path server\r\n```\r\n\r\nTake a look inside `client/src/module_bindings`. The CLI should have generated four files:\r\n\r\n```\r\nmodule_bindings\r\n├── message.ts\r\n├── send_message_reducer.ts\r\n├── set_name_reducer.ts\r\n└── user.ts\r\n```\r\n\r\nWe 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.\r\n\r\n> 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.\r\n\r\n```typescript\r\nimport { SpacetimeDBClient, Identity } from \"@clockworklabs/spacetimedb-sdk\";\r\n\r\nimport Message from \"./module_bindings/message\";\r\nimport User from \"./module_bindings/user\";\r\nimport SendMessageReducer from \"./module_bindings/send_message_reducer\";\r\nimport SetNameReducer from \"./module_bindings/set_name_reducer\";\r\nconsole.log(Message, User, SendMessageReducer, SetNameReducer);\r\n```\r\n\r\n## Create your SpacetimeDB client\r\n\r\nFirst, we need to create a SpacetimeDB client and connect to the module. Create your client at the top of the `App` function.\r\n\r\nWe 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.\r\n\r\nReplace `` 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`.\r\n\r\nAdd this before the `App` function declaration:\r\n\r\n```typescript\r\nlet token = localStorage.getItem(\"auth_token\") || undefined;\r\nvar spacetimeDBClient = new SpacetimeDBClient(\r\n \"ws://localhost:3000\",\r\n \"chat\",\r\n token\r\n);\r\n```\r\n\r\nInside the `App` function, add a few refs:\r\n\r\n```typescript\r\nlet local_identity = useRef(undefined);\r\nlet initialized = useRef(false);\r\nconst client = useRef(spacetimeDBClient);\r\n```\r\n\r\n## Register callbacks and connect\r\n\r\nWe need to handle several sorts of events:\r\n\r\n1. `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.\r\n2. `initialStateSync`: When we're informed of the backlog of past messages, we'll sort them and update the `message` section of the page.\r\n3. `Message.onInsert`: When we receive a new message, we'll update the `message` section of the page.\r\n4. `User.onInsert`: When a new user joins, we'll update the `system` section of the page with an appropiate message.\r\n5. `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.\r\n6. `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.\r\n7. `SendMessageReducer.on`: If the server rejects a message we send, we'll update the `system` section of the page with an appropriate error message.\r\n\r\nWe 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.\r\n\r\n### onConnect Callback\r\n\r\nOn connect SpacetimeDB will provide us with our client credentials.\r\n\r\nEach client has a credentials which consists of two parts:\r\n\r\n- An `Identity`, a unique public identifier. We're using these to identify `User` rows.\r\n- A `Token`, a private key which SpacetimeDB uses to authenticate the client.\r\n\r\nThese 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.\r\n\r\nWe want to store our local client identity in a stateful variable and also save our `token` to local storage for future connections.\r\n\r\nOnce 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.\r\n\r\nTo the body of `App`, add:\r\n\r\n```typescript\r\nclient.current.onConnect((token, identity) => {\r\n console.log(\"Connected to SpacetimeDB\");\r\n\r\n local_identity.current = identity;\r\n\r\n localStorage.setItem(\"auth_token\", token);\r\n\r\n client.current.subscribe([\"SELECT * FROM User\", \"SELECT * FROM Message\"]);\r\n});\r\n```\r\n\r\n### initialStateSync callback\r\n\r\nThis callback fires when our local client cache of the database is populated. This is a good time to set the initial messages list.\r\n\r\nWe'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`.\r\n\r\nTo 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.\r\n\r\nWhenever 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.\r\n\r\nWe 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`.\r\n\r\nTo the body of `App`, add:\r\n\r\n```typescript\r\nfunction userNameOrIdentity(user: User): string {\r\n console.log(`Name: ${user.name} `);\r\n if (user.name !== null) {\r\n return user.name || \"\";\r\n } else {\r\n var identityStr = new Identity(user.identity).toHexString();\r\n console.log(`Name: ${identityStr} `);\r\n return new Identity(user.identity).toHexString().substring(0, 8);\r\n }\r\n}\r\n\r\nfunction setAllMessagesInOrder() {\r\n let messages = Array.from(Message.all());\r\n messages.sort((a, b) => (a.sent > b.sent ? 1 : a.sent < b.sent ? -1 : 0));\r\n\r\n let messagesType: MessageType[] = messages.map((message) => {\r\n let sender_identity = User.filterByIdentity(message.sender);\r\n let display_name = sender_identity\r\n ? userNameOrIdentity(sender_identity)\r\n : \"unknown\";\r\n\r\n return {\r\n name: display_name,\r\n message: message.text,\r\n };\r\n });\r\n\r\n setMessages(messagesType);\r\n}\r\n\r\nclient.current.on(\"initialStateSync\", () => {\r\n setAllMessagesInOrder();\r\n var user = User.filterByIdentity(local_identity?.current?.toUint8Array()!);\r\n setName(userNameOrIdentity(user!));\r\n});\r\n```\r\n\r\n### Message.onInsert callback - Update messages\r\n\r\nWhen 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.\r\n\r\nTo the body of `App`, add:\r\n\r\n```typescript\r\nMessage.onInsert((message, reducerEvent) => {\r\n if (reducerEvent !== undefined) {\r\n setAllMessagesInOrder();\r\n }\r\n});\r\n```\r\n\r\n### User.onInsert callback - Notify about new users\r\n\r\nFor 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`.\r\n\r\nThese callbacks can fire in two contexts:\r\n\r\n- After a reducer runs, when the client's cache is updated about changes to subscribed rows.\r\n- After calling `subscribe`, when the client's cache is initialized with all existing matching rows.\r\n\r\nThis 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.\r\n\r\n`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.\r\n\r\nWe 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.\r\n\r\nTo the body of `App`, add:\r\n\r\n```typescript\r\n// Helper function to append a line to the systemMessage state\r\nfunction appendToSystemMessage(line: String) {\r\n setSystemMessage((prevMessage) => prevMessage + \"\\n\" + line);\r\n}\r\n\r\nUser.onInsert((user, reducerEvent) => {\r\n if (user.online) {\r\n appendToSystemMessage(`${userNameOrIdentity(user)} has connected.`);\r\n }\r\n});\r\n```\r\n\r\n### User.onUpdate callback - Notify about updated users\r\n\r\nBecause 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.\r\n\r\n`onUpdate` callbacks take three arguments: the old row, the new row, and a `ReducerEvent`.\r\n\r\nIn our module, users can be updated for three reasons:\r\n\r\n1. They've set their name using the `set_name` reducer.\r\n2. They're an existing user re-connecting, so their `online` has been set to `true`.\r\n3. They've disconnected, so their `online` has been set to `false`.\r\n\r\nWe'll update the `system` message in each of these cases.\r\n\r\nTo the body of `App`, add:\r\n\r\n```typescript\r\nUser.onUpdate((oldUser, user, reducerEvent) => {\r\n if (oldUser.online === false && user.online === true) {\r\n appendToSystemMessage(`${userNameOrIdentity(user)} has connected.`);\r\n } else if (oldUser.online === true && user.online === false) {\r\n appendToSystemMessage(`${userNameOrIdentity(user)} has disconnected.`);\r\n }\r\n\r\n if (user.name !== oldUser.name) {\r\n appendToSystemMessage(\r\n `User ${userNameOrIdentity(oldUser)} renamed to ${userNameOrIdentity(\r\n user\r\n )}.`\r\n );\r\n }\r\n});\r\n```\r\n\r\n### SetNameReducer.on callback - Handle errors and update profile name\r\n\r\nWe 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`.\r\n\r\nEach reducer callback takes two arguments:\r\n\r\n1. `ReducerEvent` that contains information about the reducer that triggered this event. It contains several fields. The ones we care about are:\r\n\r\n - `callerIdentity`: The `Identity` of the client that called the reducer.\r\n - `status`: The `Status` of the reducer run, one of `\"Committed\"`, `\"Failed\"` or `\"OutOfEnergy\"`.\r\n - `message`: The error message, if any, that the reducer returned.\r\n\r\n2. `ReducerArgs` which is an array containing the arguments with which the reducer was invoked.\r\n\r\nThese callbacks will be invoked in one of two cases:\r\n\r\n1. If the reducer was successful and altered any of our subscribed rows.\r\n2. If we requested an invocation which failed.\r\n\r\nNote that a status of `Failed` or `OutOfEnergy` implies that the caller identity is our own identity.\r\n\r\nWe 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.\r\n\r\nWe'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.\r\n\r\nIf the reducer status comes back as `committed`, we'll update the name in our app.\r\n\r\nTo the body of `App`, add:\r\n\r\n```typescript\r\nSetNameReducer.on((reducerEvent, reducerArgs) => {\r\n if (\r\n local_identity.current &&\r\n reducerEvent.callerIdentity.isEqual(local_identity.current)\r\n ) {\r\n if (reducerEvent.status === \"failed\") {\r\n appendToSystemMessage(`Error setting name: ${reducerEvent.message} `);\r\n } else if (reducerEvent.status === \"committed\") {\r\n setName(reducerArgs[0]);\r\n }\r\n }\r\n});\r\n```\r\n\r\n### SendMessageReducer.on callback - Handle errors\r\n\r\nWe 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.\r\n\r\nTo the body of `App`, add:\r\n\r\n```typescript\r\nSendMessageReducer.on((reducerEvent, reducerArgs) => {\r\n if (\r\n local_identity.current &&\r\n reducerEvent.callerIdentity.isEqual(local_identity.current)\r\n ) {\r\n if (reducerEvent.status === \"failed\") {\r\n appendToSystemMessage(`Error sending message: ${reducerEvent.message} `);\r\n }\r\n }\r\n});\r\n```\r\n\r\n## Update the UI button callbacks\r\n\r\nWe need to update the `onSubmitNewName` and `onMessageSubmit` callbacks to send the appropriate reducer to the module.\r\n\r\n`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`.\r\n\r\nAdd the following to the `onSubmitNewName` callback:\r\n\r\n```typescript\r\nSetNameReducer.call(newName);\r\n```\r\n\r\nAdd the following to the `onMessageSubmit` callback:\r\n\r\n```typescript\r\nSendMessageReducer.call(newMessage);\r\n```\r\n\r\n## Connecting to the module\r\n\r\nWe 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.\r\n\r\n```typescript\r\nuseEffect(() => {\r\n if (!initialized.current) {\r\n client.current.connect();\r\n initialized.current = true;\r\n }\r\n}, []);\r\n```\r\n\r\n## What's next?\r\n\r\nWhen 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.\r\n\r\nCongratulations! 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)\r\n\r\nFor 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).\r\n\r\n## Troubleshooting\r\n\r\nIf you encounter the following error:\r\n\r\n```\r\nTS2802: Type 'IterableIterator' can only be iterated through when using the '--downlevelIteration' flag or with a '--target' of 'es2015' or higher.\r\n```\r\n\r\nYou can fix it by changing your compiler target. Add the following to your `tsconfig.json` file:\r\n\r\n```json\r\n{\r\n \"compilerOptions\": {\r\n \"target\": \"es2015\"\r\n }\r\n}\r\n```\r\n", "hasPages": false, "editUrl": "index.md", "jumpLinks": [ @@ -1944,6 +2009,7 @@ export const docsConfig = { "identifier": "SDK Reference", "indexIdentifier": "SDK Reference", "hasPages": false, + "content": "# The SpacetimeDB Typescript client SDK\r\n\r\nThe 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.\r\n\r\n> You need a database created before use the client, so make sure to follow the Rust or C# Module Quickstart guides if need one.\r\n\r\n## Install the SDK\r\n\r\nFirst, create a new client project, and add the following to your `tsconfig.json` file:\r\n\r\n```json\r\n{\r\n \"compilerOptions\": {\r\n //You can use any target higher than this one\r\n //https://www.typescriptlang.org/tsconfig#target\r\n \"target\": \"es2015\"\r\n }\r\n}\r\n```\r\n\r\nThen add the SpacetimeDB SDK to your dependencies:\r\n\r\n```bash\r\ncd client\r\nnpm install @clockworklabs/spacetimedb-sdk\r\n```\r\n\r\nYou should have this folder layout starting from the root of your project:\r\n\r\n```bash\r\nquickstart-chat\r\n├── client\r\n│ ├── node_modules\r\n│ ├── public\r\n│ └── src\r\n└── server\r\n └── src\r\n```\r\n\r\n### Tip for utilities/scripts\r\n\r\nIf want to create a quick script to test your module bindings from the command line, you can use https://www.npmjs.com/package/tsx to execute TypeScript files.\r\n\r\nThen you create a `script.ts` file and add the imports, code and execute with:\r\n\r\n```bash\r\nnpx tsx src/script.ts\r\n```\r\n\r\n## Generate module bindings\r\n\r\nEach SpacetimeDB client depends on some bindings specific to your module. Create a `module_bindings` directory in your project's `src` directory and generate the Typescript interface files using the Spacetime CLI. From your project directory, run:\r\n\r\n```bash\r\nmkdir -p client/src/module_bindings\r\nspacetime generate --lang typescript \\\r\n --out-dir client/src/module_bindings \\\r\n --project-path server\r\n```\r\n\r\nAnd now you will get the files for the `reducers` & `tables`:\r\n\r\n```bash\r\nquickstart-chat\r\n├── client\r\n│ ├── node_modules\r\n│ ├── public\r\n│ └── src\r\n| └── module_bindings\r\n| ├── add_reducer.ts\r\n| ├── person.ts\r\n| └── say_hello_reducer.ts\r\n└── server\r\n └── src\r\n```\r\n\r\nImport the `module_bindings` in your client's _main_ file:\r\n\r\n```typescript\r\nimport { SpacetimeDBClient, Identity } from \"@clockworklabs/spacetimedb-sdk\";\r\n\r\nimport Person from \"./module_bindings/person\";\r\nimport AddReducer from \"./module_bindings/add_reducer\";\r\nimport SayHelloReducer from \"./module_bindings/say_hello_reducer\";\r\nconsole.log(Person, AddReducer, SayHelloReducer);\r\n```\r\n\r\n> 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.\r\n\r\n## API at a glance\r\n\r\n### Classes\r\n\r\n| Class | Description |\r\n| ----------------------------------------------- | ---------------------------------------------------------------- |\r\n| [`SpacetimeDBClient`](#class-spacetimedbclient) | The database client connection to a SpacetimeDB server. |\r\n| [`Identity`](#class-identity) | The user's public identity. |\r\n| [`{Table}`](#class-table) | `{Table}` is a placeholder for each of the generated tables. |\r\n| [`{Reducer}`](#class-reducer) | `{Reducer}` is a placeholder for each of the generated reducers. |\r\n\r\n### Class `SpacetimeDBClient`\r\n\r\nThe database client connection to a SpacetimeDB server.\r\n\r\nDefined in [spacetimedb-sdk.spacetimedb](https://github.com/clockworklabs/spacetimedb-typescript-sdk/blob/main/src/spacetimedb.ts):\r\n\r\n| Constructors | Description |\r\n| ----------------------------------------------------------------- | ------------------------------------------------------------------------ |\r\n| [`SpacetimeDBClient.constructor`](#spacetimedbclient-constructor) | Creates a new `SpacetimeDBClient` database client. |\r\n| Properties |\r\n| [`SpacetimeDBClient.identity`](#spacetimedbclient-identity) | The user's public identity. |\r\n| [`SpacetimeDBClient.live`](#spacetimedbclient-live) | Whether the client is connected. |\r\n| [`SpacetimeDBClient.token`](#spacetimedbclient-token) | The user's private authentication token. |\r\n| Methods | |\r\n| [`SpacetimeDBClient.connect`](#spacetimedbclient-connect) | Connect to a SpacetimeDB module. |\r\n| [`SpacetimeDBClient.disconnect`](#spacetimedbclient-disconnect) | Close the current connection. |\r\n| [`SpacetimeDBClient.subscribe`](#spacetimedbclient-subscribe) | Subscribe to a set of queries. |\r\n| Events | |\r\n| [`SpacetimeDBClient.onConnect`](#spacetimedbclient-onconnect) | Register a callback to be invoked upon authentication with the database. |\r\n| [`SpacetimeDBClient.onError`](#spacetimedbclient-onerror) | Register a callback to be invoked upon a error. |\r\n\r\n## Constructors\r\n\r\n### `SpacetimeDBClient` constructor\r\n\r\nCreates a new `SpacetimeDBClient` database client and set the initial parameters.\r\n\r\n```ts\r\nnew SpacetimeDBClient(host: string, name_or_address: string, auth_token?: string, protocol?: \"binary\" | \"json\")\r\n```\r\n\r\n#### Parameters\r\n\r\n| Name | Type | Description |\r\n| :---------------- | :--------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------ |\r\n| `host` | `string` | The host of the SpacetimeDB server. |\r\n| `name_or_address` | `string` | The name or address of the SpacetimeDB module. |\r\n| `auth_token?` | `string` | The credentials to use to connect to authenticate with SpacetimeDB. |\r\n| `protocol?` | `\"binary\"` \\| `\"json\"` | Define how encode the messages: `\"binary\"` \\| `\"json\"`. Binary is more efficient and compact, but JSON provides human-readable debug information. |\r\n\r\n#### Example\r\n\r\n```ts\r\nconst host = \"ws://localhost:3000\";\r\nconst name_or_address = \"database_name\";\r\nconst auth_token = undefined;\r\nconst protocol = \"binary\";\r\n\r\nvar spacetimeDBClient = new SpacetimeDBClient(\r\n host,\r\n name_or_address,\r\n auth_token,\r\n protocol\r\n);\r\n```\r\n\r\n## Properties\r\n\r\n### `SpacetimeDBClient` identity\r\n\r\nThe user's public [Identity](#class-identity).\r\n\r\n```\r\nidentity: Identity | undefined\r\n```\r\n\r\n---\r\n\r\n### `SpacetimeDBClient` live\r\n\r\nWhether the client is connected.\r\n\r\n```ts\r\nlive: boolean;\r\n```\r\n\r\n---\r\n\r\n### `SpacetimeDBClient` token\r\n\r\nThe user's private authentication token.\r\n\r\n```\r\ntoken: string | undefined\r\n```\r\n\r\n#### Parameters\r\n\r\n| Name | Type | Description |\r\n| :------------ | :----------------------------------------------------- | :------------------------------ |\r\n| `reducerName` | `string` | The name of the reducer to call |\r\n| `serializer` | [`Serializer`](../interfaces/serializer.Serializer.md) | - |\r\n\r\n---\r\n\r\n### `SpacetimeDBClient` connect\r\n\r\nConnect 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.\r\n\r\n```ts\r\nconnect(host: string?, name_or_address: string?, auth_token: string?): Promise\r\n```\r\n\r\n#### Parameters\r\n\r\n| Name | Type | Description |\r\n| :----------------- | :------- | :------------------------------------------------------------------------------------------------------------------------------------------ |\r\n| `host?` | `string` | The hostname of the SpacetimeDB server. Defaults to the value passed to the [constructor](#spacetimedbclient-constructor). |\r\n| `name_or_address?` | `string` | The name or address of the SpacetimeDB module. Defaults to the value passed to the [constructor](#spacetimedbclient-constructor). |\r\n| `auth_token?` | `string` | The credentials to use to authenticate with SpacetimeDB. Defaults to the value passed to the [constructor](#spacetimedbclient-constructor). |\r\n\r\n#### Returns\r\n\r\n`Promise`<`void`\\>\r\n\r\n#### Example\r\n\r\n```ts\r\nconst host = \"ws://localhost:3000\";\r\nconst name_or_address = \"database_name\";\r\nconst auth_token = undefined;\r\n\r\nvar spacetimeDBClient = new SpacetimeDBClient(\r\n host,\r\n name_or_address,\r\n auth_token\r\n);\r\n// Connect with the initial parameters\r\nspacetimeDBClient.connect();\r\n//Set the `auth_token`\r\nspacetimeDBClient.connect(undefined, undefined, NEW_TOKEN);\r\n```\r\n\r\n---\r\n\r\n### `SpacetimeDBClient` disconnect\r\n\r\nClose the current connection.\r\n\r\n```ts\r\ndisconnect(): void\r\n```\r\n\r\n#### Example\r\n\r\n```ts\r\nvar spacetimeDBClient = new SpacetimeDBClient(\r\n \"ws://localhost:3000\",\r\n \"database_name\"\r\n);\r\n\r\nspacetimeDBClient.disconnect();\r\n```\r\n\r\n---\r\n\r\n### `SpacetimeDBClient` subscribe\r\n\r\nSubscribe to a set of queries, to be notified when rows which match those queries are altered.\r\n\r\n> A new call to `subscribe` will remove all previous subscriptions and replace them with the new `queries`.\r\n> If any rows matched the previous subscribed queries but do not match the new queries,\r\n> those rows will be removed from the client cache, and [`{Table}.on_delete`](#table-ondelete) callbacks will be invoked for them.\r\n\r\n```ts\r\nsubscribe(queryOrQueries: string | string[]): void\r\n```\r\n\r\n#### Parameters\r\n\r\n| Name | Type | Description |\r\n| :--------------- | :--------------------- | :------------------------------- |\r\n| `queryOrQueries` | `string` \\| `string`[] | A `SQL` query or list of queries |\r\n\r\n#### Example\r\n\r\n```ts\r\nspacetimeDBClient.subscribe([\"SELECT * FROM User\", \"SELECT * FROM Message\"]);\r\n```\r\n\r\n## Events\r\n\r\n### `SpacetimeDBClient` onConnect\r\n\r\nRegister a callback to be invoked upon authentication with the database.\r\n\r\n```ts\r\nonConnect(callback: (token: string, identity: Identity) => void): void\r\n```\r\n\r\nThe 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.\r\n\r\nThe credentials passed to the callback can be saved and used to authenticate the same user in future connections.\r\n\r\n#### Parameters\r\n\r\n| Name | Type |\r\n| :--------- | :----------------------------------------------------------------------- |\r\n| `callback` | (`token`: `string`, `identity`: [`Identity`](#class-identity)) => `void` |\r\n\r\n#### Example\r\n\r\n```ts\r\nspacetimeDBClient.onConnect((token, identity) => {\r\n console.log(\"Connected to SpacetimeDB\");\r\n console.log(\"Token\", token);\r\n console.log(\"Identity\", identity);\r\n});\r\n```\r\n\r\n---\r\n\r\n### `SpacetimeDBClient` onError\r\n\r\nRegister a callback to be invoked upon an error.\r\n\r\n```ts\r\nonError(callback: (...args: any[]) => void): void\r\n```\r\n\r\n#### Parameters\r\n\r\n| Name | Type |\r\n| :--------- | :----------------------------- |\r\n| `callback` | (...`args`: `any`[]) => `void` |\r\n\r\n#### Example\r\n\r\n```ts\r\nspacetimeDBClient.onError((...args: any[]) => {\r\n console.error(\"ERROR\", args);\r\n});\r\n```\r\n\r\n### Class `Identity`\r\n\r\nA unique public identifier for a client connected to a database.\r\n\r\nDefined in [spacetimedb-sdk.identity](https://github.com/clockworklabs/spacetimedb-typescript-sdk/blob/main/src/identity.ts):\r\n\r\n| Constructors | Description |\r\n| ----------------------------------------------- | -------------------------------------------- |\r\n| [`Identity.constructor`](#identity-constructor) | Creates a new `Identity`. |\r\n| Methods | |\r\n| [`Identity.isEqual`](#identity-isequal) | Compare two identities for equality. |\r\n| [`Identity.toHexString`](#identity-tohexstring) | Print the identity as a hexadecimal string. |\r\n| Static methods | |\r\n| [`Identity.fromString`](#identity-fromstring) | Parse an Identity from a hexadecimal string. |\r\n\r\n## Constructors\r\n\r\n### `Identity` constructor\r\n\r\n```ts\r\nnew Identity(data: Uint8Array)\r\n```\r\n\r\n#### Parameters\r\n\r\n| Name | Type |\r\n| :----- | :----------- |\r\n| `data` | `Uint8Array` |\r\n\r\n## Methods\r\n\r\n### `Identity` isEqual\r\n\r\nCompare two identities for equality.\r\n\r\n```ts\r\nisEqual(other: Identity): boolean\r\n```\r\n\r\n#### Parameters\r\n\r\n| Name | Type |\r\n| :------ | :---------------------------- |\r\n| `other` | [`Identity`](#class-identity) |\r\n\r\n#### Returns\r\n\r\n`boolean`\r\n\r\n---\r\n\r\n### `Identity` toHexString\r\n\r\nPrint an `Identity` as a hexadecimal string.\r\n\r\n```ts\r\ntoHexString(): string\r\n```\r\n\r\n#### Returns\r\n\r\n`string`\r\n\r\n---\r\n\r\n### `Identity` fromString\r\n\r\nStatic method; parse an Identity from a hexadecimal string.\r\n\r\n```ts\r\nIdentity.fromString(str: string): Identity\r\n```\r\n\r\n#### Parameters\r\n\r\n| Name | Type |\r\n| :---- | :------- |\r\n| `str` | `string` |\r\n\r\n#### Returns\r\n\r\n[`Identity`](#class-identity)\r\n\r\n### Class `{Table}`\r\n\r\nFor 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`.\r\n\r\nThe generated class has a field for each of the table's columns, whose names are the column names converted to `snake_case`.\r\n\r\n| Properties | Description |\r\n| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |\r\n| [`Table.name`](#table-name) | The name of the class. |\r\n| [`Table.tableName`](#table-tableName) | The name of the table in the database. |\r\n| Methods | |\r\n| [`Table.isEqual`](#table-isequal) | Method to compare two identities. |\r\n| [`Table.all`](#table-all) | Return all the subscribed rows in the table. |\r\n| [`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. |\r\n| Events | |\r\n| [`Table.onInsert`](#table-oninsert) | Register an `onInsert` callback for when a subscribed row is newly inserted into the database. |\r\n| [`Table.removeOnInsert`](#table-removeoninsert) | Unregister a previously-registered [`onInsert`](#table-oninsert) callback. |\r\n| [`Table.onUpdate`](#table-onupdate) | Register an `onUpdate` callback for when an existing row is modified. |\r\n| [`Table.removeOnUpdate`](#table-removeonupdate) | Unregister a previously-registered [`onUpdate`](#table-onupdate) callback. |\r\n| [`Table.onDelete`](#table-ondelete) | Register an `onDelete` callback for when a subscribed row is removed from the database. |\r\n| [`Table.removeOnDelete`](#table-removeondelete) | Unregister a previously-registered [`onDelete`](#table-removeondelete) callback. |\r\n\r\n## Properties\r\n\r\n### {Table} name\r\n\r\n• **name**: `string`\r\n\r\nThe name of the `Class`.\r\n\r\n---\r\n\r\n### {Table} tableName\r\n\r\nThe name of the table in the database.\r\n\r\n▪ `Static` **tableName**: `string` = `\"Person\"`\r\n\r\n## Methods\r\n\r\n### {Table} all\r\n\r\nReturn all the subscribed rows in the table.\r\n\r\n```ts\r\n{Table}.all(): {Table}[]\r\n```\r\n\r\n#### Returns\r\n\r\n`{Table}[]`\r\n\r\n#### Example\r\n\r\n```ts\r\nvar spacetimeDBClient = new SpacetimeDBClient(\r\n \"ws://localhost:3000\",\r\n \"database_name\"\r\n);\r\n\r\nspacetimeDBClient.onConnect((token, identity) => {\r\n spacetimeDBClient.subscribe([\"SELECT * FROM Person\"]);\r\n\r\n setTimeout(() => {\r\n console.log(Person.all()); // Prints all the `Person` rows in the database.\r\n }, 5000);\r\n});\r\n```\r\n\r\n---\r\n\r\n### {Table} count\r\n\r\nReturn the number of subscribed rows in the table, or 0 if there is no active connection.\r\n\r\n```ts\r\n{Table}.count(): number\r\n```\r\n\r\n#### Returns\r\n\r\n`number`\r\n\r\n#### Example\r\n\r\n```ts\r\nvar spacetimeDBClient = new SpacetimeDBClient(\r\n \"ws://localhost:3000\",\r\n \"database_name\"\r\n);\r\n\r\nspacetimeDBClient.onConnect((token, identity) => {\r\n spacetimeDBClient.subscribe([\"SELECT * FROM Person\"]);\r\n\r\n setTimeout(() => {\r\n console.log(Person.count());\r\n }, 5000);\r\n});\r\n```\r\n\r\n---\r\n\r\n### {Table} filterBy{COLUMN}\r\n\r\nFor 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.\r\n\r\nThese methods are named `filterBy{COLUMN}`, where `{COLUMN}` is the column name converted to `camelCase`.\r\n\r\n```ts\r\n{Table}.filterBy{COLUMN}(value): {Table}[]\r\n```\r\n\r\n#### Parameters\r\n\r\n| Name | Type |\r\n| :------ | :-------------------------- |\r\n| `value` | The type of the `{COLUMN}`. |\r\n\r\n#### Returns\r\n\r\n`{Table}[]`\r\n\r\n#### Example\r\n\r\n```ts\r\nvar spacetimeDBClient = new SpacetimeDBClient(\r\n \"ws://localhost:3000\",\r\n \"database_name\"\r\n);\r\n\r\nspacetimeDBClient.onConnect((token, identity) => {\r\n spacetimeDBClient.subscribe([\"SELECT * FROM Person\"]);\r\n\r\n setTimeout(() => {\r\n console.log(Person.filterByName(\"John\")); // prints all the `Person` rows named John.\r\n }, 5000);\r\n});\r\n```\r\n\r\n---\r\n\r\n### {Table} fromValue\r\n\r\nDeserialize an `AlgebraicType` into this `{Table}`.\r\n\r\n```ts\r\n {Table}.fromValue(value: AlgebraicValue): {Table}\r\n```\r\n\r\n#### Parameters\r\n\r\n| Name | Type |\r\n| :------ | :--------------- |\r\n| `value` | `AlgebraicValue` |\r\n\r\n#### Returns\r\n\r\n`{Table}`\r\n\r\n---\r\n\r\n### {Table} getAlgebraicType\r\n\r\nSerialize `this` into an `AlgebraicType`.\r\n\r\n#### Example\r\n\r\n```ts\r\n{Table}.getAlgebraicType(): AlgebraicType\r\n```\r\n\r\n#### Returns\r\n\r\n`AlgebraicType`\r\n\r\n---\r\n\r\n### {Table} onInsert\r\n\r\nRegister an `onInsert` callback for when a subscribed row is newly inserted into the database.\r\n\r\n```ts\r\n{Table}.onInsert(callback: (value: {Table}, reducerEvent: ReducerEvent | undefined) => void): void\r\n```\r\n\r\n#### Parameters\r\n\r\n| Name | Type | Description |\r\n| :--------- | :---------------------------------------------------------------------------- | :----------------------------------------------------- |\r\n| `callback` | (`value`: `{Table}`, `reducerEvent`: `undefined` \\| `ReducerEvent`) => `void` | Callback to run whenever a subscribed row is inserted. |\r\n\r\n#### Example\r\n\r\n```ts\r\nvar spacetimeDBClient = new SpacetimeDBClient(\r\n \"ws://localhost:3000\",\r\n \"database_name\"\r\n);\r\nspacetimeDBClient.onConnect((token, identity) => {\r\n spacetimeDBClient.subscribe([\"SELECT * FROM Person\"]);\r\n});\r\n\r\nPerson.onInsert((person, reducerEvent) => {\r\n if (reducerEvent) {\r\n console.log(\"New person inserted by reducer\", reducerEvent, person);\r\n } else {\r\n console.log(\"New person received during subscription update\", person);\r\n }\r\n});\r\n```\r\n\r\n---\r\n\r\n### {Table} removeOnInsert\r\n\r\nUnregister a previously-registered [`onInsert`](#table-oninsert) callback.\r\n\r\n```ts\r\n{Table}.removeOnInsert(callback: (value: Person, reducerEvent: ReducerEvent | undefined) => void): void\r\n```\r\n\r\n#### Parameters\r\n\r\n| Name | Type |\r\n| :--------- | :---------------------------------------------------------------------------- |\r\n| `callback` | (`value`: `{Table}`, `reducerEvent`: `undefined` \\| `ReducerEvent`) => `void` |\r\n\r\n---\r\n\r\n### {Table} onUpdate\r\n\r\nRegister an `onUpdate` callback to run when an existing row is modified by primary key.\r\n\r\n```ts\r\n{Table}.onUpdate(callback: (oldValue: {Table}, newValue: {Table}, reducerEvent: ReducerEvent | undefined) => void): void\r\n```\r\n\r\n`onUpdate` callbacks are only meaningful for tables with a column declared as a primary key. Tables without primary keys will never fire `onUpdate` callbacks.\r\n\r\n#### Parameters\r\n\r\n| Name | Type | Description |\r\n| :--------- | :------------------------------------------------------------------------------------------------------ | :---------------------------------------------------- |\r\n| `callback` | (`oldValue`: `{Table}`, `newValue`: `{Table}`, `reducerEvent`: `undefined` \\| `ReducerEvent`) => `void` | Callback to run whenever a subscribed row is updated. |\r\n\r\n#### Example\r\n\r\n```ts\r\nvar spacetimeDBClient = new SpacetimeDBClient(\r\n \"ws://localhost:3000\",\r\n \"database_name\"\r\n);\r\nspacetimeDBClient.onConnect((token, identity) => {\r\n spacetimeDBClient.subscribe([\"SELECT * FROM Person\"]);\r\n});\r\n\r\nPerson.onUpdate((oldPerson, newPerson, reducerEvent) => {\r\n console.log(\"Person updated by reducer\", reducerEvent, oldPerson, newPerson);\r\n});\r\n```\r\n\r\n---\r\n\r\n### {Table} removeOnUpdate\r\n\r\nUnregister a previously-registered [`onUpdate`](#table-onUpdate) callback.\r\n\r\n```ts\r\n{Table}.removeOnUpdate(callback: (oldValue: {Table}, newValue: {Table}, reducerEvent: ReducerEvent | undefined) => void): void\r\n```\r\n\r\n#### Parameters\r\n\r\n| Name | Type |\r\n| :--------- | :------------------------------------------------------------------------------------------------------ |\r\n| `callback` | (`oldValue`: `{Table}`, `newValue`: `{Table}`, `reducerEvent`: `undefined` \\| `ReducerEvent`) => `void` |\r\n\r\n---\r\n\r\n### {Table} onDelete\r\n\r\nRegister an `onDelete` callback for when a subscribed row is removed from the database.\r\n\r\n```ts\r\n{Table}.onDelete(callback: (value: {Table}, reducerEvent: ReducerEvent | undefined) => void): void\r\n```\r\n\r\n#### Parameters\r\n\r\n| Name | Type | Description |\r\n| :--------- | :---------------------------------------------------------------------------- | :---------------------------------------------------- |\r\n| `callback` | (`value`: `{Table}`, `reducerEvent`: `undefined` \\| `ReducerEvent`) => `void` | Callback to run whenever a subscribed row is removed. |\r\n\r\n#### Example\r\n\r\n```ts\r\nvar spacetimeDBClient = new SpacetimeDBClient(\r\n \"ws://localhost:3000\",\r\n \"database_name\"\r\n);\r\nspacetimeDBClient.onConnect((token, identity) => {\r\n spacetimeDBClient.subscribe([\"SELECT * FROM Person\"]);\r\n});\r\n\r\nPerson.onDelete((person, reducerEvent) => {\r\n if (reducerEvent) {\r\n console.log(\"Person deleted by reducer\", reducerEvent, person);\r\n } else {\r\n console.log(\r\n \"Person no longer subscribed during subscription update\",\r\n person\r\n );\r\n }\r\n});\r\n```\r\n\r\n---\r\n\r\n### {Table} removeOnDelete\r\n\r\nUnregister a previously-registered [`onDelete`](#table-onDelete) callback.\r\n\r\n```ts\r\n{Table}.removeOnDelete(callback: (value: {Table}, reducerEvent: ReducerEvent | undefined) => void): void\r\n```\r\n\r\n#### Parameters\r\n\r\n| Name | Type |\r\n| :--------- | :---------------------------------------------------------------------------- |\r\n| `callback` | (`value`: `{Table}`, `reducerEvent`: `undefined` \\| `ReducerEvent`) => `void` |\r\n\r\n### Class `{Reducer}`\r\n\r\n`spacetime generate` defines an `{Reducer}` class in the `module_bindings` folder for each reducer defined by a module.\r\n\r\nThe class's name will be the reducer's name converted to `PascalCase`.\r\n\r\n| Static methods | Description |\r\n| ------------------------------- | ------------------------------------------------------------ |\r\n| [`Reducer.call`](#reducer-call) | Executes the reducer. |\r\n| Events | |\r\n| [`Reducer.on`](#reducer-on) | Register a callback to run each time the reducer is invoked. |\r\n\r\n## Static methods\r\n\r\n### {Reducer} call\r\n\r\nExecutes the reducer.\r\n\r\n```ts\r\n{Reducer}.call(): void\r\n```\r\n\r\n#### Example\r\n\r\n```ts\r\nSayHelloReducer.call();\r\n```\r\n\r\n## Events\r\n\r\n### {Reducer} on\r\n\r\nRegister a callback to run each time the reducer is invoked.\r\n\r\n```ts\r\n{Reducer}.on(callback: (reducerEvent: ReducerEvent, reducerArgs: any[]) => void): void\r\n```\r\n\r\nClients will only be notified of reducer runs if either of two criteria is met:\r\n\r\n- The reducer inserted, deleted or updated at least one row to which the client is subscribed.\r\n- The reducer invocation was requested by this client, and the run failed.\r\n\r\n#### Parameters\r\n\r\n| Name | Type |\r\n| :--------- | :---------------------------------------------------------- |\r\n| `callback` | `(reducerEvent: ReducerEvent, reducerArgs: any[]) => void)` |\r\n\r\n#### Example\r\n\r\n```ts\r\nSayHelloReducer.on((reducerEvent, reducerArgs) => {\r\n console.log(\"SayHelloReducer called\", reducerEvent, reducerArgs);\r\n});\r\n```\r\n", "editUrl": "SDK%20Reference.md", "jumpLinks": [ { @@ -2396,7 +2462,17 @@ export const docsConfig = { } ] } - ] + ], + "previousKey": { + "title": "Server Module Languages", + "route": "index", + "depth": 1 + }, + "nextKey": { + "title": "Module ABI Reference", + "route": "index", + "depth": 1 + } }, { "title": "Module ABI Reference", @@ -2411,6 +2487,7 @@ export const docsConfig = { "title": "Module ABI Reference", "identifier": "index", "indexIdentifier": "index", + "content": "# Module ABI Reference\r\n\r\nThis document specifies the _low level details_ of module-host interactions (_\"Module ABI\"_). _**Most users**_ looking to interact with the host will want to use derived and higher level functionality like [`bindings`], `#[spacetimedb(table)]`, and `#[derive(SpacetimeType)]` rather than this low level ABI. For more on those, read the [Rust module quick start][module_quick_start] guide and the [Rust module reference][module_ref].\r\n\r\nThe Module ABI is defined in [`bindings_sys::raw`] and is used by modules to interact with their host and perform various operations like:\r\n\r\n- logging,\r\n- transporting data,\r\n- scheduling reducers,\r\n- altering tables,\r\n- inserting and deleting rows,\r\n- querying tables.\r\n\r\nIn the next few sections, we'll define the functions that make up the ABI and what these functions do.\r\n\r\n## General notes\r\n\r\nThe functions in this ABI all use the [`C` ABI on the `wasm32` platform][wasm_c_abi]. They are specified in a Rust `extern \"C\" { .. }` block. For those more familiar with the `C` notation, an [appendix][c_header] is provided with equivalent definitions as would occur in a `.h` file.\r\n\r\nMany functions in the ABI take in- or out-pointers, e.g. `*const u8` and `*mut u8`. The WASM host itself does not have undefined behavior. However, what WASM does not consider a memory access violation could be one according to some other language's abstract machine. For example, running the following on a WASM host would violate Rust's rules around writing across allocations:\r\n\r\n```rust\r\nfn main() {\r\n let mut bytes = [0u8; 12];\r\n let other_bytes = [0u8; 4];\r\n unsafe { ffi_func_with_out_ptr_and_len(&mut bytes as *mut u8, 16); }\r\n assert_eq!(other_bytes, [0u8; 4]);\r\n}\r\n```\r\n\r\nWhen we note in this reference that traps occur or errors are returned on memory access violations, we only mean those that WASM can directly detected, and not cases like the one above.\r\n\r\nShould memory access violations occur, such as a buffer overrun, undefined behavior will never result, as it does not exist in WASM. However, in many cases, an error code will result.\r\n\r\nSome functions will treat UTF-8 strings _lossily_. That is, if the slice identified by a `(ptr, len)` contains non-UTF-8 bytes, these bytes will be replaced with `�` in the read string.\r\n\r\nMost functions return a `u16` value. This is how these functions indicate an error where a `0` value means that there were no errors. Such functions will instead return any data they need to through out pointers.\r\n\r\n## Logging\r\n\r\n```rust\r\n/// The error log level.\r\nconst LOG_LEVEL_ERROR: u8 = 0;\r\n/// The warn log level.\r\nconst LOG_LEVEL_WARN: u8 = 1;\r\n/// The info log level.\r\nconst LOG_LEVEL_INFO: u8 = 2;\r\n/// The debug log level.\r\nconst LOG_LEVEL_DEBUG: u8 = 3;\r\n/// The trace log level.\r\nconst LOG_LEVEL_TRACE: u8 = 4;\r\n/// The panic log level.\r\n///\r\n/// A panic level is emitted just before\r\n/// a fatal error causes the WASM module to trap.\r\nconst LOG_LEVEL_PANIC: u8 = 101;\r\n\r\n/// Log at `level` a `text` message occuring in `filename:line_number`\r\n/// with `target` being the module path at the `log!` invocation site.\r\n///\r\n/// These various pointers are interpreted lossily as UTF-8 strings.\r\n/// The data pointed to are copied. Ownership does not transfer.\r\n///\r\n/// See https://docs.rs/log/latest/log/struct.Record.html#method.target\r\n/// for more info on `target`.\r\n///\r\n/// Calls to the function cannot fail\r\n/// irrespective of memory access violations.\r\n/// If they occur, no message is logged.\r\nfn _console_log(\r\n // The level we're logging at.\r\n // One of the `LOG_*` constants above.\r\n level: u8,\r\n // The module path, if any, associated with the message\r\n // or to \"blame\" for the reason we're logging.\r\n //\r\n // This is a pointer to a buffer holding an UTF-8 encoded string.\r\n // When the pointer is `NULL`, `target` is ignored.\r\n target: *const u8,\r\n // The length of the buffer pointed to by `text`.\r\n // Unused when `target` is `NULL`.\r\n target_len: usize,\r\n // The file name, if any, associated with the message\r\n // or to \"blame\" for the reason we're logging.\r\n //\r\n // This is a pointer to a buffer holding an UTF-8 encoded string.\r\n // When the pointer is `NULL`, `filename` is ignored.\r\n filename: *const u8,\r\n // The length of the buffer pointed to by `text`.\r\n // Unused when `filename` is `NULL`.\r\n filename_len: usize,\r\n // The line number associated with the message\r\n // or to \"blame\" for the reason we're logging.\r\n line_number: u32,\r\n // A pointer to a buffer holding an UTF-8 encoded message to log.\r\n text: *const u8,\r\n // The length of the buffer pointed to by `text`.\r\n text_len: usize,\r\n);\r\n```\r\n\r\n## Buffer handling\r\n\r\n```rust\r\n/// Returns the length of buffer `bufh` without\r\n/// transferring ownership of the data into the function.\r\n///\r\n/// The `bufh` must have previously been allocating using `_buffer_alloc`.\r\n///\r\n/// Traps if the buffer does not exist.\r\nfn _buffer_len(\r\n // The buffer previously allocated using `_buffer_alloc`.\r\n // Ownership of the buffer is not taken.\r\n bufh: ManuallyDrop\r\n) -> usize;\r\n\r\n/// Consumes the buffer `bufh`,\r\n/// moving its contents to the WASM byte slice `(ptr, len)`.\r\n///\r\n/// Returns an error if the buffer does not exist\r\n/// or on any memory access violations associated with `(ptr, len)`.\r\nfn _buffer_consume(\r\n // The buffer to consume and move into `(ptr, len)`.\r\n // Ownership of the buffer and its contents are taken.\r\n // That is, `bufh` won't be usable after this call.\r\n bufh: Buffer,\r\n // A WASM out pointer to write the contents of `bufh` to.\r\n ptr: *mut u8,\r\n // The size of the buffer pointed to by `ptr`.\r\n // This size must match that of `bufh` or a trap will occur.\r\n len: usize\r\n);\r\n\r\n/// Creates a buffer of size `data_len` in the host environment.\r\n///\r\n/// The contents of the byte slice lasting `data_len` bytes\r\n/// at the `data` WASM pointer are read\r\n/// and written into the newly initialized buffer.\r\n///\r\n/// Traps on any memory access violations.\r\nfn _buffer_alloc(data: *const u8, data_len: usize) -> Buffer;\r\n```\r\n\r\n## Reducer scheduling\r\n\r\n```rust\r\n/// Schedules a reducer to be called asynchronously at `time`.\r\n///\r\n/// The reducer is named as the valid UTF-8 slice `(name, name_len)`,\r\n/// and is passed the slice `(args, args_len)` as its argument.\r\n///\r\n/// A generated schedule id is assigned to the reducer.\r\n/// This id is written to the pointer `out`.\r\n///\r\n/// Errors on any memory access violations,\r\n/// if `(name, name_len)` does not point to valid UTF-8,\r\n/// or if the `time` delay exceeds `64^6 - 1` milliseconds from now.\r\nfn _schedule_reducer(\r\n // A pointer to a buffer\r\n // with a valid UTF-8 string of `name_len` many bytes.\r\n name: *const u8,\r\n // The number of bytes in the `name` buffer.\r\n name_len: usize,\r\n // A pointer to a byte buffer of `args_len` many bytes.\r\n args: *const u8,\r\n // The number of bytes in the `args` buffer.\r\n args_len: usize,\r\n // When to call the reducer.\r\n time: u64,\r\n // The schedule ID is written to this out pointer on a successful call.\r\n out: *mut u64,\r\n);\r\n\r\n/// Unschedules a reducer\r\n/// using the same `id` generated as when it was scheduled.\r\n///\r\n/// This assumes that the reducer hasn't already been executed.\r\nfn _cancel_reducer(id: u64);\r\n```\r\n\r\n## Altering tables\r\n\r\n```rust\r\n/// Creates an index with the name `index_name` and type `index_type`,\r\n/// on a product of the given columns in `col_ids`\r\n/// in the table identified by `table_id`.\r\n///\r\n/// Here `index_name` points to a UTF-8 slice in WASM memory\r\n/// and `col_ids` points to a byte slice in WASM memory\r\n/// with each element being a column.\r\n///\r\n/// Currently only single-column-indices are supported\r\n/// and they may only be of the btree index type.\r\n/// In the former case, the function will panic,\r\n/// and in latter, an error is returned.\r\n///\r\n/// Returns an error on any memory access violations,\r\n/// if `(index_name, index_name_len)` is not valid UTF-8,\r\n/// or when a table with the provided `table_id` doesn't exist.\r\n///\r\n/// Traps if `index_type /= 0` or if `col_len /= 1`.\r\nfn _create_index(\r\n // A pointer to a buffer holding an UTF-8 encoded index name.\r\n index_name: *const u8,\r\n // The length of the buffer pointed to by `index_name`.\r\n index_name_len: usize,\r\n // The ID of the table to create the index for.\r\n table_id: u32,\r\n // The type of the index.\r\n // Must be `0` currently, that is, a btree-index.\r\n index_type: u8,\r\n // A pointer to a buffer holding a byte slice\r\n // where each element is the position\r\n // of a column to include in the index.\r\n col_ids: *const u8,\r\n // The length of the byte slice in `col_ids`. Must be `1`.\r\n col_len: usize,\r\n) -> u16;\r\n```\r\n\r\n## Inserting and deleting rows\r\n\r\n```rust\r\n/// Inserts a row into the table identified by `table_id`,\r\n/// where the row is read from the byte slice `row_ptr` in WASM memory,\r\n/// lasting `row_len` bytes.\r\n///\r\n/// Errors if there were unique constraint violations,\r\n/// if there were any memory access violations in associated with `row`,\r\n/// if the `table_id` doesn't identify a table,\r\n/// or if `(row, row_len)` doesn't decode from BSATN to a `ProductValue`\r\n/// according to the `ProductType` that the table's schema specifies.\r\nfn _insert(\r\n // The table to insert the row into.\r\n // The interpretation of `(row, row_len)` depends on this ID\r\n // as it's table schema determines how to decode the raw bytes.\r\n table_id: u32,\r\n // An in/out pointer to a byte buffer\r\n // holding the BSATN-encoded `ProductValue` row data to insert.\r\n //\r\n // The pointer is written to with the inserted row re-encoded.\r\n // This is due to auto-incrementing columns.\r\n row: *mut u8,\r\n // The length of the buffer pointed to by `row`.\r\n row_len: usize\r\n) -> u16;\r\n\r\n/// Deletes all rows in the table identified by `table_id`\r\n/// where the column identified by `col_id` matches the byte string,\r\n/// in WASM memory, pointed to by `value`.\r\n///\r\n/// Matching is defined by decoding of `value` to an `AlgebraicValue`\r\n/// according to the column's schema and then `Ord for AlgebraicValue`.\r\n///\r\n/// The number of rows deleted is written to the WASM pointer `out`.\r\n///\r\n/// Errors if there were memory access violations\r\n/// associated with `value` or `out`,\r\n/// if no columns were deleted,\r\n/// or if the column wasn't found.\r\nfn _delete_by_col_eq(\r\n // The table to delete rows from.\r\n table_id: u32,\r\n // The position of the column to match `(value, value_len)` against.\r\n col_id: u32,\r\n // A pointer to a byte buffer holding a BSATN-encoded `AlgebraicValue`\r\n // of the `AlgebraicType` that the table's schema specifies\r\n // for the column identified by `col_id`.\r\n value: *const u8,\r\n // The length of the buffer pointed to by `value`.\r\n value_len: usize,\r\n // An out pointer that the number of rows deleted is written to.\r\n out: *mut u32\r\n) -> u16;\r\n```\r\n\r\n## Querying tables\r\n\r\n```rust\r\n/// Queries the `table_id` associated with the given (table) `name`\r\n/// where `name` points to a UTF-8 slice\r\n/// in WASM memory of `name_len` bytes.\r\n///\r\n/// The table id is written into the `out` pointer.\r\n///\r\n/// Errors on memory access violations associated with `name`\r\n/// or if the table does not exist.\r\nfn _get_table_id(\r\n // A pointer to a buffer holding the name of the table\r\n // as a valid UTF-8 encoded string.\r\n name: *const u8,\r\n // The length of the buffer pointed to by `name`.\r\n name_len: usize,\r\n // An out pointer to write the table ID to.\r\n out: *mut u32\r\n) -> u16;\r\n\r\n/// Finds all rows in the table identified by `table_id`,\r\n/// where the row has a column, identified by `col_id`,\r\n/// with data matching the byte string,\r\n/// in WASM memory, pointed to at by `val`.\r\n///\r\n/// Matching is defined by decoding of `value`\r\n/// to an `AlgebraicValue` according to the column's schema\r\n/// and then `Ord for AlgebraicValue`.\r\n///\r\n/// The rows found are BSATN encoded and then concatenated.\r\n/// The resulting byte string from the concatenation\r\n/// is written to a fresh buffer\r\n/// with the buffer's identifier written to the WASM pointer `out`.\r\n///\r\n/// Errors if no table with `table_id` exists,\r\n/// if `col_id` does not identify a column of the table,\r\n/// if `(value, value_len)` cannot be decoded to an `AlgebraicValue`\r\n/// typed at the `AlgebraicType` of the column,\r\n/// or if memory access violations occurred associated with `value` or `out`.\r\nfn _iter_by_col_eq(\r\n // Identifies the table to find rows in.\r\n table_id: u32,\r\n // The position of the column in the table\r\n // to match `(value, value_len)` against.\r\n col_id: u32,\r\n // A pointer to a byte buffer holding a BSATN encoded\r\n // value typed at the `AlgebraicType` of the column.\r\n value: *const u8,\r\n // The length of the buffer pointed to by `value`.\r\n value_len: usize,\r\n // An out pointer to which the new buffer's id is written to.\r\n out: *mut Buffer\r\n) -> u16;\r\n\r\n/// Starts iteration on each row, as bytes,\r\n/// of a table identified by `table_id`.\r\n///\r\n/// The iterator is registered in the host environment\r\n/// under an assigned index which is written to the `out` pointer provided.\r\n///\r\n/// Errors if the table doesn't exist\r\n/// or if memory access violations occurred in association with `out`.\r\nfn _iter_start(\r\n // The ID of the table to start row iteration on.\r\n table_id: u32,\r\n // An out pointer to which an identifier\r\n // to the newly created buffer is written.\r\n out: *mut BufferIter\r\n) -> u16;\r\n\r\n/// Like [`_iter_start`], starts iteration on each row,\r\n/// as bytes, of a table identified by `table_id`.\r\n///\r\n/// The rows are filtered through `filter`, which is read from WASM memory\r\n/// and is encoded in the embedded language defined by `spacetimedb_lib::filter::Expr`.\r\n///\r\n/// The iterator is registered in the host environment\r\n/// under an assigned index which is written to the `out` pointer provided.\r\n///\r\n/// Errors if `table_id` doesn't identify a table,\r\n/// if `(filter, filter_len)` doesn't decode to a filter expression,\r\n/// or if there were memory access violations\r\n/// in association with `filter` or `out`.\r\nfn _iter_start_filtered(\r\n // The ID of the table to start row iteration on.\r\n table_id: u32,\r\n // A pointer to a buffer holding an encoded filter expression.\r\n filter: *const u8,\r\n // The length of the buffer pointed to by `filter`.\r\n filter_len: usize,\r\n // An out pointer to which an identifier\r\n // to the newly created buffer is written.\r\n out: *mut BufferIter\r\n) -> u16;\r\n\r\n/// Advances the registered iterator with the index given by `iter_key`.\r\n///\r\n/// On success, the next element (the row as bytes) is written to a buffer.\r\n/// The buffer's index is returned and written to the `out` pointer.\r\n/// If there are no elements left, an invalid buffer index is written to `out`.\r\n/// On failure however, the error is returned.\r\n///\r\n/// Errors if `iter` does not identify a registered `BufferIter`,\r\n/// or if there were memory access violations in association with `out`.\r\nfn _iter_next(\r\n // An identifier for the iterator buffer to advance.\r\n // Ownership of the buffer nor the identifier is moved into the function.\r\n iter: ManuallyDrop,\r\n // An out pointer to write the newly created buffer's identifier to.\r\n out: *mut Buffer\r\n) -> u16;\r\n\r\n/// Drops the entire registered iterator with the index given by `iter_key`.\r\n/// The iterator is effectively de-registered.\r\n///\r\n/// Returns an error if the iterator does not exist.\r\nfn _iter_drop(\r\n // An identifier for the iterator buffer to unregister / drop.\r\n iter: ManuallyDrop\r\n) -> u16;\r\n```\r\n\r\n## Appendix, `bindings.h`\r\n\r\n```c\r\n#include \r\n#include \r\n#include \r\n#include \r\n#include \r\n\r\ntypedef uint32_t Buffer;\r\ntypedef uint32_t BufferIter;\r\n\r\nvoid _console_log(\r\n uint8_t level,\r\n const uint8_t *target,\r\n size_t target_len,\r\n const uint8_t *filename,\r\n size_t filename_len,\r\n uint32_t line_number,\r\n const uint8_t *text,\r\n size_t text_len\r\n);\r\n\r\n\r\nBuffer _buffer_alloc(\r\n const uint8_t *data,\r\n size_t data_len\r\n);\r\nvoid _buffer_consume(\r\n Buffer bufh,\r\n uint8_t *into,\r\n size_t len\r\n);\r\nsize_t _buffer_len(Buffer bufh);\r\n\r\n\r\nvoid _schedule_reducer(\r\n const uint8_t *name,\r\n size_t name_len,\r\n const uint8_t *args,\r\n size_t args_len,\r\n uint64_t time,\r\n uint64_t *out\r\n);\r\nvoid _cancel_reducer(uint64_t id);\r\n\r\n\r\nuint16_t _create_index(\r\n const uint8_t *index_name,\r\n size_t index_name_len,\r\n uint32_t table_id,\r\n uint8_t index_type,\r\n const uint8_t *col_ids,\r\n size_t col_len\r\n);\r\n\r\n\r\nuint16_t _insert(\r\n uint32_t table_id,\r\n uint8_t *row,\r\n size_t row_len\r\n);\r\nuint16_t _delete_by_col_eq(\r\n uint32_t table_id,\r\n uint32_t col_id,\r\n const uint8_t *value,\r\n size_t value_len,\r\n uint32_t *out\r\n);\r\n\r\n\r\nuint16_t _get_table_id(\r\n const uint8_t *name,\r\n size_t name_len,\r\n uint32_t *out\r\n);\r\nuint16_t _iter_by_col_eq(\r\n uint32_t table_id,\r\n uint32_t col_id,\r\n const uint8_t *value,\r\n size_t value_len,\r\n Buffer *out\r\n);\r\nuint16_t _iter_drop(BufferIter iter);\r\nuint16_t _iter_next(BufferIter iter, Buffer *out);\r\nuint16_t _iter_start(uint32_t table_id, BufferIter *out);\r\nuint16_t _iter_start_filtered(\r\n uint32_t table_id,\r\n const uint8_t *filter,\r\n size_t filter_len,\r\n BufferIter *out\r\n);\r\n```\r\n\r\n[`bindings_sys::raw`]: https://github.com/clockworklabs/SpacetimeDB/blob/master/crates/bindings-sys/src/lib.rs#L44-L215\r\n[`bindings`]: https://github.com/clockworklabs/SpacetimeDB/blob/master/crates/bindings/src/lib.rs\r\n[module_ref]: /docs/languages/rust/rust-module-reference\r\n[module_quick_start]: /docs/languages/rust/rust-module-quick-start\r\n[wasm_c_abi]: https://github.com/WebAssembly/tool-conventions/blob/main/BasicCABI.md\r\n[c_header]: #appendix-bindingsh\r\n", "hasPages": false, "editUrl": "index.md", "jumpLinks": [ @@ -2462,7 +2539,17 @@ export const docsConfig = { ], "pages": [] } - ] + ], + "previousKey": { + "title": "Client SDK Languages", + "route": "index", + "depth": 1 + }, + "nextKey": { + "title": "HTTP API Reference", + "route": "index", + "depth": 1 + } }, { "title": "HTTP API Reference", @@ -2478,6 +2565,7 @@ export const docsConfig = { "identifier": "Databases", "indexIdentifier": "Databases", "hasPages": false, + "content": "# `/database` HTTP API\r\n\r\nThe 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.\r\n\r\n## At a glance\r\n\r\n| Route | Description |\r\n| ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |\r\n| [`/database/dns/:name GET`](#databasednsname-get) | Look up a database's address by its name. |\r\n| [`/database/reverse_dns/:address GET`](#databasereverse_dnsaddress-get) | Look up a database's name by its address. |\r\n| [`/database/set_name GET`](#databaseset_name-get) | Set a database's name, given its address. |\r\n| [`/database/ping GET`](#databaseping-get) | No-op. Used to determine whether a client can connect. |\r\n| [`/database/register_tld GET`](#databaseregister_tld-get) | Register a top-level domain. |\r\n| [`/database/request_recovery_code GET`](#databaserequest_recovery_code-get) | Request a recovery code to the email associated with an identity. |\r\n| [`/database/confirm_recovery_code GET`](#databaseconfirm_recovery_code-get) | Recover a login token from a recovery code. |\r\n| [`/database/publish POST`](#databasepublish-post) | Publish a database given its module code. |\r\n| [`/database/delete/:address POST`](#databasedeleteaddress-post) | Delete a database. |\r\n| [`/database/subscribe/:name_or_address GET`](#databasesubscribename_or_address-get) | Begin a [WebSocket connection](/docs/websocket-api-reference). |\r\n| [`/database/call/:name_or_address/:reducer POST`](#databasecallname_or_addressreducer-post) | Invoke a reducer in a database. |\r\n| [`/database/schema/:name_or_address GET`](#databaseschemaname_or_address-get) | Get the schema for a database. |\r\n| [`/database/schema/:name_or_address/:entity_type/:entity GET`](#databaseschemaname_or_addressentity_typeentity-get) | Get a schema for a particular table or reducer. |\r\n| [`/database/info/:name_or_address GET`](#databaseinfoname_or_address-get) | Get a JSON description of a database. |\r\n| [`/database/logs/:name_or_address GET`](#databaselogsname_or_address-get) | Retrieve logs from a database. |\r\n| [`/database/sql/:name_or_address POST`](#databasesqlname_or_address-post) | Run a SQL query against a database. |\r\n\r\n## `/database/dns/:name GET`\r\n\r\nLook up a database's address by its name.\r\n\r\nAccessible through the CLI as `spacetime dns lookup `.\r\n\r\n#### Parameters\r\n\r\n| Name | Value |\r\n| ------- | ------------------------- |\r\n| `:name` | The name of the database. |\r\n\r\n#### Returns\r\n\r\nIf a database with that name exists, returns JSON in the form:\r\n\r\n```typescript\r\n{ \"Success\": {\r\n \"domain\": string,\r\n \"address\": string\r\n} }\r\n```\r\n\r\nIf no database with that name exists, returns JSON in the form:\r\n\r\n```typescript\r\n{ \"Failure\": {\r\n \"domain\": string\r\n} }\r\n```\r\n\r\n## `/database/reverse_dns/:address GET`\r\n\r\nLook up a database's name by its address.\r\n\r\nAccessible through the CLI as `spacetime dns reverse-lookup
`.\r\n\r\n#### Parameters\r\n\r\n| Name | Value |\r\n| ---------- | ---------------------------- |\r\n| `:address` | The address of the database. |\r\n\r\n#### Returns\r\n\r\nReturns JSON in the form:\r\n\r\n```typescript\r\n{ \"names\": array }\r\n```\r\n\r\nwhere `` is a JSON array of strings, each of which is a name which refers to the database.\r\n\r\n## `/database/set_name GET`\r\n\r\nSet the name associated with a database.\r\n\r\nAccessible through the CLI as `spacetime dns set-name
`.\r\n\r\n#### Query Parameters\r\n\r\n| Name | Value |\r\n| -------------- | ------------------------------------------------------------------------- |\r\n| `address` | The address of the database to be named. |\r\n| `domain` | The name to register. |\r\n| `register_tld` | A boolean; whether to register the name as a TLD. Should usually be true. |\r\n\r\n#### Required Headers\r\n\r\n| Name | Value |\r\n| --------------- | ------------------------------------------------------------------------------------------- |\r\n| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). |\r\n\r\n#### Returns\r\n\r\nIf the name was successfully set, returns JSON in the form:\r\n\r\n```typescript\r\n{ \"Success\": {\r\n \"domain\": string,\r\n \"address\": string\r\n} }\r\n```\r\n\r\nIf the top-level domain is not registered, and `register_tld` was not specified, returns JSON in the form:\r\n\r\n```typescript\r\n{ \"TldNotRegistered\": {\r\n \"domain\": string\r\n} }\r\n```\r\n\r\nIf 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:\r\n\r\n```typescript\r\n{ \"PermissionDenied\": {\r\n \"domain\": string\r\n} }\r\n```\r\n\r\n> 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.\r\n\r\n## `/database/ping GET`\r\n\r\nDoes nothing and returns no data. Clients can send requests to this endpoint to determine whether they are able to connect to SpacetimeDB.\r\n\r\n## `/database/register_tld GET`\r\n\r\nRegister 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.\r\n\r\n> 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.\r\n\r\nAccessible through the CLI as `spacetime dns register-tld `.\r\n\r\n#### Query Parameters\r\n\r\n| Name | Value |\r\n| ----- | -------------------------------------- |\r\n| `tld` | New top-level domain name to register. |\r\n\r\n#### Required Headers\r\n\r\n| Name | Value |\r\n| --------------- | ------------------------------------------------------------------------------------------- |\r\n| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). |\r\n\r\n#### Returns\r\n\r\nIf the domain is successfully registered, returns JSON in the form:\r\n\r\n```typescript\r\n{ \"Success\": {\r\n \"domain\": string\r\n} }\r\n```\r\n\r\nIf the domain is already registered to the caller, returns JSON in the form:\r\n\r\n```typescript\r\n{ \"AlreadyRegistered\": {\r\n \"domain\": string\r\n} }\r\n```\r\n\r\nIf the domain is already registered to another identity, returns JSON in the form:\r\n\r\n```typescript\r\n{ \"Unauthorized\": {\r\n \"domain\": string\r\n} }\r\n```\r\n\r\n## `/database/request_recovery_code GET`\r\n\r\nRequest a recovery code or link via email, in order to recover the token associated with an identity.\r\n\r\nAccessible through the CLI as `spacetime identity recover `.\r\n\r\n#### Query Parameters\r\n\r\n| Name | Value |\r\n| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\r\n| `identity` | The identity whose token should be recovered. |\r\n| `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). |\r\n| `link` | A boolean; whether to send a clickable link rather than a recovery code. |\r\n\r\n## `/database/confirm_recovery_code GET`\r\n\r\nConfirm 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.\r\n\r\nAccessible through the CLI as `spacetime identity recover `.\r\n\r\n#### Query Parameters\r\n\r\n| Name | Value |\r\n| ---------- | --------------------------------------------- |\r\n| `identity` | The identity whose token should be recovered. |\r\n| `email` | The email which received the recovery code. |\r\n| `code` | The recovery code received via email. |\r\n\r\nOn success, returns JSON in the form:\r\n\r\n```typescript\r\n{\r\n \"identity\": string,\r\n \"token\": string\r\n}\r\n```\r\n\r\n## `/database/publish POST`\r\n\r\nPublish a database.\r\n\r\nAccessible through the CLI as `spacetime publish`.\r\n\r\n#### Query Parameters\r\n\r\n| Name | Value |\r\n| ----------------- | ------------------------------------------------------------------------------------------------ |\r\n| `host_type` | Optional; a SpacetimeDB module host type. Currently, only `\"wasmer\"` is supported. |\r\n| `clear` | A boolean; whether to clear any existing data when updating an existing database. |\r\n| `name_or_address` | The name of the database to publish or update, or the address of an existing database to update. |\r\n| `register_tld` | A boolean; whether to register the database's top-level domain. |\r\n\r\n#### Required Headers\r\n\r\n| Name | Value |\r\n| --------------- | ------------------------------------------------------------------------------------------- |\r\n| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). |\r\n\r\n#### Data\r\n\r\nA WebAssembly module in the [binary format](https://webassembly.github.io/spec/core/binary/index.html).\r\n\r\n#### Returns\r\n\r\nIf the database was successfully published, returns JSON in the form:\r\n\r\n```typescript\r\n{ \"Success\": {\r\n \"domain\": null | string,\r\n \"address\": string,\r\n \"op\": \"created\" | \"updated\"\r\n} }\r\n```\r\n\r\nIf the top-level domain for the requested name is not registered, returns JSON in the form:\r\n\r\n```typescript\r\n{ \"TldNotRegistered\": {\r\n \"domain\": string\r\n} }\r\n```\r\n\r\nIf 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:\r\n\r\n```typescript\r\n{ \"PermissionDenied\": {\r\n \"domain\": string\r\n} }\r\n```\r\n\r\n> 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.\r\n\r\n## `/database/delete/:address POST`\r\n\r\nDelete a database.\r\n\r\nAccessible through the CLI as `spacetime delete
`.\r\n\r\n#### Parameters\r\n\r\n| Name | Address |\r\n| ---------- | ---------------------------- |\r\n| `:address` | The address of the database. |\r\n\r\n#### Required Headers\r\n\r\n| Name | Value |\r\n| --------------- | ------------------------------------------------------------------------------------------- |\r\n| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). |\r\n\r\n## `/database/subscribe/:name_or_address GET`\r\n\r\nBegin a [WebSocket connection](/docs/websocket-api-reference) with a database.\r\n\r\n#### Parameters\r\n\r\n| Name | Value |\r\n| ------------------ | ---------------------------- |\r\n| `:name_or_address` | The address of the database. |\r\n\r\n#### Required Headers\r\n\r\nFor more information about WebSocket headers, see [RFC 6455](https://datatracker.ietf.org/doc/html/rfc6455).\r\n\r\n| Name | Value |\r\n| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |\r\n| `Sec-WebSocket-Protocol` | [`v1.bin.spacetimedb`](/docs/websocket-api-reference#binary-protocol) or [`v1.text.spacetimedb`](/docs/websocket-api-reference#text-protocol). |\r\n| `Connection` | `Updgrade` |\r\n| `Upgrade` | `websocket` |\r\n| `Sec-WebSocket-Version` | `13` |\r\n| `Sec-WebSocket-Key` | A 16-byte value, generated randomly by the client, encoded as Base64. |\r\n\r\n#### Optional Headers\r\n\r\n| Name | Value |\r\n| --------------- | ------------------------------------------------------------------------------------------- |\r\n| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). |\r\n\r\n## `/database/call/:name_or_address/:reducer POST`\r\n\r\nInvoke a reducer in a database.\r\n\r\n#### Parameters\r\n\r\n| Name | Value |\r\n| ------------------ | ------------------------------------ |\r\n| `:name_or_address` | The name or address of the database. |\r\n| `:reducer` | The name of the reducer. |\r\n\r\n#### Required Headers\r\n\r\n| Name | Value |\r\n| --------------- | ------------------------------------------------------------------------------------------- |\r\n| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). |\r\n\r\n#### Data\r\n\r\nA JSON array of arguments to the reducer.\r\n\r\n## `/database/schema/:name_or_address GET`\r\n\r\nGet a schema for a database.\r\n\r\nAccessible through the CLI as `spacetime describe `.\r\n\r\n#### Parameters\r\n\r\n| Name | Value |\r\n| ------------------ | ------------------------------------ |\r\n| `:name_or_address` | The name or address of the database. |\r\n\r\n#### Query Parameters\r\n\r\n| Name | Value |\r\n| -------- | ----------------------------------------------------------- |\r\n| `expand` | A boolean; whether to include full schemas for each entity. |\r\n\r\n#### Returns\r\n\r\nReturns a JSON object with two properties, `\"entities\"` and `\"typespace\"`. For example, on the default module generated by `spacetime init` with `expand=true`, returns:\r\n\r\n```typescript\r\n{\r\n \"entities\": {\r\n \"Person\": {\r\n \"arity\": 1,\r\n \"schema\": {\r\n \"elements\": [\r\n {\r\n \"algebraic_type\": {\r\n \"Builtin\": {\r\n \"String\": []\r\n }\r\n },\r\n \"name\": {\r\n \"some\": \"name\"\r\n }\r\n }\r\n ]\r\n },\r\n \"type\": \"table\"\r\n },\r\n \"__init__\": {\r\n \"arity\": 0,\r\n \"schema\": {\r\n \"elements\": [],\r\n \"name\": \"__init__\"\r\n },\r\n \"type\": \"reducer\"\r\n },\r\n \"add\": {\r\n \"arity\": 1,\r\n \"schema\": {\r\n \"elements\": [\r\n {\r\n \"algebraic_type\": {\r\n \"Builtin\": {\r\n \"String\": []\r\n }\r\n },\r\n \"name\": {\r\n \"some\": \"name\"\r\n }\r\n }\r\n ],\r\n \"name\": \"add\"\r\n },\r\n \"type\": \"reducer\"\r\n },\r\n \"say_hello\": {\r\n \"arity\": 0,\r\n \"schema\": {\r\n \"elements\": [],\r\n \"name\": \"say_hello\"\r\n },\r\n \"type\": \"reducer\"\r\n }\r\n },\r\n \"typespace\": [\r\n {\r\n \"Product\": {\r\n \"elements\": [\r\n {\r\n \"algebraic_type\": {\r\n \"Builtin\": {\r\n \"String\": []\r\n }\r\n },\r\n \"name\": {\r\n \"some\": \"name\"\r\n }\r\n }\r\n ]\r\n }\r\n }\r\n ]\r\n}\r\n```\r\n\r\nThe `\"entities\"` will be an object whose keys are table and reducer names, and whose values are objects of the form:\r\n\r\n```typescript\r\n{\r\n \"arity\": number,\r\n \"type\": \"table\" | \"reducer\",\r\n \"schema\"?: ProductType\r\n}\r\n```\r\n\r\n| Entity field | Value |\r\n| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\r\n| `arity` | For tables, the number of colums; for reducers, the number of arguments. |\r\n| `type` | For tables, `\"table\"`; for reducers, `\"reducer\"`. |\r\n| `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. |\r\n\r\nThe `\"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]`.\r\n\r\n## `/database/schema/:name_or_address/:entity_type/:entity GET`\r\n\r\nGet a schema for a particular table or reducer in a database.\r\n\r\nAccessible through the CLI as `spacetime describe `.\r\n\r\n#### Parameters\r\n\r\n| Name | Value |\r\n| ------------------ | ---------------------------------------------------------------- |\r\n| `:name_or_address` | The name or address of the database. |\r\n| `:entity_type` | `reducer` to describe a reducer, or `table` to describe a table. |\r\n| `:entity` | The name of the reducer or table. |\r\n\r\n#### Query Parameters\r\n\r\n| Name | Value |\r\n| -------- | ------------------------------------------------------------- |\r\n| `expand` | A boolean; whether to include the full schema for the entity. |\r\n\r\n#### Returns\r\n\r\nReturns 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):\r\n\r\n```typescript\r\n{\r\n \"arity\": number,\r\n \"type\": \"table\" | \"reducer\",\r\n \"schema\"?: ProductType,\r\n}\r\n```\r\n\r\n| Field | Value |\r\n| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\r\n| `arity` | For tables, the number of colums; for reducers, the number of arguments. |\r\n| `type` | For tables, `\"table\"`; for reducers, `\"reducer\"`. |\r\n| `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. |\r\n\r\n## `/database/info/:name_or_address GET`\r\n\r\nGet a database's address, owner identity, host type, number of replicas and a hash of its WASM module.\r\n\r\n#### Parameters\r\n\r\n| Name | Value |\r\n| ------------------ | ------------------------------------ |\r\n| `:name_or_address` | The name or address of the database. |\r\n\r\n#### Returns\r\n\r\nReturns JSON in the form:\r\n\r\n```typescript\r\n{\r\n \"address\": string,\r\n \"identity\": string,\r\n \"host_type\": \"wasmer\",\r\n \"num_replicas\": number,\r\n \"program_bytes_address\": string\r\n}\r\n```\r\n\r\n| Field | Type | Meaning |\r\n| ------------------------- | ------ | ----------------------------------------------------------- |\r\n| `\"address\"` | String | The address of the database. |\r\n| `\"identity\"` | String | The Spacetime identity of the database's owner. |\r\n| `\"host_type\"` | String | The module host type; currently always `\"wasmer\"`. |\r\n| `\"num_replicas\"` | Number | The number of replicas of the database. Currently always 1. |\r\n| `\"program_bytes_address\"` | String | Hash of the WASM module for the database. |\r\n\r\n## `/database/logs/:name_or_address GET`\r\n\r\nRetrieve logs from a database.\r\n\r\nAccessible through the CLI as `spacetime logs `.\r\n\r\n#### Parameters\r\n\r\n| Name | Value |\r\n| ------------------ | ------------------------------------ |\r\n| `:name_or_address` | The name or address of the database. |\r\n\r\n#### Query Parameters\r\n\r\n| Name | Value |\r\n| ----------- | --------------------------------------------------------------- |\r\n| `num_lines` | Number of most-recent log lines to retrieve. |\r\n| `follow` | A boolean; whether to continue receiving new logs via a stream. |\r\n\r\n#### Required Headers\r\n\r\n| Name | Value |\r\n| --------------- | ------------------------------------------------------------------------------------------- |\r\n| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). |\r\n\r\n#### Returns\r\n\r\nText, or streaming text if `follow` is supplied, containing log lines.\r\n\r\n## `/database/sql/:name_or_address POST`\r\n\r\nRun a SQL query against a database.\r\n\r\nAccessible through the CLI as `spacetime sql `.\r\n\r\n#### Parameters\r\n\r\n| Name | Value |\r\n| ------------------ | --------------------------------------------- |\r\n| `:name_or_address` | The name or address of the database to query. |\r\n\r\n#### Required Headers\r\n\r\n| Name | Value |\r\n| --------------- | ------------------------------------------------------------------------------------------- |\r\n| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). |\r\n\r\n#### Data\r\n\r\nSQL queries, separated by `;`.\r\n\r\n#### Returns\r\n\r\nReturns a JSON array of statement results, each of which takes the form:\r\n\r\n```typescript\r\n{\r\n \"schema\": ProductType,\r\n \"rows\": array\r\n}\r\n```\r\n\r\nThe `schema` will be a [JSON-encoded `ProductType`](/docs/satn-reference/satn-reference-json-format) describing the type of the returned rows.\r\n\r\nThe `rows` will be an array of [JSON-encoded `ProductValue`s](/docs/satn-reference/satn-reference-json-format), each of which conforms to the `schema`.\r\n", "editUrl": "Databases.md", "jumpLinks": [ { @@ -2778,6 +2866,7 @@ export const docsConfig = { "identifier": "Energy", "indexIdentifier": "Energy", "hasPages": false, + "content": "# `/energy` HTTP API\r\n\r\nThe HTTP endpoints in `/energy` allow clients to query identities' energy balances. Spacetime databases expend energy from their owners' balances while executing reducers.\r\n\r\n## At a glance\r\n\r\n| Route | Description |\r\n| ------------------------------------------------ | --------------------------------------------------------- |\r\n| [`/energy/:identity GET`](#energyidentity-get) | Get the remaining energy balance for the user `identity`. |\r\n| [`/energy/:identity POST`](#energyidentity-post) | Set the energy balance for the user `identity`. |\r\n\r\n## `/energy/:identity GET`\r\n\r\nGet the energy balance of an identity.\r\n\r\nAccessible through the CLI as `spacetime energy status `.\r\n\r\n#### Parameters\r\n\r\n| Name | Value |\r\n| ----------- | ----------------------- |\r\n| `:identity` | The Spacetime identity. |\r\n\r\n#### Returns\r\n\r\nReturns JSON in the form:\r\n\r\n```typescript\r\n{\r\n \"balance\": string\r\n}\r\n```\r\n\r\n| Field | Value |\r\n| --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |\r\n| `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. |\r\n\r\n## `/energy/:identity POST`\r\n\r\nSet the energy balance for an identity.\r\n\r\nNote that in the SpacetimeDB 0.6 Testnet, this endpoint always returns code 401, `UNAUTHORIZED`. Testnet energy balances cannot be refilled.\r\n\r\nAccessible through the CLI as `spacetime energy set-balance `.\r\n\r\n#### Parameters\r\n\r\n| Name | Value |\r\n| ----------- | ----------------------- |\r\n| `:identity` | The Spacetime identity. |\r\n\r\n#### Query Parameters\r\n\r\n| Name | Value |\r\n| --------- | ------------------------------------------ |\r\n| `balance` | A decimal integer; the new balance to set. |\r\n\r\n#### Required Headers\r\n\r\n| Name | Value |\r\n| --------------- | ------------------------------------------------------------------------------------------- |\r\n| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). |\r\n\r\n#### Returns\r\n\r\nReturns JSON in the form:\r\n\r\n```typescript\r\n{\r\n \"balance\": number\r\n}\r\n```\r\n\r\n| Field | Value |\r\n| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\r\n| `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. |\r\n", "editUrl": "Energy.md", "jumpLinks": [ { @@ -2838,6 +2927,7 @@ export const docsConfig = { "identifier": "Identities", "indexIdentifier": "Identities", "hasPages": false, + "content": "# `/identity` HTTP API\r\n\r\nThe HTTP endpoints in `/identity` allow clients to generate and manage Spacetime public identities and private tokens.\r\n\r\n## At a glance\r\n\r\n| Route | Description |\r\n| ----------------------------------------------------------------------- | ------------------------------------------------------------------ |\r\n| [`/identity GET`](#identity-get) | Look up an identity by email. |\r\n| [`/identity POST`](#identity-post) | Generate a new identity and token. |\r\n| [`/identity/websocket_token POST`](#identitywebsocket_token-post) | Generate a short-lived access token for use in untrusted contexts. |\r\n| [`/identity/:identity/set-email POST`](#identityidentityset-email-post) | Set the email for an identity. |\r\n| [`/identity/:identity/databases GET`](#identityidentitydatabases-get) | List databases owned by an identity. |\r\n| [`/identity/:identity/verify GET`](#identityidentityverify-get) | Verify an identity and token. |\r\n\r\n## `/identity GET`\r\n\r\nLook up Spacetime identities associated with an email.\r\n\r\nAccessible through the CLI as `spacetime identity find `.\r\n\r\n#### Query Parameters\r\n\r\n| Name | Value |\r\n| ------- | ------------------------------- |\r\n| `email` | An email address to search for. |\r\n\r\n#### Returns\r\n\r\nReturns JSON in the form:\r\n\r\n```typescript\r\n{\r\n \"identities\": [\r\n {\r\n \"identity\": string,\r\n \"email\": string\r\n }\r\n ]\r\n}\r\n```\r\n\r\nThe `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.\r\n\r\n## `/identity POST`\r\n\r\nCreate a new identity.\r\n\r\nAccessible through the CLI as `spacetime identity new`.\r\n\r\n#### Query Parameters\r\n\r\n| Name | Value |\r\n| ------- | ----------------------------------------------------------------------------------------------------------------------- |\r\n| `email` | An email address to associate with the new identity. If unsupplied, the new identity will not have an associated email. |\r\n\r\n#### Returns\r\n\r\nReturns JSON in the form:\r\n\r\n```typescript\r\n{\r\n \"identity\": string,\r\n \"token\": string\r\n}\r\n```\r\n\r\n## `/identity/websocket_token POST`\r\n\r\nGenerate a short-lived access token which can be used in untrusted contexts, e.g. embedded in URLs.\r\n\r\n#### Required Headers\r\n\r\n| Name | Value |\r\n| --------------- | ------------------------------------------------------------------------------------------- |\r\n| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). |\r\n\r\n#### Returns\r\n\r\nReturns JSON in the form:\r\n\r\n```typescript\r\n{\r\n \"token\": string\r\n}\r\n```\r\n\r\nThe `token` value is a short-lived [JSON Web Token](https://datatracker.ietf.org/doc/html/rfc7519).\r\n\r\n## `/identity/:identity/set-email POST`\r\n\r\nAssociate an email with a Spacetime identity.\r\n\r\nAccessible through the CLI as `spacetime identity set-email `.\r\n\r\n#### Parameters\r\n\r\n| Name | Value |\r\n| ----------- | ----------------------------------------- |\r\n| `:identity` | The identity to associate with the email. |\r\n\r\n#### Query Parameters\r\n\r\n| Name | Value |\r\n| ------- | ----------------- |\r\n| `email` | An email address. |\r\n\r\n#### Required Headers\r\n\r\n| Name | Value |\r\n| --------------- | ------------------------------------------------------------------------------------------- |\r\n| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). |\r\n\r\n## `/identity/:identity/databases GET`\r\n\r\nList all databases owned by an identity.\r\n\r\n#### Parameters\r\n\r\n| Name | Value |\r\n| ----------- | --------------------- |\r\n| `:identity` | A Spacetime identity. |\r\n\r\n#### Returns\r\n\r\nReturns JSON in the form:\r\n\r\n```typescript\r\n{\r\n \"addresses\": array\r\n}\r\n```\r\n\r\nThe `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.\r\n\r\n## `/identity/:identity/verify GET`\r\n\r\nVerify the validity of an identity/token pair.\r\n\r\n#### Parameters\r\n\r\n| Name | Value |\r\n| ----------- | ----------------------- |\r\n| `:identity` | The identity to verify. |\r\n\r\n#### Required Headers\r\n\r\n| Name | Value |\r\n| --------------- | ------------------------------------------------------------------------------------------- |\r\n| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). |\r\n\r\n#### Returns\r\n\r\nReturns no data.\r\n\r\nIf the token is valid and matches the identity, returns `204 No Content`.\r\n\r\nIf the token is valid but does not match the identity, returns `400 Bad Request`.\r\n\r\nIf the token is invalid, or no `Authorization` header is included in the request, returns `401 Unauthorized`.\r\n", "editUrl": "Identities.md", "jumpLinks": [ { @@ -2957,6 +3047,7 @@ export const docsConfig = { "title": "SpacetimeDB HTTP Authorization", "identifier": "index", "indexIdentifier": "index", + "content": "# SpacetimeDB HTTP Authorization\r\n\r\nRather 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.\r\n\r\n> Do not share your SpacetimeDB token with anyone, ever.\r\n\r\n### Generating identities and tokens\r\n\r\nClients can request a new identity and token via [the `/identity POST` HTTP endpoint](/docs/http-api-reference/identities#identity-post).\r\n\r\nAlternately, 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).\r\n\r\n### Encoding `Authorization` headers\r\n\r\nMany 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.\r\n\r\nTo construct an appropriate `Authorization` header value for a `token`:\r\n\r\n1. Prepend the string `token:`.\r\n2. Base64-encode.\r\n3. Prepend the string `Basic `.\r\n\r\n#### Python\r\n\r\n```python\r\ndef auth_header_value(token):\r\n username_and_password = f\"token:{token}\".encode(\"utf-8\")\r\n base64_encoded = base64.b64encode(username_and_password).decode(\"utf-8\")\r\n return f\"Basic {base64_encoded}\"\r\n```\r\n\r\n#### Rust\r\n\r\n```rust\r\nfn auth_header_value(token: &str) -> String {\r\n let username_and_password = format!(\"token:{}\", token);\r\n let base64_encoded = base64::prelude::BASE64_STANDARD.encode(username_and_password);\r\n format!(\"Basic {}\", encoded)\r\n}\r\n```\r\n\r\n#### C#\r\n\r\n```csharp\r\npublic string AuthHeaderValue(string token)\r\n{\r\n var username_and_password = Encoding.UTF8.GetBytes($\"token:{auth}\");\r\n var base64_encoded = Convert.ToBase64String(username_and_password);\r\n return \"Basic \" + base64_encoded;\r\n}\r\n```\r\n", "hasPages": false, "editUrl": "index.md", "jumpLinks": [ @@ -2993,7 +3084,17 @@ export const docsConfig = { ], "pages": [] } - ] + ], + "previousKey": { + "title": "Module ABI Reference", + "route": "index", + "depth": 1 + }, + "nextKey": { + "title": "SATN Reference", + "route": "index", + "depth": 1 + } }, { "title": "SATN Reference", @@ -3009,6 +3110,7 @@ export const docsConfig = { "identifier": "Binary Format", "indexIdentifier": "Binary Format", "hasPages": false, + "content": "# SATN Binary Format (BSATN)\r\n\r\nThe Spacetime Algebraic Type Notation binary (BSATN) format defines\r\nhow Spacetime `AlgebraicValue`s and friends are encoded as byte strings.\r\n\r\nAlgebraic values and product values are BSATN-encoded for e.g.,\r\nmodule-host communication and for storing row data in the database.\r\n\r\n## Notes on notation\r\n\r\nIn this reference, we give a formal definition of the format.\r\nTo do this, we use inductive definitions, and define the following notation:\r\n\r\n- `bsatn(x)` denotes a function converting some value `x` to a list of bytes.\r\n- `a: B` means that `a` is of type `B`.\r\n- `Foo(x)` denotes extracting `x` out of some variant or type `Foo`.\r\n- `a ++ b` denotes concatenating two byte lists `a` and `b`.\r\n- `bsatn(A) = bsatn(B) | ... | bsatn(Z)` where `B` to `Z` are variants of `A`\r\n means that `bsatn(A)` is defined as e.g.,\r\n `bsatn(B)`, `bsatn(C)`, .., `bsatn(Z)` depending on what variant of `A` it was.\r\n- `[]` denotes the empty list of bytes.\r\n\r\n## Values\r\n\r\n### At a glance\r\n\r\n| Type | Description |\r\n| ---------------- | ---------------------------------------------------------------- |\r\n| `AlgebraicValue` | A value whose type may be any [`AlgebraicType`](#algebraictype). |\r\n| `SumValue` | A value whose type is a [`SumType`](#sumtype). |\r\n| `ProductValue` | A value whose type is a [`ProductType`](#producttype). |\r\n| `BuiltinValue` | A value whose type is a [`BuiltinType`](#builtintype). |\r\n\r\n### `AlgebraicValue`\r\n\r\nThe BSATN encoding of an `AlgebraicValue` defers to the encoding of each variant:\r\n\r\n```fsharp\r\nbsatn(AlgebraicValue) = bsatn(SumValue) | bsatn(ProductValue) | bsatn(BuiltinValue)\r\n```\r\n\r\n### `SumValue`\r\n\r\nAn instance of a [`SumType`](#sumtype).\r\n`SumValue`s are binary-encoded as `bsatn(tag) ++ bsatn(variant_data)`\r\nwhere `tag: u8` is an index into the [`SumType.variants`](#sumtype)\r\narray of the value's [`SumType`](#sumtype),\r\nand where `variant_data` is the data of the variant.\r\nFor variants holding no data, i.e., of some zero sized type,\r\n`bsatn(variant_data) = []`.\r\n\r\n### `ProductValue`\r\n\r\nAn instance of a [`ProductType`](#producttype).\r\n`ProductValue`s are binary encoded as:\r\n\r\n```fsharp\r\nbsatn(elems) = bsatn(elem_0) ++ .. ++ bsatn(elem_n)\r\n```\r\n\r\nField names are not encoded.\r\n\r\n### `BuiltinValue`\r\n\r\nAn instance of a [`BuiltinType`](#builtintype).\r\nThe BSATN encoding of `BuiltinValue`s defers to the encoding of each variant:\r\n\r\n```fsharp\r\nbsatn(BuiltinValue)\r\n = bsatn(Bool)\r\n | bsatn(U8) | bsatn(U16) | bsatn(U32) | bsatn(U64) | bsatn(U128)\r\n | bsatn(I8) | bsatn(I16) | bsatn(I32) | bsatn(I64) | bsatn(I128)\r\n | bsatn(F32) | bsatn(F64)\r\n | bsatn(String)\r\n | bsatn(Array)\r\n | bsatn(Map)\r\n\r\nbsatn(Bool(b)) = bsatn(b as u8)\r\nbsatn(U8(x)) = [x]\r\nbsatn(U16(x: u16)) = to_little_endian_bytes(x)\r\nbsatn(U32(x: u32)) = to_little_endian_bytes(x)\r\nbsatn(U64(x: u64)) = to_little_endian_bytes(x)\r\nbsatn(U128(x: u128)) = to_little_endian_bytes(x)\r\nbsatn(I8(x: i8)) = to_little_endian_bytes(x)\r\nbsatn(I16(x: i16)) = to_little_endian_bytes(x)\r\nbsatn(I32(x: i32)) = to_little_endian_bytes(x)\r\nbsatn(I64(x: i64)) = to_little_endian_bytes(x)\r\nbsatn(I128(x: i128)) = to_little_endian_bytes(x)\r\nbsatn(F32(x: f32)) = bsatn(f32_to_raw_bits(x)) // lossless conversion\r\nbsatn(F64(x: f64)) = bsatn(f64_to_raw_bits(x)) // lossless conversion\r\nbsatn(String(s)) = bsatn(len(s) as u32) ++ bsatn(bytes(s))\r\nbsatn(Array(a)) = bsatn(len(a) as u32)\r\n ++ bsatn(normalize(a)_0) ++ .. ++ bsatn(normalize(a)_n)\r\nbsatn(Map(map)) = bsatn(len(m) as u32)\r\n ++ bsatn(key(map_0)) ++ bsatn(value(map_0))\r\n ..\r\n ++ bsatn(key(map_n)) ++ bsatn(value(map_n))\r\n```\r\n\r\nWhere\r\n\r\n- `f32_to_raw_bits(x)` is the raw transmute of `x: f32` to `u32`\r\n- `f64_to_raw_bits(x)` is the raw transmute of `x: f64` to `u64`\r\n- `normalize(a)` for `a: ArrayValue` converts `a` to a list of `AlgebraicValue`s\r\n- `key(map_i)` extracts the key of the `i`th entry of `map`\r\n- `value(map_i)` extracts the value of the `i`th entry of `map`\r\n\r\n## Types\r\n\r\nAll SATS types are BSATN-encoded by converting them to an `AlgebraicValue`,\r\nthen BSATN-encoding that meta-value.\r\n\r\nSee [the SATN JSON Format](/docs/satn-reference-json-format)\r\nfor more details of the conversion to meta values.\r\nNote that these meta values are converted to BSATN and _not JSON_.\r\n", "editUrl": "Binary%20Format.md", "jumpLinks": [ { @@ -3063,6 +3165,7 @@ export const docsConfig = { "title": "SATN JSON Format", "identifier": "index", "indexIdentifier": "index", + "content": "# SATN JSON Format\r\n\r\nThe 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).\r\n\r\n## Values\r\n\r\n### At a glance\r\n\r\n| Type | Description |\r\n| ---------------- | ---------------------------------------------------------------- |\r\n| `AlgebraicValue` | A value whose type may be any [`AlgebraicType`](#algebraictype). |\r\n| `SumValue` | A value whose type is a [`SumType`](#sumtype). |\r\n| `ProductValue` | A value whose type is a [`ProductType`](#producttype). |\r\n| `BuiltinValue` | A value whose type is a [`BuiltinType`](#builtintype). |\r\n| | |\r\n\r\n### `AlgebraicValue`\r\n\r\n```json\r\nSumValue | ProductValue | BuiltinValue\r\n```\r\n\r\n### `SumValue`\r\n\r\nAn instance of a [`SumType`](#sumtype). `SumValue`s are encoded as a JSON object with a single key, a non-negative integer tag which identifies the variant. The value associated with this key is the variant data. Variants which hold no data will have an empty array as their value.\r\n\r\nThe tag is an index into the [`SumType.variants`](#sumtype) array of the value's [`SumType`](#sumtype).\r\n\r\n```json\r\n{\r\n \"\": AlgebraicValue\r\n}\r\n```\r\n\r\n### `ProductValue`\r\n\r\nAn 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).\r\n\r\n```json\r\narray\r\n```\r\n\r\n### `BuiltinValue`\r\n\r\nAn instance of a [`BuiltinType`](#builtintype). `BuiltinValue`s are encoded as JSON values of corresponding types.\r\n\r\n```json\r\nboolean | number | string | array | map\r\n```\r\n\r\n| [`BuiltinType`](#builtintype) | JSON type |\r\n| ----------------------------- | ------------------------------------- |\r\n| `Bool` | `boolean` |\r\n| Integer types | `number` |\r\n| Float types | `number` |\r\n| `String` | `string` |\r\n| Array types | `array` |\r\n| Map types | `map` |\r\n\r\nAll SATS integer types are encoded as JSON `number`s, so values of 64-bit and 128-bit integer types may lose precision when encoding values larger than 2⁵².\r\n\r\n## Types\r\n\r\nAll SATS types are JSON-encoded by converting them to an `AlgebraicValue`, then JSON-encoding that meta-value.\r\n\r\n### At a glance\r\n\r\n| Type | Description |\r\n| --------------------------------------- | ------------------------------------------------------------------------------------ |\r\n| [`AlgebraicType`](#algebraictype) | Any SATS type. |\r\n| [`SumType`](#sumtype) | Sum types, i.e. tagged unions. |\r\n| [`ProductType`](#productype) | Product types, i.e. structures. |\r\n| [`BuiltinType`](#builtintype) | Built-in and primitive types, including booleans, numbers, strings, arrays and maps. |\r\n| [`AlgebraicTypeRef`](#algebraictyperef) | An indirect reference to a type, used to implement recursive types. |\r\n\r\n#### `AlgebraicType`\r\n\r\n`AlgebraicType` is the most general meta-type in the Spacetime Algebraic Type System. Any SATS type can be represented as an `AlgebraicType`. `AlgebraicType` is encoded as a tagged union, with variants for [`SumType`](#sumtype), [`ProductType`](#producttype), [`BuiltinType`](#builtintype) and [`AlgebraicTypeRef`](#algebraictyperef).\r\n\r\n```json\r\n{ \"Sum\": SumType }\r\n| { \"Product\": ProductType }\r\n| { \"Builtin\": BuiltinType }\r\n| { \"Ref\": AlgebraicTypeRef }\r\n```\r\n\r\n#### `SumType`\r\n\r\nThe meta-type `SumType` represents sum types, also called tagged unions or Rust `enum`s. A sum type has some number of variants, each of which has an `AlgebraicType` of variant data, and an optional string discriminant. For each instance, exactly one variant will be active. The instance will contain only that variant's data.\r\n\r\nA `SumType` with zero variants is called an empty type or never type because it is impossible to construct an instance.\r\n\r\nInstances of `SumType`s are [`SumValue`s](#sumvalue), and store a tag which identifies the active variant.\r\n\r\n```json\r\n// SumType:\r\n{\r\n \"variants\": array,\r\n}\r\n\r\n// SumTypeVariant:\r\n{\r\n \"algebraic_type\": AlgebraicType,\r\n \"name\": { \"some\": string } | { \"none\": [] }\r\n}\r\n```\r\n\r\n### `ProductType`\r\n\r\nThe meta-type `ProductType` represents product types, also called structs or tuples. A product type has some number of fields, each of which has an `AlgebraicType` of field data, and an optional string field name. Each instance will contain data for all of the product type's fields.\r\n\r\nA `ProductType` with zero fields is called a unit type because it has a single instance, the unit, which is empty.\r\n\r\nInstances of `ProductType`s are [`ProductValue`s](#productvalue), and store an array of field data.\r\n\r\n```json\r\n// ProductType:\r\n{\r\n \"elements\": array,\r\n}\r\n\r\n// ProductTypeElement:\r\n{\r\n \"algebraic_type\": AlgebraicType,\r\n \"name\": { \"some\": string } | { \"none\": [] }\r\n}\r\n```\r\n\r\n### `BuiltinType`\r\n\r\nThe meta-type `BuiltinType` represents SATS primitive types: booleans, integers, floating-point numbers, strings, arrays and maps. `BuiltinType` is encoded as a tagged union, with a variant for each SATS primitive type.\r\n\r\nSATS integer types are identified by their signedness and width in bits. SATS supports the same set of integer types as Rust, i.e. 8, 16, 32, 64 and 128-bit signed and unsigned integers.\r\n\r\nSATS floating-point number types are identified by their width in bits. SATS supports 32 and 64-bit floats, which correspond to [IEEE 754](https://en.wikipedia.org/wiki/IEEE_754) single- and double-precision binary floats, respectively.\r\n\r\nSATS array and map types are homogeneous, meaning that each array has a single element type to which all its elements must conform, and each map has a key type and a value type to which all of its keys and values must conform.\r\n\r\n```json\r\n{ \"Bool\": [] }\r\n| { \"I8\": [] }\r\n| { \"U8\": [] }\r\n| { \"I16\": [] }\r\n| { \"U16\": [] }\r\n| { \"I32\": [] }\r\n| { \"U32\": [] }\r\n| { \"I64\": [] }\r\n| { \"U64\": [] }\r\n| { \"I128\": [] }\r\n| { \"U128\": [] }\r\n| { \"F32\": [] }\r\n| { \"F64\": [] }\r\n| { \"String\": [] }\r\n| { \"Array\": AlgebraicType }\r\n| { \"Map\": {\r\n \"key_ty\": AlgebraicType,\r\n \"ty\": AlgebraicType,\r\n } }\r\n```\r\n\r\n### `AlgebraicTypeRef`\r\n\r\n`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).\r\n", "hasPages": false, "editUrl": "index.md", "jumpLinks": [ @@ -3139,7 +3242,17 @@ export const docsConfig = { ], "pages": [] } - ] + ], + "previousKey": { + "title": "HTTP API Reference", + "route": "index", + "depth": 1 + }, + "nextKey": { + "title": "SQL Reference", + "route": "index", + "depth": 1 + } }, { "title": "SQL Reference", @@ -3154,6 +3267,7 @@ export const docsConfig = { "title": "SQL Support", "identifier": "index", "indexIdentifier": "index", + "content": "# SQL Support\r\n\r\nSpacetimeDB 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.\r\n\r\nSpacetimeDB 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/).\r\n\r\nSpacetimeDB 0.6 implements a relatively small subset of SQL. Future SpacetimeDB versions will implement additional SQL features.\r\n\r\n## Types\r\n\r\n| Type | Description |\r\n| --------------------------------------------- | -------------------------------------- |\r\n| [Nullable types](#nullable-types) | Types which may not hold a value. |\r\n| [Logic types](#logic-types) | Booleans, i.e. `true` and `false`. |\r\n| [Integer types](#integer-types) | Numbers without fractional components. |\r\n| [Floating-point types](#floating-point-types) | Numbers with fractional components. |\r\n| [Text types](#text-types) | UTF-8 encoded text. |\r\n\r\n### Definition statements\r\n\r\n| Statement | Description |\r\n| ----------------------------- | ------------------------------------ |\r\n| [CREATE TABLE](#create-table) | Create a new table. |\r\n| [DROP TABLE](#drop-table) | Remove a table, discarding all rows. |\r\n\r\n### Query statements\r\n\r\n| Statement | Description |\r\n| ----------------- | -------------------------------------------------------------------------------------------- |\r\n| [FROM](#from) | A source of data, like a table or a value. |\r\n| [JOIN](#join) | Combine several data sources. |\r\n| [SELECT](#select) | Select specific rows and columns from a data source, and optionally compute a derived value. |\r\n| [DELETE](#delete) | Delete specific rows from a table. |\r\n| [INSERT](#insert) | Insert rows into a table. |\r\n| [UPDATE](#update) | Update specific rows in a table. |\r\n\r\n## Data types\r\n\r\nSpacetimeDB 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.\r\n\r\nBecause 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.\r\n\r\nMost SATS builtin types map cleanly to SQL types.\r\n\r\n### Nullable types\r\n\r\nSpacetimeDB 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`.\r\n\r\n### Logic types\r\n\r\n| SQL | SATS | Example |\r\n| --------- | ------ | --------------- |\r\n| `BOOLEAN` | `Bool` | `true`, `false` |\r\n\r\n### Numeric types\r\n\r\n#### Integer types\r\n\r\nAn integer is a number without a fractional component.\r\n\r\nAdding 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.\r\n\r\n| SQL | SATS | Example | Min | Max |\r\n| ------------------- | ----- | ------- | ------ | ----- |\r\n| `TINYINT` | `I8` | 1 | -(2⁷) | 2⁷-1 |\r\n| `TINYINT UNSIGNED` | `U8` | 1 | 0 | 2⁸-1 |\r\n| `SMALLINT` | `I16` | 1 | -(2¹⁵) | 2¹⁵-1 |\r\n| `SMALLINT UNSIGNED` | `U16` | 1 | 0 | 2¹⁶-1 |\r\n| `INT`, `INTEGER` | `I32` | 1 | -(2³¹) | 2³¹-1 |\r\n| `INT UNSIGNED` | `U32` | 1 | 0 | 2³²-1 |\r\n| `BIGINT` | `I64` | 1 | -(2⁶³) | 2⁶³-1 |\r\n| `BIGINT UNSIGNED` | `U64` | 1 | 0 | 2⁶⁴-1 |\r\n\r\n#### Floating-point types\r\n\r\nSpacetimeDB supports single- and double-precision [binary IEEE-754 floats](https://en.wikipedia.org/wiki/IEEE_754).\r\n\r\n| SQL | SATS | Example | Min | Max |\r\n| ----------------- | ----- | ------- | ------------------------ | ----------------------- |\r\n| `REAL` | `F32` | 1.0 | -3.40282347E+38 | 3.40282347E+38 |\r\n| `DOUBLE`, `FLOAT` | `F64` | 1.0 | -1.7976931348623157E+308 | 1.7976931348623157E+308 |\r\n\r\n### Text types\r\n\r\nSpacetimeDB supports a single string type, `String`. SpacetimeDB strings are UTF-8 encoded.\r\n\r\n| SQL | SATS | Example | Notes |\r\n| ----------------------------------------------- | -------- | ------- | -------------------- |\r\n| `CHAR`, `VARCHAR`, `NVARCHAR`, `TEXT`, `STRING` | `String` | 'hello' | Always UTF-8 encoded |\r\n\r\n> SpacetimeDB SQL currently does not support length contraints like `CHAR(10)`.\r\n\r\n## Syntax\r\n\r\n### Comments\r\n\r\nSQL line comments begin with `--`.\r\n\r\n```sql\r\n-- This is a comment\r\n```\r\n\r\n### Expressions\r\n\r\nWe can express different, composable, values that are universally called `expressions`.\r\n\r\nAn expression is one of the following:\r\n\r\n#### Literals\r\n\r\n| Example | Description |\r\n| --------- | ----------- |\r\n| `1` | An integer. |\r\n| `1.0` | A float. |\r\n| `'hello'` | A string. |\r\n| `true` | A boolean. |\r\n\r\n#### Binary operators\r\n\r\n| Example | Description |\r\n| ------- | ------------------- |\r\n| `1 > 2` | Integer comparison. |\r\n| `1 + 2` | Integer addition. |\r\n\r\n#### Logical expressions\r\n\r\nAny expression which returns a boolean, i.e. `true` or `false`, is a logical expression.\r\n\r\n| Example | Description |\r\n| ---------------- | ------------------------------------------------------------ |\r\n| `1 > 2` | Integer comparison. |\r\n| `1 + 2 == 3` | Equality comparison between a constant and a computed value. |\r\n| `true AND false` | Boolean and. |\r\n| `true OR false` | Boolean or. |\r\n| `NOT true` | Boolean inverse. |\r\n\r\n#### Function calls\r\n\r\n| Example | Description |\r\n| --------------- | -------------------------------------------------- |\r\n| `lower('JOHN')` | Apply the function `lower` to the string `'JOHN'`. |\r\n\r\n#### Table identifiers\r\n\r\n| Example | Description |\r\n| ------------- | ------------------------- |\r\n| `inventory` | Refers to a table. |\r\n| `\"inventory\"` | Refers to the same table. |\r\n\r\n#### Column references\r\n\r\n| Example | Description |\r\n| -------------------------- | ------------------------------------------------------- |\r\n| `inventory_id` | Refers to a column. |\r\n| `\"inventory_id\"` | Refers to the same column. |\r\n| `\"inventory.inventory_id\"` | Refers to the same column, explicitly naming its table. |\r\n\r\n#### Wildcards\r\n\r\nSpecial \"star\" expressions which select all the columns of a table.\r\n\r\n| Example | Description |\r\n| ------------- | ------------------------------------------------------- |\r\n| `*` | Refers to all columns of a table identified by context. |\r\n| `inventory.*` | Refers to all columns of the `inventory` table. |\r\n\r\n#### Parenthesized expressions\r\n\r\nSub-expressions can be enclosed in parentheses for grouping and to override operator precedence.\r\n\r\n| Example | Description |\r\n| ------------- | ----------------------- |\r\n| `1 + (2 / 3)` | One plus a fraction. |\r\n| `(1 + 2) / 3` | A sum divided by three. |\r\n\r\n### `CREATE TABLE`\r\n\r\nA `CREATE TABLE` statement creates a new, initially empty table in the database.\r\n\r\nThe syntax of the `CREATE TABLE` statement is:\r\n\r\n> **CREATE TABLE** _table_name_ (_column_name_ _data_type_, ...);\r\n\r\n![create-table](/images/syntax/create_table.svg)\r\n\r\n#### Examples\r\n\r\nCreate a table `inventory` with two columns, an integer `inventory_id` and a string `name`:\r\n\r\n```sql\r\nCREATE TABLE inventory (inventory_id INTEGER, name TEXT);\r\n```\r\n\r\nCreate a table `player` with two integer columns, an `entity_id` and an `inventory_id`:\r\n\r\n```sql\r\nCREATE TABLE player (entity_id INTEGER, inventory_id INTEGER);\r\n```\r\n\r\nCreate a table `location` with three columns, an integer `entity_id` and floats `x` and `z`:\r\n\r\n```sql\r\nCREATE TABLE location (entity_id INTEGER, x REAL, z REAL);\r\n```\r\n\r\n### `DROP TABLE`\r\n\r\nA `DROP TABLE` statement removes a table from the database, deleting all its associated rows, indexes, constraints and sequences.\r\n\r\nTo empty a table of rows without destroying the table, use [`DELETE`](#delete).\r\n\r\nThe syntax of the `DROP TABLE` statement is:\r\n\r\n> **DROP TABLE** _table_name_;\r\n\r\n![drop-table](/images/syntax/drop_table.svg)\r\n\r\nExamples:\r\n\r\n```sql\r\nDROP TABLE inventory;\r\n```\r\n\r\n## Queries\r\n\r\n### `FROM`\r\n\r\nA `FROM` clause derives a data source from a table name.\r\n\r\nThe syntax of the `FROM` clause is:\r\n\r\n> **FROM** _table_name_ _join_clause_?;\r\n\r\n![from](/images/syntax/from.svg)\r\n\r\n#### Examples\r\n\r\nSelect all rows from the `inventory` table:\r\n\r\n```sql\r\nSELECT * FROM inventory;\r\n```\r\n\r\n### `JOIN`\r\n\r\nA `JOIN` clause combines two data sources into a new data source.\r\n\r\nCurrently, SpacetimeDB SQL supports only inner joins, which return rows from two data sources where the values of two columns match.\r\n\r\nThe syntax of the `JOIN` clause is:\r\n\r\n> **JOIN** _table_name_ **ON** _expr_ = _expr_;\r\n\r\n![join](/images/syntax/join.svg)\r\n\r\n### Examples\r\n\r\nSelect all players rows who have a corresponding location:\r\n\r\n```sql\r\nSELECT player.* FROM player\r\n JOIN location\r\n ON location.entity_id = player.entity_id;\r\n```\r\n\r\nSelect all inventories which have a corresponding player, and where that player has a corresponding location:\r\n\r\n```sql\r\nSELECT inventory.* FROM inventory\r\n JOIN player\r\n ON inventory.inventory_id = player.inventory_id\r\n JOIN location\r\n ON player.entity_id = location.entity_id;\r\n```\r\n\r\n### `SELECT`\r\n\r\nA `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.\r\n\r\nThe syntax of the `SELECT` command is:\r\n\r\n> **SELECT** _column_expr_ > **FROM** _from_expr_\r\n> {**WHERE** _expr_}?\r\n\r\n![sql-select](/images/syntax/select.svg)\r\n\r\n#### Examples\r\n\r\nSelect all columns of all rows from the `inventory` table:\r\n\r\n```sql\r\nSELECT * FROM inventory;\r\nSELECT inventory.* FROM inventory;\r\n```\r\n\r\nSelect only the `inventory_id` column of all rows from the `inventory` table:\r\n\r\n```sql\r\nSELECT inventory_id FROM inventory;\r\nSELECT inventory.inventory_id FROM inventory;\r\n```\r\n\r\nAn 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`.\r\n\r\n#### Examples\r\n\r\nSelect all columns of all rows from the `inventory` table, with a filter that is always true:\r\n\r\n```sql\r\nSELECT * FROM inventory WHERE 1 = 1;\r\n```\r\n\r\nSelect all columns of all rows from the `inventory` table with the `inventory_id` 1:\r\n\r\n```sql\r\nSELECT * FROM inventory WHERE inventory_id = 1;\r\n```\r\n\r\nSelect only the `name` column of all rows from the `inventory` table with the `inventory_id` 1:\r\n\r\n```sql\r\nSELECT name FROM inventory WHERE inventory_id = 1;\r\n```\r\n\r\nSelect all columns of all rows from the `inventory` table where the `inventory_id` is 2 or greater:\r\n\r\n```sql\r\nSELECT * FROM inventory WHERE inventory_id > 1;\r\n```\r\n\r\n### `INSERT`\r\n\r\nAn `INSERT INTO` statement inserts new rows into a table.\r\n\r\nOne can insert one or more rows specified by value expressions.\r\n\r\nThe syntax of the `INSERT INTO` statement is:\r\n\r\n> **INSERT INTO** _table_name_ (_column_name_, ...) **VALUES** (_expr_, ...), ...;\r\n\r\n![sql-insert](/images/syntax/insert.svg)\r\n\r\n#### Examples\r\n\r\nInsert a single row:\r\n\r\n```sql\r\nINSERT INTO inventory (inventory_id, name) VALUES (1, 'health1');\r\n```\r\n\r\nInsert two rows:\r\n\r\n```sql\r\nINSERT INTO inventory (inventory_id, name) VALUES (1, 'health1'), (2, 'health2');\r\n```\r\n\r\n### UPDATE\r\n\r\nAn `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.\r\n\r\nColumns not explicitly modified with the `SET` clause retain their previous values.\r\n\r\nIf the `WHERE` clause is absent, the effect is to update all rows in the table.\r\n\r\nThe syntax of the `UPDATE` statement is\r\n\r\n> **UPDATE** _table_name_ **SET** > _column_name_ = _expr_, ...\r\n> {_WHERE expr_}?;\r\n\r\n![sql-update](/images/syntax/update.svg)\r\n\r\n#### Examples\r\n\r\nSet the `name` column of all rows from the `inventory` table with the `inventory_id` 1 to `'new name'`:\r\n\r\n```sql\r\nUPDATE inventory\r\n SET name = 'new name'\r\n WHERE inventory_id = 1;\r\n```\r\n\r\n### DELETE\r\n\r\nA `DELETE` statement deletes rows that satisfy the `WHERE` clause from the specified table.\r\n\r\nIf 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.\r\n\r\nThe syntax of the `DELETE` statement is\r\n\r\n> **DELETE** _table_name_\r\n> {**WHERE** _expr_}?;\r\n\r\n![sql-delete](/images/syntax/delete.svg)\r\n\r\n#### Examples\r\n\r\nDelete all the rows from the `inventory` table with the `inventory_id` 1:\r\n\r\n```sql\r\nDELETE FROM inventory WHERE inventory_id = 1;\r\n```\r\n\r\nDelete all rows from the `inventory` table, leaving it empty:\r\n\r\n```sql\r\nDELETE FROM inventory;\r\n```\r\n", "hasPages": false, "editUrl": "index.md", "jumpLinks": [ @@ -3355,7 +3469,17 @@ export const docsConfig = { ], "pages": [] } - ] + ], + "previousKey": { + "title": "SATN Reference", + "route": "index", + "depth": 1 + }, + "nextKey": { + "title": "WebSocket API Reference", + "route": "index", + "depth": 1 + } }, { "title": "WebSocket API Reference", @@ -3370,6 +3494,7 @@ export const docsConfig = { "title": "The SpacetimeDB WebSocket API", "identifier": "index", "indexIdentifier": "index", + "content": "# The SpacetimeDB WebSocket API\r\n\r\nAs 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.\r\n\r\nThe SpacetimeDB SDKs comminicate with their corresponding database using the WebSocket API.\r\n\r\n## Connecting\r\n\r\nTo 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).\r\n\r\nTo 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.\r\n\r\n## Protocols\r\n\r\nClients 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.\r\n\r\n| `Sec-WebSocket-Protocol` header value | Selected protocol |\r\n| ------------------------------------- | -------------------------- |\r\n| `v1.bin.spacetimedb` | [Binary](#binary-protocol) |\r\n| `v1.text.spacetimedb` | [Text](#text-protocol) |\r\n\r\n### Binary Protocol\r\n\r\nThe 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).\r\n\r\nThe 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).\r\n\r\n### Text Protocol\r\n\r\nThe 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).\r\n\r\n## Messages\r\n\r\n### Client to server\r\n\r\n| Message | Description |\r\n| ------------------------------- | --------------------------------------------------------------------------- |\r\n| [`FunctionCall`](#functioncall) | Invoke a reducer. |\r\n| [`Subscribe`](#subscribe) | Register queries to receive streaming updates for a subset of the database. |\r\n\r\n#### `FunctionCall`\r\n\r\nClients 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.\r\n\r\n##### Binary: ProtoBuf definition\r\n\r\n```protobuf\r\nmessage FunctionCall {\r\n string reducer = 1;\r\n bytes argBytes = 2;\r\n}\r\n```\r\n\r\n| Field | Value |\r\n| ---------- | -------------------------------------------------------- |\r\n| `reducer` | The name of the reducer to invoke. |\r\n| `argBytes` | The reducer arguments encoded as a BSATN `ProductValue`. |\r\n\r\n##### Text: JSON encoding\r\n\r\n```typescript\r\n{\r\n \"call\": {\r\n \"fn\": string,\r\n \"args\": array,\r\n }\r\n}\r\n```\r\n\r\n| Field | Value |\r\n| ------ | ---------------------------------------------- |\r\n| `fn` | The name of the reducer to invoke. |\r\n| `args` | The reducer arguments encoded as a JSON array. |\r\n\r\n#### `Subscribe`\r\n\r\nClients send a `Subscribe` message to register SQL queries in order to receive streaming updates.\r\n\r\nThe 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.\r\n\r\nSpacetimeDB responds to each `Subscribe` message with a [`SubscriptionUpdate` message](#subscriptionupdate) containing all matching rows at the time the subscription is applied.\r\n\r\nEach `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.\r\n\r\nEach 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.\r\n\r\n##### Binary: ProtoBuf definition\r\n\r\n```protobuf\r\nmessage Subscribe {\r\n repeated string query_strings = 1;\r\n}\r\n```\r\n\r\n| Field | Value |\r\n| --------------- | ----------------------------------------------------------------- |\r\n| `query_strings` | A sequence of strings, each of which contains a single SQL query. |\r\n\r\n##### Text: JSON encoding\r\n\r\n```typescript\r\n{\r\n \"subscribe\": {\r\n \"query_strings\": array\r\n }\r\n}\r\n```\r\n\r\n| Field | Value |\r\n| --------------- | --------------------------------------------------------------- |\r\n| `query_strings` | An array of strings, each of which contains a single SQL query. |\r\n\r\n### Server to client\r\n\r\n| Message | Description |\r\n| ------------------------------------------- | -------------------------------------------------------------------------- |\r\n| [`IdentityToken`](#identitytoken) | Sent once upon successful connection with the client's identity and token. |\r\n| [`SubscriptionUpdate`](#subscriptionupdate) | Initial message in response to a [`Subscribe` message](#subscribe). |\r\n| [`TransactionUpdate`](#transactionupdate) | Streaming update after a reducer runs containing altered rows. |\r\n\r\n#### `IdentityToken`\r\n\r\nUpon 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.\r\n\r\n##### Binary: ProtoBuf definition\r\n\r\n```protobuf\r\nmessage IdentityToken {\r\n bytes identity = 1;\r\n string token = 2;\r\n}\r\n```\r\n\r\n| Field | Value |\r\n| ---------- | --------------------------------------- |\r\n| `identity` | The client's public Spacetime identity. |\r\n| `token` | The client's private access token. |\r\n\r\n##### Text: JSON encoding\r\n\r\n```typescript\r\n{\r\n \"IdentityToken\": {\r\n \"identity\": array,\r\n \"token\": string\r\n }\r\n}\r\n```\r\n\r\n| Field | Value |\r\n| ---------- | --------------------------------------- |\r\n| `identity` | The client's public Spacetime identity. |\r\n| `token` | The client's private access token. |\r\n\r\n#### `SubscriptionUpdate`\r\n\r\nIn 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.\r\n\r\n##### Binary: ProtoBuf definition\r\n\r\n```protobuf\r\nmessage SubscriptionUpdate {\r\n repeated TableUpdate tableUpdates = 1;\r\n}\r\n\r\nmessage TableUpdate {\r\n uint32 tableId = 1;\r\n string tableName = 2;\r\n repeated TableRowOperation tableRowOperations = 3;\r\n}\r\n\r\nmessage TableRowOperation {\r\n enum OperationType {\r\n DELETE = 0;\r\n INSERT = 1;\r\n }\r\n OperationType op = 1;\r\n bytes row_pk = 2;\r\n bytes row = 3;\r\n}\r\n```\r\n\r\nEach `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`.\r\n\r\n| `TableUpdate` field | Value |\r\n| -------------------- | ------------------------------------------------------------------------------------------------------------- |\r\n| `tableId` | An integer identifier for the table. A table's `tableId` is not stable, so clients should not depend on it. |\r\n| `tableName` | The string name of the table. Clients should use this field to identify the table, rather than the `tableId`. |\r\n| `tableRowOperations` | A `TableRowOperation` for each inserted or deleted row. |\r\n\r\n| `TableRowOperation` field | Value |\r\n| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\r\n| `op` | `INSERT` for inserted rows during a [`TransactionUpdate`](#transactionupdate) or rows resident upon applying a subscription; `DELETE` for deleted rows during a [`TransactionUpdate`](#transactionupdate). |\r\n| `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`. |\r\n| `row` | The altered row, encoded as a BSATN `ProductValue`. |\r\n\r\n##### Text: JSON encoding\r\n\r\n```typescript\r\n// SubscriptionUpdate:\r\n{\r\n \"SubscriptionUpdate\": {\r\n \"table_updates\": array\r\n }\r\n}\r\n\r\n// TableUpdate:\r\n{\r\n \"table_id\": number,\r\n \"table_name\": string,\r\n \"table_row_operations\": array\r\n}\r\n\r\n// TableRowOperation:\r\n{\r\n \"op\": \"insert\" | \"delete\",\r\n \"row_pk\": string,\r\n \"row\": array\r\n}\r\n```\r\n\r\nEach `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\"`.\r\n\r\n| `TableUpdate` field | Value |\r\n| ---------------------- | -------------------------------------------------------------------------------------------------------------- |\r\n| `table_id` | An integer identifier for the table. A table's `table_id` is not stable, so clients should not depend on it. |\r\n| `table_name` | The string name of the table. Clients should use this field to identify the table, rather than the `table_id`. |\r\n| `table_row_operations` | A `TableRowOperation` for each inserted or deleted row. |\r\n\r\n| `TableRowOperation` field | Value |\r\n| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\r\n| `op` | `\"insert\"` for inserted rows during a [`TransactionUpdate`](#transactionupdate) or rows resident upon applying a subscription; `\"delete\"` for deleted rows during a [`TransactionUpdate`](#transactionupdate). |\r\n| `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. |\r\n| `row` | The altered row, encoded as a JSON array. |\r\n\r\n#### `TransactionUpdate`\r\n\r\nUpon 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:\r\n\r\n1. The reducer ran successfully and altered at least one row to which the client subscribes.\r\n2. The reducer was invoked by the client, and either failed or was terminated due to insufficient energy.\r\n\r\nEach `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.\r\n\r\n##### Binary: ProtoBuf definition\r\n\r\n```protobuf\r\nmessage TransactionUpdate {\r\n Event event = 1;\r\n SubscriptionUpdate subscriptionUpdate = 2;\r\n}\r\n\r\nmessage Event {\r\n enum Status {\r\n committed = 0;\r\n failed = 1;\r\n out_of_energy = 2;\r\n }\r\n uint64 timestamp = 1;\r\n bytes callerIdentity = 2;\r\n FunctionCall functionCall = 3;\r\n Status status = 4;\r\n string message = 5;\r\n int64 energy_quanta_used = 6;\r\n uint64 host_execution_duration_micros = 7;\r\n}\r\n```\r\n\r\n| Field | Value |\r\n| -------------------- | --------------------------------------------------------------------------------------------------------------------------- |\r\n| `event` | An `Event` containing information about the reducer run. |\r\n| `subscriptionUpdate` | A [`SubscriptionUpdate`](#subscriptionupdate) containing all the row insertions and deletions committed by the transaction. |\r\n\r\n| `Event` field | Value |\r\n| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\r\n| `timestamp` | The time when the reducer started, as microseconds since the Unix epoch. |\r\n| `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. |\r\n| `functionCall` | A [`FunctionCall`](#functioncall) containing the name of the reducer and the arguments passed to it. |\r\n| `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. |\r\n| `message` | The error message with which the reducer failed if `status` is `failed`, or the empty string otherwise. |\r\n| `energy_quanta_used` | The amount of energy consumed by running the reducer. |\r\n| `host_execution_duration_micros` | The duration of the reducer's execution, in microseconds. |\r\n\r\n##### Text: JSON encoding\r\n\r\n```typescript\r\n// TransactionUpdate:\r\n{\r\n \"TransactionUpdate\": {\r\n \"event\": Event,\r\n \"subscription_update\": SubscriptionUpdate\r\n }\r\n}\r\n\r\n// Event:\r\n{\r\n \"timestamp\": number,\r\n \"status\": \"committed\" | \"failed\" | \"out_of_energy\",\r\n \"caller_identity\": string,\r\n \"function_call\": {\r\n \"reducer\": string,\r\n \"args\": array,\r\n },\r\n \"energy_quanta_used\": number,\r\n \"message\": string\r\n}\r\n```\r\n\r\n| Field | Value |\r\n| --------------------- | --------------------------------------------------------------------------------------------------------------------------- |\r\n| `event` | An `Event` containing information about the reducer run. |\r\n| `subscription_update` | A [`SubscriptionUpdate`](#subscriptionupdate) containing all the row insertions and deletions committed by the transaction. |\r\n\r\n| `Event` field | Value |\r\n| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\r\n| `timestamp` | The time when the reducer started, as microseconds since the Unix epoch. |\r\n| `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. |\r\n| `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. |\r\n| `function_call.reducer` | The name of the reducer. |\r\n| `function_call.args` | The reducer arguments encoded as a JSON array. |\r\n| `energy_quanta_used` | The amount of energy consumed by running the reducer. |\r\n| `message` | The error message with which the reducer failed if `status` is `failed`, or the empty string otherwise. |\r\n", "hasPages": false, "editUrl": "index.md", "jumpLinks": [ @@ -3491,7 +3616,13 @@ export const docsConfig = { ], "pages": [] } - ] + ], + "previousKey": { + "title": "SQL Reference", + "route": "index", + "depth": 1 + }, + "nextKey": null } ], "rootEditURL": "https://github.com/clockworklabs/spacetime-docs/edit/master/docs/" diff --git a/docs/src/index.ts b/docs/src/index.ts index 7927a60e65a..32fac1b6510 100644 --- a/docs/src/index.ts +++ b/docs/src/index.ts @@ -91,7 +91,11 @@ program.command("generate").action(() => { if (subSection) { subSections.push(subSection); } - } else if (isMarkdownFile && item !== "_category.json") { + } else if ( + isMarkdownFile && + item !== "_category.json" && + item !== "index.md" + ) { const { title, jumpLinks } = extractHeadersFromMarkdown(itemPath); const pageIdentifier = item.replace(".md", ""); @@ -100,6 +104,21 @@ program.command("generate").action(() => { identifier: pageIdentifier, indexIdentifier: pageIdentifier, hasPages: false, + content: `${fs.readFileSync(itemPath, "utf-8")}`, + editUrl: encodeURIComponent(pageIdentifier) + ".md", + jumpLinks: jumpLinks, + pages: [], + }); + } else if (isMarkdownFile && item === "index.md") { + const { title, jumpLinks } = extractHeadersFromMarkdown(itemPath); + const pageIdentifier = item.replace(".md", ""); + + subSections.push({ + title: title || pageIdentifier, + identifier: pageIdentifier, + indexIdentifier: pageIdentifier, + content: `${fs.readFileSync(itemPath, "utf-8")}`, + hasPages: false, editUrl: encodeURIComponent(pageIdentifier) + ".md", jumpLinks: jumpLinks, pages: [], @@ -135,13 +154,37 @@ program.command("generate").action(() => { const orderA = config.order.indexOf(a.title); const orderB = config.order.indexOf(b.title); - if (orderA === -1 && orderB === -1) return 0; // If both items are not in the order list, they remain in their current order. - if (orderA === -1) return 1; // If only 'a' is not in the order list, 'b' comes first. - if (orderB === -1) return -1; // If only 'b' is not in the order list, 'a' comes first. + if (orderA === -1 && orderB === -1) return 0; + if (orderA === -1) return 1; + if (orderB === -1) return -1; - return orderA - orderB; // Otherwise, sort according to the order in the order list. + return orderA - orderB; }); + for (let i = 0; i < docConfig.sections.length; i++) { + const section = docConfig.sections[i]; + + if (i > 0) { + section.previousKey = { + title: docConfig.sections[i - 1].title, + route: docConfig.sections[i - 1].indexIdentifier, + depth: 1, + }; + } else { + section.previousKey = null; + } + + if (i < docConfig.sections.length - 1) { + section.nextKey = { + title: docConfig.sections[i + 1].title, + route: docConfig.sections[i + 1].indexIdentifier, + depth: 1, + }; + } else { + section.nextKey = null; + } + } + fs.writeFileSync( path.join(rootDir, "docs-config.ts"), `export const docsConfig = ${JSON.stringify(docConfig, null, 2)};` diff --git a/docs/src/types.ts b/docs/src/types.ts index e19e2bd4327..0226b3b0afb 100644 --- a/docs/src/types.ts +++ b/docs/src/types.ts @@ -13,6 +13,7 @@ export type DocSectionConfig = { editUrl: string; nextKey?: JumpLink; previousKey?: JumpLink; + content?: string; pages?: DocSectionConfig[]; jumpLinks: JumpLink[]; }; From 7051be0fb886a01ba56b7bc8e985f1ee5be6b834 Mon Sep 17 00:00:00 2001 From: Nathaniel Richards Date: Thu, 28 Sep 2023 08:57:08 -0400 Subject: [PATCH 006/195] Removed excess --- docs/README.md | 52 - docs/docs/docs-config.ts | 3629 -------------------------------------- docs/package.json | 25 - docs/spacetime-docs.json | 17 - docs/src/index.ts | 281 --- docs/src/types.ts | 25 - docs/tsconfig.json | 14 - docs/yarn.lock | 56 - 8 files changed, 4099 deletions(-) delete mode 100644 docs/README.md delete mode 100644 docs/docs/docs-config.ts delete mode 100644 docs/package.json delete mode 100644 docs/spacetime-docs.json delete mode 100644 docs/src/index.ts delete mode 100644 docs/src/types.ts delete mode 100644 docs/tsconfig.json delete mode 100644 docs/yarn.lock diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index f8b68ffde2b..00000000000 --- a/docs/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# Spacetime Docs CLI - -## How to use: - -1. run `yarn install` -2. run `yarn build` -3. run `npm i -g .` -4. run `spacetime-docs -h` in your terminal - -## Specify Docs Out Directory - -Create a `spacetime-docs.json` file in your project root and specify the `docPath` property. - -```json -{ - "docPath": "./docs" -} -``` - -## Specify The Sidebar Order - -In the `spacetime-docs.json` file in your project root add: - -> This will respect the order when generating the docs. - -```json - "order": [ - "Overview", - "Getting Started", - "Cloud Testnet", - "Unity Tutorial", - "Server Module Languages", - "Client SDK Languages", - "Module ABI Reference", - "HTTP API Reference", - "WebScoket API Reference", - "SATN Reference", - "SQL Reference" - ] -``` - -## Add tags - -Tags will show up next to the section title in the sidebar. In the `_category.json` file for a section add: - -```json -tag: "New" // Or anything else... -``` - -## Images - -When referencing your images upload straight to GITHUB in the `images` folder and use that link as our own makeshift CDN for now. diff --git a/docs/docs/docs-config.ts b/docs/docs/docs-config.ts deleted file mode 100644 index 7f58b31ca28..00000000000 --- a/docs/docs/docs-config.ts +++ /dev/null @@ -1,3629 +0,0 @@ -export const docsConfig = { - "sections": [ - { - "title": "Overview", - "identifier": "Overview", - "indexIdentifier": "index", - "comingSoon": false, - "hasPages": true, - "editUrl": "Overview/index.md", - "jumpLinks": [], - "pages": [ - { - "title": "SpacetimeDB Documentation", - "identifier": "index", - "indexIdentifier": "index", - "content": "# SpacetimeDB Documentation\r\n\r\n## Installation\r\n\r\nYou can run SpacetimeDB as a standalone database server via the `spacetime` CLI tool.\r\n\r\nYou can find the instructions to install the CLI tool for your platform [here](/install).\r\n\r\n\r\n\r\nTo get started running your own standalone instance of SpacetimeDB check out our [Getting Started Guide](/docs/getting-started).\r\n\r\n\r\n\r\n## What is SpacetimeDB?\r\n\r\nYou can think of SpacetimeDB as a database that is also a server.\r\n\r\nIt 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\".\r\n\r\nInstead 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.\r\n\r\nThis 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.\r\n\r\n
\r\n \"SpacetimeDB\r\n
\r\n SpacetimeDB application architecture\r\n (elements in white are provided by SpacetimeDB)\r\n
\r\n
\r\n\r\nIt'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.\r\n\r\nSo 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.\r\n\r\nSpacetimeDB 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.\r\n\r\nThis 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.\r\n\r\n## State Synchronization\r\n\r\nSpacetimeDB 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.\r\n\r\n## Identities\r\n\r\nAn 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`.\r\n\r\nSpacetimeDB 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.\r\n\r\nEach 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.\r\n\r\nAdditionally, 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.\r\n\r\nSpacetimeDB provides tools in the CLI and the [client SDKs](/docs/client-languages/client-sdk-overview) for managing credentials.\r\n\r\n## Language Support\r\n\r\n### Server-side Libraries\r\n\r\nCurrently, Rust is the best-supported language for writing SpacetimeDB modules. Support for lots of other languages is in the works!\r\n\r\n- [Rust](/docs/server-languages/rust/rust-module-reference) - [(Quickstart)](/docs/server-languages/rust/rust-module-quickstart-guide)\r\n- [C#](/docs/server-languages/csharp/csharp-module-reference) - [(Quickstart)](/docs/server-languages/csharp/csharp-module-quickstart-guide)\r\n- Python (Coming soon)\r\n- C# (Coming soon)\r\n- Typescript (Coming soon)\r\n- C++ (Planned)\r\n- Lua (Planned)\r\n\r\n### Client-side SDKs\r\n\r\n- [Rust](/docs/client-languages/rust/rust-sdk-reference) - [(Quickstart)](/docs/client-languages/rust/rust-sdk-quickstart-guide)\r\n- [C#](/docs/client-languages/csharp/csharp-sdk-reference) - [(Quickstart)](/docs/client-languages/csharp/csharp-sdk-quickstart-guide)\r\n- [TypeScript](/docs/client-languages/typescript/typescript-sdk-reference) - [(Quickstart)](client-languages/typescript/typescript-sdk-quickstart-guide)\r\n- [Python](/docs/client-languages/python/python-sdk-reference) - [(Quickstart)](/docs/python/python-sdk-quickstart-guide)\r\n- C++ (Planned)\r\n- Lua (Planned)\r\n\r\n### Unity\r\n\r\nSpacetimeDB 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).\r\n\r\n## FAQ\r\n\r\n1. What is SpacetimeDB?\r\n It's a whole cloud platform within a database that's fast enough to run real-time games.\r\n\r\n1. How do I use SpacetimeDB?\r\n 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.\r\n\r\n1. How do I get/install SpacetimeDB?\r\n Just install our command line tool and then upload your application to the cloud.\r\n\r\n1. How do I create a new database with SpacetimeDB?\r\n Follow our [Quick Start](/docs/quick-start) guide!\r\n\r\nTL;DR in an empty directory:\r\n\r\n```bash\r\nspacetime init --lang=rust\r\nspacetime publish\r\n```\r\n\r\n5. How do I create a Unity game with SpacetimeDB?\r\n Follow our [Unity Project](/docs/unity-project) guide!\r\n\r\nTL;DR in an empty directory:\r\n\r\n```bash\r\nspacetime init --lang=rust\r\nspacetime publish\r\nspacetime generate --out-dir --lang=csharp\r\n```\r\n", - "hasPages": false, - "editUrl": "index.md", - "jumpLinks": [ - { - "title": "SpacetimeDB Documentation", - "route": "spacetimedb-documentation", - "depth": 1 - }, - { - "title": "Installation", - "route": "installation", - "depth": 2 - }, - { - "title": "What is SpacetimeDB?", - "route": "what-is-spacetimedb-", - "depth": 2 - }, - { - "title": "State Synchronization", - "route": "state-synchronization", - "depth": 2 - }, - { - "title": "Identities", - "route": "identities", - "depth": 2 - }, - { - "title": "Language Support", - "route": "language-support", - "depth": 2 - }, - { - "title": "Server-side Libraries", - "route": "server-side-libraries", - "depth": 3 - }, - { - "title": "Client-side SDKs", - "route": "client-side-sdks", - "depth": 3 - }, - { - "title": "Unity", - "route": "unity", - "depth": 3 - }, - { - "title": "FAQ", - "route": "faq", - "depth": 2 - } - ], - "pages": [] - } - ], - "previousKey": null, - "nextKey": { - "title": "Getting Started", - "route": "index", - "depth": 1 - } - }, - { - "title": "Getting Started", - "identifier": "Getting Started", - "indexIdentifier": "index", - "comingSoon": false, - "hasPages": true, - "editUrl": "Getting%20Started/index.md", - "jumpLinks": [], - "pages": [ - { - "title": "Getting Started", - "identifier": "index", - "indexIdentifier": "index", - "content": "# Getting Started\r\n\r\nTo develop SpacetimeDB applications locally, you will need to run the Standalone version of the server.\r\n\r\n1. [Install](/install) the SpacetimeDB CLI (Command Line Interface).\r\n2. Run the start command\r\n\r\n```bash\r\nspacetime start\r\n```\r\n\r\nThe server listens on port `3000` by default. You can change this by using the `--listen-addr` option described below.\r\n\r\nSSL is not supported in standalone mode.\r\n\r\nTo set up your CLI to connect to the server, you can run the `spacetime server` command.\r\n\r\n```bash\r\nspacetime server set \"http://localhost:3000\"\r\n```\r\n\r\n## What's Next?\r\n\r\nYou are ready to start developing SpacetimeDB modules. We have a quickstart guide for each supported server-side language:\r\n\r\n- [Rust](/docs/server-languages/rust/rust-module-quickstart-guide)\r\n- [C#](/docs/server-languages/csharp/csharp-module-quickstart-guide)\r\n\r\nThen you can write your client application. We have a quickstart guide for each supported client-side language:\r\n\r\n- [Rust](/docs/client-languages/rust/rust-sdk-quickstart-guide)\r\n- [C#](/docs/client-languages/csharp/csharp-sdk-quickstart-guide)\r\n- [Typescript](/docs/client-languages/typescript/typescript-sdk-quickstart-guide)\r\n- [Python](/docs/client-languages/python/python-sdk-quickstart-guide)\r\n\r\nWe also have a [step-by-step tutorial](/docs/unity-tutorial/unity-tutorial-part-1) for building a multiplayer game in Unity3d.\r\n", - "hasPages": false, - "editUrl": "index.md", - "jumpLinks": [ - { - "title": "Getting Started", - "route": "getting-started", - "depth": 1 - }, - { - "title": "What's Next?", - "route": "what-s-next-", - "depth": 2 - } - ], - "pages": [] - } - ], - "previousKey": { - "title": "Overview", - "route": "index", - "depth": 1 - }, - "nextKey": { - "title": "Cloud Testnet", - "route": "index", - "depth": 1 - } - }, - { - "title": "Cloud Testnet", - "identifier": "Cloud Testnet", - "indexIdentifier": "index", - "comingSoon": false, - "hasPages": true, - "editUrl": "Cloud%20Testnet/index.md", - "jumpLinks": [], - "pages": [ - { - "title": "SpacetimeDB Cloud Deployment", - "identifier": "index", - "indexIdentifier": "index", - "content": "# SpacetimeDB Cloud Deployment\r\n\r\nThe SpacetimeDB Cloud is a managed cloud service that provides developers an easy way to deploy their SpacetimeDB apps to the cloud.\r\n\r\nCurrently only the `testnet` is available for SpacetimeDB cloud which is subject to wipes. The `mainnet` will be available soon.\r\n\r\n## Deploy via CLI\r\n\r\n1. [Install](/install) the SpacetimeDB CLI.\r\n1. Configure your CLI to use the SpacetimeDB Cloud. To do this, run the `spacetime server` command:\r\n\r\n```bash\r\nspacetime server set \"https://testnet.spacetimedb.com\"\r\n```\r\n\r\n## Connecting your Identity to the Web Dashboard\r\n\r\nBy associating an email with your CLI identity, you can view your published modules on the web dashboard.\r\n\r\n1. Get your identity using the `spacetime identity list` command. Copy it to your clipboard.\r\n1. Connect your email address to your identity using the `spacetime identity set-email` command:\r\n\r\n```bash\r\nspacetime identity set-email \r\n```\r\n\r\n1. Open the SpacetimeDB website and log in using your email address.\r\n1. Choose your identity from the dropdown menu.\r\n1. Validate your email address by clicking the link in the email you receive.\r\n1. You should now be able to see your published modules on the web dashboard.\r\n\r\n---\r\n\r\nWith SpacetimeDB Cloud, you benefit from automatic scaling, robust security, and the convenience of not having to manage the hosting environment.\r\n", - "hasPages": false, - "editUrl": "index.md", - "jumpLinks": [ - { - "title": "SpacetimeDB Cloud Deployment", - "route": "spacetimedb-cloud-deployment", - "depth": 1 - }, - { - "title": "Deploy via CLI", - "route": "deploy-via-cli", - "depth": 2 - }, - { - "title": "Connecting your Identity to the Web Dashboard", - "route": "connecting-your-identity-to-the-web-dashboard", - "depth": 2 - } - ], - "pages": [] - } - ], - "previousKey": { - "title": "Getting Started", - "route": "index", - "depth": 1 - }, - "nextKey": { - "title": "Unity Tutorial", - "route": "index", - "depth": 1 - } - }, - { - "title": "Unity Tutorial", - "identifier": "Unity Tutorial", - "indexIdentifier": "index", - "comingSoon": false, - "hasPages": true, - "editUrl": "Unity%20Tutorial/index.md", - "jumpLinks": [], - "pages": [ - { - "title": "Part 1 - Basic Multiplayer", - "identifier": "index", - "indexIdentifier": "index", - "content": "# Part 1 - Basic Multiplayer\r\n\r\n![UnityTutorial-HeroImage](/images/unity-tutorial/UnityTutorial-HeroImage.JPG)\r\n\r\nThe 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.\r\n\r\n## Setting up the Tutorial Unity Project\r\n\r\nIn 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.\r\n\r\n### Step 1: Create a Blank Unity Project\r\n\r\n1. Open Unity and create a new project by selecting \"New\" from the Unity Hub or going to **File -> New Project**.\r\n\r\n![UnityHub-NewProject](/images/unity-tutorial/UnityHub-NewProject.JPG)\r\n\r\n2. 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\".\r\n\r\nThis 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.\r\n\r\nEnsure that you have selected the **3D (URP)** template for this project.\r\n\r\n![UnityHub-3DURP](/images/unity-tutorial/UnityHub-3DURP.JPG)\r\n\r\n3. Click \"Create\" to generate the blank project.\r\n\r\n### Step 2: Adding Required Packages\r\n\r\nTo work with SpacetimeDB and ensure compatibility, we need to add some essential packages to our Unity project. Follow these steps:\r\n\r\n1. Open the Unity Package Manager by going to **Window -> Package Manager**.\r\n2. In the Package Manager window, select the \"Unity Registry\" tab to view unity packages.\r\n3. Search for and install the following package:\r\n - **Input System**: Enables the use of Unity's new Input system used by this project.\r\n\r\n![PackageManager-InputSystem](/images/unity-tutorial/PackageManager-InputSystem.JPG)\r\n\r\n4. You may need to restart the Unity Editor to switch to the new Input system.\r\n\r\n![PackageManager-Restart](/images/unity-tutorial/PackageManager-Restart.JPG)\r\n\r\n### Step 3: Importing the Tutorial Package\r\n\r\nIn this step, we will import the provided Unity tutorial package that contains the basic single-player game setup. Follow these instructions:\r\n\r\n1. 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)\r\n2. In Unity, go to **Assets -> Import Package -> Custom Package**.\r\n\r\n![Unity-ImportCustomPackageB](/images/unity-tutorial/Unity-ImportCustomPackageB.JPG)\r\n\r\n3. Browse and select the downloaded tutorial package file.\r\n4. 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.\r\n\r\n![Unity-ImportCustomPackage2](/images/unity-tutorial/Unity-ImportCustomPackage2.JPG)\r\n\r\n### Step 4: Running the Project\r\n\r\nNow that we have everything set up, let's run the project and see it in action:\r\n\r\n1. Open the scene named \"Main\" in the Scenes folder provided in the project hierarchy by double-clicking it.\r\n\r\n![Unity-OpenSceneMain](/images/unity-tutorial/Unity-OpenSceneMain.JPG)\r\n\r\nNOTE: 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.\r\n\r\n![Unity Import TMP Essentials](/images/unity-tutorial/Unity-ImportTMPEssentials.JPG)\r\n\r\n2. Press the **Play** button located at the top of the Unity Editor.\r\n\r\n![Unity-Play](/images/unity-tutorial/Unity-Play.JPG)\r\n\r\n3. Enter any name and click \"Continue\"\r\n\r\n4. You should see a character loaded in the scene, and you can use the keyboard or mouse controls to move the character around.\r\n\r\nCongratulations! 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.\r\n\r\n## Writing our SpacetimeDB Server Module\r\n\r\n### Step 1: Create the Module\r\n\r\n1. It is important that you already have SpacetimeDB [installed](/install).\r\n\r\n2. Run the SpacetimeDB standalone using the installed CLI. In your terminal or command window, run the following command:\r\n\r\n```bash\r\nspacetime start\r\n```\r\n\r\n3. Make sure your CLI is pointed to your local instance of SpacetimeDB. You can do this by running the following command:\r\n\r\n```bash\r\nspacetime server set http://localhost:3000\r\n```\r\n\r\n4. Open a new command prompt or terminal and navigate to the folder where your Unity project is located using the cd command. For example:\r\n\r\n```bash\r\ncd path/to/tutorial_project_folder\r\n```\r\n\r\n5. Run the following command to initialize the SpacetimeDB server project with Rust as the language:\r\n\r\n```bash\r\nspacetime init --lang=rust ./Server\r\n```\r\n\r\nThis 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.\r\n\r\n### Step 2: SpacetimeDB Tables\r\n\r\n1. Using your favorite code editor (we recommend VS Code) open the newly created lib.rs file in the Server folder.\r\n2. Erase everything in the file as we are going to be writing our module from scratch.\r\n\r\n---\r\n\r\n**Understanding ECS**\r\n\r\nECS 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).\r\n\r\nWe chose ECS for this example project because it promotes scalability, modularity, and efficient data management, making it ideal for building multiplayer games with SpacetimeDB.\r\n\r\n---\r\n\r\n3. Add the following code to lib.rs.\r\n\r\nWe 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.\r\n\r\nYou'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.\r\n\r\nThe `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\r\nglobal variables that can be accessed from anywhere.\r\n\r\nWe 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.\r\n\r\n```rust\r\nuse spacetimedb::{spacetimedb, Identity, SpacetimeType, Timestamp, ReducerContext};\r\nuse log;\r\n\r\n#[spacetimedb(table)]\r\n#[derive(Clone)]\r\npub struct Config {\r\n // Config is a global table with a single row. This table will be used to\r\n // store configuration or global variables\r\n\r\n #[primarykey]\r\n // always 0\r\n // having a table with a primarykey field which is always zero is a way to store singleton global state\r\n pub version: u32,\r\n\r\n pub message_of_the_day: String,\r\n}\r\n\r\n```\r\n\r\nThe 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.\r\n\r\nThe 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.\r\n\r\n```rust\r\n#[spacetimedb(table)]\r\npub struct SpawnableEntityComponent {\r\n // All entities that can be spawned in the world will have this component.\r\n // This allows us to find all objects in the world by iterating through\r\n // this table. It also ensures that all world objects have a unique\r\n // entity_id.\r\n\r\n #[primarykey]\r\n #[autoinc]\r\n pub entity_id: u64,\r\n}\r\n```\r\n\r\nThe `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.\r\n\r\n```rust\r\n#[derive(Clone)]\r\n#[spacetimedb(table)]\r\npub struct PlayerComponent {\r\n // All players have this component and it associates the spawnable entity\r\n // with the user's identity. It also stores their username.\r\n\r\n #[primarykey]\r\n pub entity_id: u64,\r\n #[unique]\r\n pub owner_id: Identity,\r\n\r\n // username is provided to the create_player reducer\r\n pub username: String,\r\n // this value is updated when the user logs in and out\r\n pub logged_in: bool,\r\n}\r\n```\r\n\r\nThe next component, `MobileLocationComponent`, is used to store the last known location and movement direction for spawnable entities that can move smoothly through the world.\r\n\r\nUsing 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.\r\n\r\nWe 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.\r\n\r\n```rust\r\n#[derive(SpacetimeType, Clone)]\r\npub struct StdbVector2 {\r\n // A spacetime type which can be used in tables and reducers to represent\r\n // a 2d position.\r\n pub x: f32,\r\n pub z: f32,\r\n}\r\n\r\nimpl StdbVector2 {\r\n // this allows us to use StdbVector2::ZERO in reducers\r\n pub const ZERO: StdbVector2 = StdbVector2 { x: 0.0, z: 0.0 };\r\n}\r\n\r\n#[spacetimedb(table)]\r\n#[derive(Clone)]\r\npub struct MobileLocationComponent {\r\n // This component will be created for all world objects that can move\r\n // smoothly throughout the world. It keeps track of the position the last\r\n // time the component was updated and the direction the mobile object is\r\n // currently moving.\r\n\r\n #[primarykey]\r\n pub entity_id: u64,\r\n\r\n // The last known location of this entity\r\n pub location: StdbVector2,\r\n // Movement direction, {0,0} if not moving at all.\r\n pub direction: StdbVector2,\r\n // Timestamp when movement started. Timestamp::UNIX_EPOCH if not moving.\r\n pub move_start_timestamp: Timestamp,\r\n}\r\n```\r\n\r\nNext we write our very first reducer, `create_player`. This reducer is called by the client after the user enters a username.\r\n\r\n---\r\n\r\n**SpacetimeDB Reducers**\r\n\r\n\"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.\r\n\r\n---\r\n\r\nThe 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.\r\n\r\nBefore 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.\r\n\r\n---\r\n\r\n**Rust Options**\r\n\r\nRust 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/).\r\n\r\n---\r\n\r\nThe 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.\r\n\r\nNote 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.\r\n\r\n---\r\n\r\n**Rust Results**\r\n\r\nA 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/).\r\n\r\n---\r\n\r\nWe then create and insert our `PlayerComponent` and `MobileLocationComponent` using the same `entity_id`.\r\n\r\nWe 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.\r\n\r\n```rust\r\n#[spacetimedb(reducer)]\r\npub fn create_player(ctx: ReducerContext, username: String) -> Result<(), String> {\r\n // This reducer is called when the user logs in for the first time and\r\n // enters a username\r\n\r\n let owner_id = ctx.sender;\r\n // We check to see if there is already a PlayerComponent with this identity.\r\n // this should never happen because the client only calls it if no player\r\n // is found.\r\n if PlayerComponent::filter_by_owner_id(&owner_id).is_some() {\r\n log::info!(\"Player already exists\");\r\n return Err(\"Player already exists\".to_string());\r\n }\r\n\r\n // Next we create the SpawnableEntityComponent. The entity_id for this\r\n // component automatically increments and we get it back from the result\r\n // of the insert call and use it for all components.\r\n\r\n let entity_id = SpawnableEntityComponent::insert(SpawnableEntityComponent { entity_id: 0 })\r\n .expect(\"Failed to create player spawnable entity component.\")\r\n .entity_id;\r\n // The PlayerComponent uses the same entity_id and stores the identity of\r\n // the owner, username, and whether or not they are logged in.\r\n PlayerComponent::insert(PlayerComponent {\r\n entity_id,\r\n owner_id,\r\n username: username.clone(),\r\n logged_in: true,\r\n })\r\n .expect(\"Failed to insert player component.\");\r\n // The MobileLocationComponent is used to calculate the current position\r\n // of an entity that can move smoothly in the world. We are using 2d\r\n // positions and the client will use the terrain height for the y value.\r\n MobileLocationComponent::insert(MobileLocationComponent {\r\n entity_id,\r\n location: StdbVector2::ZERO,\r\n direction: StdbVector2::ZERO,\r\n move_start_timestamp: Timestamp::UNIX_EPOCH,\r\n })\r\n .expect(\"Failed to insert player mobile entity component.\");\r\n\r\n log::info!(\"Player created: {}({})\", username, entity_id);\r\n\r\n Ok(())\r\n}\r\n```\r\n\r\nSpacetimeDB also gives you the ability to define custom reducers that automatically trigger when certain events occur.\r\n\r\n- `init` - Called the very first time you publish your module and anytime you clear the database. We'll learn about publishing a little later.\r\n- `connect` - Called when a user connects to the SpacetimeDB module. Their identity can be found in the `sender` member of the `ReducerContext`.\r\n- `disconnect` - Called when a user disconnects from the SpacetimeDB module.\r\n\r\nNext 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)`.\r\n\r\n```rust\r\n#[spacetimedb(init)]\r\npub fn init() {\r\n // Called when the module is initially published\r\n\r\n\r\n // Create our global config table.\r\n Config::insert(Config {\r\n version: 0,\r\n message_of_the_day: \"Hello, World!\".to_string(),\r\n })\r\n .expect(\"Failed to insert config.\");\r\n}\r\n```\r\n\r\nWe 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.\r\n\r\n```rust\r\n#[spacetimedb(connect)]\r\npub fn identity_connected(ctx: ReducerContext) {\r\n // called when the client connects, we update the logged_in state to true\r\n update_player_login_state(ctx, true);\r\n}\r\n\r\n\r\n#[spacetimedb(disconnect)]\r\npub fn identity_disconnected(ctx: ReducerContext) {\r\n // Called when the client disconnects, we update the logged_in state to false\r\n update_player_login_state(ctx, false);\r\n}\r\n\r\n\r\npub fn update_player_login_state(ctx: ReducerContext, logged_in: bool) {\r\n // This helper function gets the PlayerComponent, sets the logged\r\n // in variable and updates the SpacetimeDB table row.\r\n if let Some(player) = PlayerComponent::filter_by_owner_id(&ctx.sender) {\r\n let entity_id = player.entity_id;\r\n // We clone the PlayerComponent so we can edit it and pass it back.\r\n let mut player = player.clone();\r\n player.logged_in = logged_in;\r\n PlayerComponent::update_by_entity_id(&entity_id, player);\r\n }\r\n}\r\n```\r\n\r\nOur 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.\r\n\r\nUsing 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.\r\n\r\n---\r\n\r\n**Server Validation**\r\n\r\nIn 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.\r\n\r\n---\r\n\r\n```rust\r\n#[spacetimedb(reducer)]\r\npub fn move_player(\r\n ctx: ReducerContext,\r\n start: StdbVector2,\r\n direction: StdbVector2,\r\n) -> Result<(), String> {\r\n // Update the MobileLocationComponent with the current movement\r\n // values. The client will call this regularly as the direction of movement\r\n // changes. A fully developed game should validate these moves on the server\r\n // before committing them, but that is beyond the scope of this tutorial.\r\n\r\n let owner_id = ctx.sender;\r\n // First, look up the player using the sender identity, then use that\r\n // entity_id to retrieve and update the MobileLocationComponent\r\n if let Some(player) = PlayerComponent::filter_by_owner_id(&owner_id) {\r\n if let Some(mut mobile) = MobileLocationComponent::filter_by_entity_id(&player.entity_id) {\r\n mobile.location = start;\r\n mobile.direction = direction;\r\n mobile.move_start_timestamp = ctx.timestamp;\r\n MobileLocationComponent::update_by_entity_id(&player.entity_id, mobile);\r\n\r\n\r\n return Ok(());\r\n }\r\n }\r\n\r\n\r\n // If we can not find the PlayerComponent for this user something went wrong.\r\n // This should never happen.\r\n return Err(\"Player not found\".to_string());\r\n}\r\n\r\n\r\n#[spacetimedb(reducer)]\r\npub fn stop_player(ctx: ReducerContext, location: StdbVector2) -> Result<(), String> {\r\n // Update the MobileLocationComponent when a player comes to a stop. We set\r\n // the location to the current location and the direction to {0,0}\r\n let owner_id = ctx.sender;\r\n if let Some(player) = PlayerComponent::filter_by_owner_id(&owner_id) {\r\n if let Some(mut mobile) = MobileLocationComponent::filter_by_entity_id(&player.entity_id) {\r\n mobile.location = location;\r\n mobile.direction = StdbVector2::ZERO;\r\n mobile.move_start_timestamp = Timestamp::UNIX_EPOCH;\r\n MobileLocationComponent::update_by_entity_id(&player.entity_id, mobile);\r\n\r\n\r\n return Ok(());\r\n }\r\n }\r\n\r\n\r\n return Err(\"Player not found\".to_string());\r\n}\r\n```\r\n\r\n4. 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.\r\n\r\n```bash\r\ncd Server\r\n\r\nspacetime publish -c yourname-bitcraftmini\r\n```\r\n\r\nIf 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.\r\n\r\n## Updating our Unity Project to use SpacetimeDB\r\n\r\nNow we are ready to connect our bitcraft mini project to SpacetimeDB.\r\n\r\n### Step 1: Import the SDK and Generate Module Files\r\n\r\n1. 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.\r\n\r\n```bash\r\nhttps://github.com/clockworklabs/com.clockworklabs.spacetimedbsdk.git\r\n```\r\n\r\n![Unity-PackageManager](/images/unity-tutorial/Unity-PackageManager.JPG)\r\n\r\n3. 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.\r\n\r\n```bash\r\nmkdir -p ../Client/Assets/module_bindings\r\n\r\nspacetime generate --out-dir ../Client/Assets/module_bindings --lang=csharp\r\n```\r\n\r\n### Step 2: Connect to the SpacetimeDB Module\r\n\r\n1. 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.\r\n\r\n![Unity-AddNetworkManager](/images/unity-tutorial/Unity-AddNetworkManager.JPG)\r\n\r\n2. 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:\r\n\r\n`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`.\r\n\r\n```csharp\r\nusing SpacetimeDB;\r\nusing SpacetimeDB.Types;\r\n```\r\n\r\n3. Inside the class definition add the following members:\r\n\r\n```csharp\r\n // These are connection variables that are exposed on the GameManager\r\n // inspector. The cloud version of SpacetimeDB needs sslEnabled = true\r\n [SerializeField] private string moduleAddress = \"YOUR_MODULE_DOMAIN_OR_ADDRESS\";\r\n [SerializeField] private string hostName = \"localhost:3000\";\r\n [SerializeField] private bool sslEnabled = false;\r\n\r\n // This is the identity for this player that is automatically generated\r\n // the first time you log in. We set this variable when the\r\n // onIdentityReceived callback is triggered by the SDK after connecting\r\n private Identity local_identity;\r\n```\r\n\r\nThe 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.\r\n\r\n4. 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.\r\n\r\nIn 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.\r\n\r\n---\r\n\r\n**Local Client Cache**\r\n\r\nThe \"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.\r\n\r\n---\r\n\r\n```csharp\r\n // When we connect to SpacetimeDB we send our subscription queries\r\n // to tell SpacetimeDB which tables we want to get updates for.\r\n SpacetimeDBClient.instance.onConnect += () =>\r\n {\r\n Debug.Log(\"Connected.\");\r\n\r\n SpacetimeDBClient.instance.Subscribe(new List()\r\n {\r\n \"SELECT * FROM Config\",\r\n \"SELECT * FROM SpawnableEntityComponent\",\r\n \"SELECT * FROM PlayerComponent\",\r\n \"SELECT * FROM MobileLocationComponent\",\r\n });\r\n };\r\n\r\n // called when we have an error connecting to SpacetimeDB\r\n SpacetimeDBClient.instance.onConnectError += (error, message) =>\r\n {\r\n Debug.LogError($\"Connection error: \" + message);\r\n };\r\n\r\n // called when we are disconnected from SpacetimeDB\r\n SpacetimeDBClient.instance.onDisconnect += (closeStatus, error) =>\r\n {\r\n Debug.Log(\"Disconnected.\");\r\n };\r\n\r\n\r\n // called when we receive the client identity from SpacetimeDB\r\n SpacetimeDBClient.instance.onIdentityReceived += (token, identity) => {\r\n AuthToken.SaveToken(token);\r\n local_identity = identity;\r\n };\r\n\r\n\r\n // called after our local cache is populated from a Subscribe call\r\n SpacetimeDBClient.instance.onSubscriptionApplied += OnSubscriptionApplied;\r\n\r\n // now that we’ve registered all our callbacks, lets connect to\r\n // spacetimedb\r\n SpacetimeDBClient.instance.Connect(AuthToken.Token, hostName, moduleAddress, sslEnabled);\r\n```\r\n\r\n5. 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.\r\n\r\n```csharp\r\nvoid OnSubscriptionApplied()\r\n{\r\n // If we don't have any data for our player, then we are creating a\r\n // new one. Let's show the username dialog, which will then call the\r\n // create player reducer\r\n var player = PlayerComponent.FilterByOwnerId(local_identity);\r\n if (player == null)\r\n {\r\n // Show username selection\r\n UIUsernameChooser.instance.Show();\r\n }\r\n\r\n // Show the Message of the Day in our Config table of the Client Cache\r\n UIChatController.instance.OnChatMessageReceived(\"Message of the Day: \" + Config.FilterByVersion(0).MessageOfTheDay);\r\n\r\n // Now that we've done this work we can unregister this callback\r\n SpacetimeDBClient.instance.onSubscriptionApplied -= OnSubscriptionApplied;\r\n}\r\n```\r\n\r\n### Step 3: Adding the Multiplayer Functionality\r\n\r\n1. 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:\r\n\r\n```csharp\r\n LocalPlayer.instance.username = _usernameField.text;\r\n BitcraftMiniGameManager.instance.StartGame();\r\n```\r\n\r\nwith:\r\n\r\n```csharp\r\n // Call the SpacetimeDB CreatePlayer reducer\r\n Reducer.CreatePlayer(_usernameField.text);\r\n```\r\n\r\n2. 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.\r\n\r\n```csharp\r\n public ulong EntityId;\r\n\r\n public TMP_Text UsernameElement;\r\n\r\n public string Username { set { UsernameElement.text = value; } }\r\n\r\n void Start()\r\n {\r\n // initialize overhead name\r\n UsernameElement = GetComponentInChildren();\r\n var canvas = GetComponentInChildren();\r\n canvas.worldCamera = Camera.main;\r\n\r\n // get the username from the PlayerComponent for this object and set it in the UI\r\n PlayerComponent playerComp = PlayerComponent.FilterByEntityId(EntityId);\r\n Username = playerComp.Username;\r\n\r\n // get the last location for this player and set the initial\r\n // position\r\n MobileLocationComponent mobPos = MobileLocationComponent.FilterByEntityId(EntityId);\r\n Vector3 playerPos = new Vector3(mobPos.Location.X, 0.0f, mobPos.Location.Z);\r\n transform.position = new Vector3(playerPos.x, MathUtil.GetTerrainHeight(playerPos), playerPos.z);\r\n\r\n // register for a callback that is called when the client gets an\r\n // update for a row in the MobileLocationComponent table\r\n MobileLocationComponent.OnUpdate += MobileLocationComponent_OnUpdate;\r\n }\r\n```\r\n\r\n3. 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)\r\n\r\n```csharp\r\n private void MobileLocationComponent_OnUpdate(MobileLocationComponent oldObj, MobileLocationComponent obj, ReducerEvent callInfo)\r\n {\r\n // if the update was made to this object\r\n if(obj.EntityId == EntityId)\r\n {\r\n // update the DirectionVec in the PlayerMovementController component with the updated values\r\n var movementController = GetComponent();\r\n movementController.DirectionVec = new Vector3(obj.Direction.X, 0.0f, obj.Direction.Z);\r\n // if DirectionVec is {0,0,0} then we came to a stop so correct our position to match the server\r\n if (movementController.DirectionVec == Vector3.zero)\r\n {\r\n Vector3 playerPos = new Vector3(obj.Location.X, 0.0f, obj.Location.Z);\r\n transform.position = new Vector3(playerPos.x, MathUtil.GetTerrainHeight(playerPos), playerPos.z);\r\n }\r\n }\r\n }\r\n```\r\n\r\n4. 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`.\r\n\r\n```csharp\r\n PlayerComponent.OnInsert += PlayerComponent_OnInsert;\r\n```\r\n\r\n5. 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.\r\n\r\n```csharp\r\n private void PlayerComponent_OnInsert(PlayerComponent obj, ReducerEvent callInfo)\r\n {\r\n // if the identity of the PlayerComponent matches our user identity then this is the local player\r\n if(obj.OwnerId == local_identity)\r\n {\r\n // Set the local player username\r\n LocalPlayer.instance.Username = obj.Username;\r\n\r\n // Get the MobileLocationComponent for this object and update the position to match the server\r\n MobileLocationComponent mobPos = MobileLocationComponent.FilterByEntityId(obj.EntityId);\r\n Vector3 playerPos = new Vector3(mobPos.Location.X, 0.0f, mobPos.Location.Z);\r\n LocalPlayer.instance.transform.position = new Vector3(playerPos.x, MathUtil.GetTerrainHeight(playerPos), playerPos.z);\r\n\r\n // Now that we have our initial position we can start the game\r\n StartGame();\r\n }\r\n // otherwise this is a remote player\r\n else\r\n {\r\n // spawn the player object and attach the RemotePlayer component\r\n var remotePlayer = Instantiate(PlayerPrefab);\r\n remotePlayer.AddComponent().EntityId = obj.EntityId;\r\n }\r\n }\r\n```\r\n\r\n6. 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\r\n\r\n```csharp\r\n private Vector3? lastUpdateDirection;\r\n\r\n private void FixedUpdate()\r\n {\r\n var directionVec = GetDirectionVec();\r\n PlayerMovementController.Local.DirectionVec = directionVec;\r\n\r\n // first get the position of the player\r\n var ourPos = PlayerMovementController.Local.GetModelTransform().position;\r\n // if we are moving , and we haven't updated our destination yet, or we've moved more than .1 units, update our destination\r\n if (directionVec.sqrMagnitude != 0 && (!lastUpdateDirection.HasValue || (directionVec - lastUpdateDirection.Value).sqrMagnitude > .1f))\r\n {\r\n Reducer.MovePlayer(new StdbVector2() { X = ourPos.x, Z = ourPos.z }, new StdbVector2() { X = directionVec.x, Z = directionVec.z });\r\n lastUpdateDirection = directionVec;\r\n }\r\n // if we stopped moving, send the update\r\n else if(directionVec.sqrMagnitude == 0 && lastUpdateDirection != null)\r\n {\r\n Reducer.StopPlayer(new StdbVector2() { X = ourPos.x, Z = ourPos.z });\r\n lastUpdateDirection = null;\r\n }\r\n }\r\n```\r\n\r\n7. 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.\r\n\r\n![GameManager-Inspector2](/images/unity-tutorial/GameManager-Inspector2.JPG)\r\n\r\n### Step 4: Play the Game!\r\n\r\n1. Go to File -> Build Settings... Replace the SampleScene with the Main scene we have been working in.\r\n\r\n![Unity-AddOpenScenes](/images/unity-tutorial/Unity-AddOpenScenes.JPG)\r\n\r\nWhen 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.\r\n\r\n### Step 5: Implement Player Logout\r\n\r\nSo 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.\r\n\r\n1. Open `BitcraftMiniGameManager.cs` and add the following code to the `Start` function:\r\n\r\n```csharp\r\n PlayerComponent.OnUpdate += PlayerComponent_OnUpdate;\r\n```\r\n\r\n2. 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.\r\n\r\n```csharp\r\n private void PlayerComponent_OnUpdate(PlayerComponent oldValue, PlayerComponent newValue, ReducerEvent dbEvent)\r\n {\r\n OnPlayerComponentChanged(newValue);\r\n }\r\n\r\n private void PlayerComponent_OnInsert(PlayerComponent obj, ReducerEvent dbEvent)\r\n {\r\n OnPlayerComponentChanged(obj);\r\n }\r\n\r\n private void OnPlayerComponentChanged(PlayerComponent obj)\r\n {\r\n // if the identity of the PlayerComponent matches our user identity then this is the local player\r\n if (obj.OwnerId == local_identity)\r\n {\r\n // Set the local player username\r\n LocalPlayer.instance.Username = obj.Username;\r\n\r\n // Get the MobileLocationComponent for this object and update the position to match the server\r\n MobileLocationComponent mobPos = MobileLocationComponent.FilterByEntityId(obj.EntityId);\r\n Vector3 playerPos = new Vector3(mobPos.Location.X, 0.0f, mobPos.Location.Z);\r\n LocalPlayer.instance.transform.position = new Vector3(playerPos.x, MathUtil.GetTerrainHeight(playerPos), playerPos.z);\r\n\r\n // Now that we have our initial position we can start the game\r\n StartGame();\r\n }\r\n // otherwise this is a remote player\r\n else\r\n {\r\n // if the remote player is logged in, spawn it\r\n if (obj.LoggedIn)\r\n {\r\n // spawn the player object and attach the RemotePlayer component\r\n var remotePlayer = Instantiate(PlayerPrefab);\r\n remotePlayer.AddComponent().EntityId = obj.EntityId;\r\n }\r\n // otherwise we need to look for the remote player object in the scene (if it exists) and destroy it\r\n else\r\n {\r\n var remotePlayer = FindObjectsOfType().FirstOrDefault(item => item.EntityId == obj.EntityId);\r\n if (remotePlayer != null)\r\n {\r\n Destroy(remotePlayer.gameObject);\r\n }\r\n }\r\n }\r\n }\r\n```\r\n\r\n3. Now you when you play the game you should see remote players disappear when they log out.\r\n\r\n### Step 6: Add Chat Support\r\n\r\nThe 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.\r\n\r\n1. First lets add a new `ChatMessage` table to the SpacetimeDB module. Add the following code to lib.rs.\r\n\r\n```rust\r\n#[spacetimedb(table)]\r\npub struct ChatMessage {\r\n // The primary key for this table will be auto-incremented\r\n #[primarykey]\r\n #[autoinc]\r\n pub chat_entity_id: u64,\r\n\r\n // The entity id of the player (or NPC) that sent the message\r\n pub source_entity_id: u64,\r\n // Message contents\r\n pub chat_text: String,\r\n // Timestamp of when the message was sent\r\n pub timestamp: Timestamp,\r\n}\r\n```\r\n\r\n2. Now we need to add a reducer to handle inserting new chat messages. Add the following code to lib.rs.\r\n\r\n```rust\r\n#[spacetimedb(reducer)]\r\npub fn chat_message(ctx: ReducerContext, message: String) -> Result<(), String> {\r\n // Add a chat entry to the ChatMessage table\r\n\r\n // Get the player component based on the sender identity\r\n let owner_id = ctx.sender;\r\n if let Some(player) = PlayerComponent::filter_by_owner_id(&owner_id) {\r\n // Now that we have the player we can insert the chat message using the player entity id.\r\n ChatMessage::insert(ChatMessage {\r\n // this column auto-increments so we can set it to 0\r\n chat_entity_id: 0,\r\n source_entity_id: player.entity_id,\r\n chat_text: message,\r\n timestamp: ctx.timestamp,\r\n })\r\n .unwrap();\r\n\r\n return Ok(());\r\n }\r\n\r\n Err(\"Player not found\".into())\r\n}\r\n```\r\n\r\n3. Before updating the client, let's generate the client files and publish our module.\r\n\r\n```bash\r\nspacetime generate --out-dir ../Client/Assets/module_bindings --lang=csharp\r\n\r\nspacetime publish -c yourname-bitcraftmini\r\n```\r\n\r\n4. 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`.\r\n\r\n```csharp\r\npublic void OnChatButtonPress()\r\n{\r\n Reducer.ChatMessage(_chatInput.text);\r\n _chatInput.text = \"\";\r\n}\r\n```\r\n\r\n5. Next let's add the `ChatMessage` table to our list of subscriptions.\r\n\r\n```csharp\r\n SpacetimeDBClient.instance.Subscribe(new List()\r\n {\r\n \"SELECT * FROM Config\",\r\n \"SELECT * FROM SpawnableEntityComponent\",\r\n \"SELECT * FROM PlayerComponent\",\r\n \"SELECT * FROM MobileLocationComponent\",\r\n \"SELECT * FROM ChatMessage\",\r\n });\r\n```\r\n\r\n6. 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:\r\n\r\n```csharp\r\n Reducer.OnChatMessageEvent += OnChatMessageEvent;\r\n```\r\n\r\nThen 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.\r\n\r\n```csharp\r\n private void OnChatMessageEvent(ReducerEvent dbEvent, string message)\r\n {\r\n var player = PlayerComponent.FilterByOwnerId(dbEvent.Identity);\r\n if (player != null)\r\n {\r\n UIChatController.instance.OnChatMessageReceived(player.Username + \": \" + message);\r\n }\r\n }\r\n```\r\n\r\n7. 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.\r\n\r\n## Conclusion\r\n\r\nThis 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.\r\n\r\n---\r\n\r\n### Troubleshooting\r\n\r\n- 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`\r\n\r\n- If you get this exception when running the project:\r\n\r\n```\r\nNullReferenceException: Object reference not set to an instance of an object\r\nBitcraftMiniGameManager.Start () (at Assets/_Project/Game/BitcraftMiniGameManager.cs:26)\r\n```\r\n\r\nCheck to see if your GameManager object in the Scene has the NetworkManager component attached.\r\n\r\n- 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.\r\n\r\n```\r\nConnection error: Unable to connect to the remote server\r\n```\r\n", - "hasPages": false, - "editUrl": "index.md", - "jumpLinks": [ - { - "title": "Part 1 - Basic Multiplayer", - "route": "part-1-basic-multiplayer", - "depth": 1 - }, - { - "title": "Setting up the Tutorial Unity Project", - "route": "setting-up-the-tutorial-unity-project", - "depth": 2 - }, - { - "title": "Step 1: Create a Blank Unity Project", - "route": "step-1-create-a-blank-unity-project", - "depth": 3 - }, - { - "title": "Step 2: Adding Required Packages", - "route": "step-2-adding-required-packages", - "depth": 3 - }, - { - "title": "Step 3: Importing the Tutorial Package", - "route": "step-3-importing-the-tutorial-package", - "depth": 3 - }, - { - "title": "Step 4: Running the Project", - "route": "step-4-running-the-project", - "depth": 3 - }, - { - "title": "Writing our SpacetimeDB Server Module", - "route": "writing-our-spacetimedb-server-module", - "depth": 2 - }, - { - "title": "Step 1: Create the Module", - "route": "step-1-create-the-module", - "depth": 3 - }, - { - "title": "Step 2: SpacetimeDB Tables", - "route": "step-2-spacetimedb-tables", - "depth": 3 - }, - { - "title": "Updating our Unity Project to use SpacetimeDB", - "route": "updating-our-unity-project-to-use-spacetimedb", - "depth": 2 - }, - { - "title": "Step 1: Import the SDK and Generate Module Files", - "route": "step-1-import-the-sdk-and-generate-module-files", - "depth": 3 - }, - { - "title": "Step 2: Connect to the SpacetimeDB Module", - "route": "step-2-connect-to-the-spacetimedb-module", - "depth": 3 - }, - { - "title": "Step 3: Adding the Multiplayer Functionality", - "route": "step-3-adding-the-multiplayer-functionality", - "depth": 3 - }, - { - "title": "Step 4: Play the Game!", - "route": "step-4-play-the-game-", - "depth": 3 - }, - { - "title": "Step 5: Implement Player Logout", - "route": "step-5-implement-player-logout", - "depth": 3 - }, - { - "title": "Step 6: Add Chat Support", - "route": "step-6-add-chat-support", - "depth": 3 - }, - { - "title": "Conclusion", - "route": "conclusion", - "depth": 2 - }, - { - "title": "Troubleshooting", - "route": "troubleshooting", - "depth": 3 - } - ], - "pages": [] - }, - { - "title": "Part 2 - Resources and Scheduling", - "identifier": "Part 2 - Resources And Scheduling", - "indexIdentifier": "Part 2 - Resources And Scheduling", - "hasPages": false, - "content": "# Part 2 - Resources and Scheduling\r\n\r\nIn 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.\r\n\r\n## Add Resource Node Spawner\r\n\r\nIn this section we will add functionality to our server to spawn the resource nodes.\r\n\r\n### Step 1: Add the SpacetimeDB Tables for Resource Nodes\r\n\r\n1. 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.\r\n\r\n```toml\r\nrand = \"0.8.5\"\r\n```\r\n\r\nWe also need to add the `getrandom` feature to our SpacetimeDB crate. Update the `spacetimedb` line to:\r\n\r\n```toml\r\nspacetimedb = { \"0.5\", features = [\"getrandom\"] }\r\n```\r\n\r\n2. 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.\r\n\r\n```rust\r\n#[derive(SpacetimeType, Clone)]\r\npub enum ResourceNodeType {\r\n Iron,\r\n}\r\n\r\n#[spacetimedb(table)]\r\n#[derive(Clone)]\r\npub struct ResourceNodeComponent {\r\n #[primarykey]\r\n pub entity_id: u64,\r\n\r\n // Resource type of this resource node\r\n pub resource_type: ResourceNodeType,\r\n}\r\n```\r\n\r\nBecause 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.\r\n\r\n```rust\r\n#[spacetimedb(table)]\r\n#[derive(Clone)]\r\npub struct StaticLocationComponent {\r\n #[primarykey]\r\n pub entity_id: u64,\r\n\r\n pub location: StdbVector2,\r\n pub rotation: f32,\r\n}\r\n```\r\n\r\n3. 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.\r\n\r\n```rust\r\n#[spacetimedb(table)]\r\npub struct Config {\r\n // Config is a global table with a single row. This table will be used to\r\n // store configuration or global variables\r\n\r\n #[primarykey]\r\n // always 0\r\n // having a table with a primarykey field which is always zero is a way to store singleton global state\r\n pub version: u32,\r\n\r\n pub message_of_the_day: String,\r\n\r\n // new variables for resource node spawner\r\n // X and Z range of the map (-map_extents to map_extents)\r\n pub map_extents: u32,\r\n // maximum number of resource nodes to spawn on the map\r\n pub num_resource_nodes: u32,\r\n}\r\n```\r\n\r\n4. In the `init` reducer, we need to set the initial values of our two new variables. Update the following code:\r\n\r\n```rust\r\n Config::insert(Config {\r\n version: 0,\r\n message_of_the_day: \"Hello, World!\".to_string(),\r\n\r\n // new variables for resource node spawner\r\n map_extents: 25,\r\n num_resource_nodes: 10,\r\n })\r\n .expect(\"Failed to insert config.\");\r\n```\r\n\r\n### Step 2: Write our Resource Spawner Repeating Reducer\r\n\r\n1. 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.\r\n\r\n```rust\r\n#[spacetimedb(reducer, repeat = 1000ms)]\r\npub fn resource_spawner_agent(_ctx: ReducerContext, _prev_time: Timestamp) -> Result<(), String> {\r\n let config = Config::filter_by_version(&0).unwrap();\r\n\r\n // Retrieve the maximum number of nodes we want to spawn from the Config table\r\n let num_resource_nodes = config.num_resource_nodes as usize;\r\n\r\n // Count the number of nodes currently spawned and exit if we have reached num_resource_nodes\r\n let num_resource_nodes_spawned = ResourceNodeComponent::iter().count();\r\n if num_resource_nodes_spawned >= num_resource_nodes {\r\n log::info!(\"All resource nodes spawned. Skipping.\");\r\n return Ok(());\r\n }\r\n\r\n // Pick a random X and Z based off the map_extents\r\n let mut rng = rand::thread_rng();\r\n let map_extents = config.map_extents as f32;\r\n let location = StdbVector2 {\r\n x: rng.gen_range(-map_extents..map_extents),\r\n z: rng.gen_range(-map_extents..map_extents),\r\n };\r\n // Pick a random Y rotation in degrees\r\n let rotation = rng.gen_range(0.0..360.0);\r\n\r\n // Insert our SpawnableEntityComponent which assigns us our entity_id\r\n let entity_id = SpawnableEntityComponent::insert(SpawnableEntityComponent { entity_id: 0 })\r\n .expect(\"Failed to create resource spawnable entity component.\")\r\n .entity_id;\r\n\r\n // Insert our static location with the random position and rotation we selected\r\n StaticLocationComponent::insert(StaticLocationComponent {\r\n entity_id,\r\n location: location.clone(),\r\n rotation,\r\n })\r\n .expect(\"Failed to insert resource static location component.\");\r\n\r\n // Insert our resource node component, so far we only have iron\r\n ResourceNodeComponent::insert(ResourceNodeComponent {\r\n entity_id,\r\n resource_type: ResourceNodeType::Iron,\r\n })\r\n .expect(\"Failed to insert resource node component.\");\r\n\r\n // Log that we spawned a node with the entity_id and location\r\n log::info!(\r\n \"Resource node spawned: {} at ({}, {})\",\r\n entity_id,\r\n location.x,\r\n location.z,\r\n );\r\n\r\n Ok(())\r\n}\r\n```\r\n\r\n2. Since this reducer uses `rand::Rng` we need add include it. Add this `use` statement to the top of lib.rs.\r\n\r\n```rust\r\nuse rand::Rng;\r\n```\r\n\r\n3. 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.\r\n\r\n```rust\r\n // Start our resource spawner repeating reducer\r\n spacetimedb::schedule!(\"1000ms\", resource_spawner_agent(_, Timestamp::now()));\r\n```\r\n\r\n4. 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:\r\n\r\n```bash\r\nspacetime generate --out-dir ../Assets/autogen --lang=csharp\r\n\r\nspacetime publish -c yourname/bitcraftmini\r\n```\r\n\r\nYour 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.\r\n\r\n```bash\r\nspacetime logs -f yourname/bitcraftmini\r\n```\r\n\r\n### Step 3: Spawn the Resource Nodes on the Client\r\n\r\n1. 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`.\r\n\r\n```csharp\r\n public ulong EntityId;\r\n\r\n public ResourceNodeType Type = ResourceNodeType.Iron;\r\n```\r\n\r\n2. 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.\r\n\r\n```csharp\r\n var resourceType = res?.Type ?? ResourceNodeType.Iron;\r\n switch (resourceType)\r\n {\r\n case ResourceNodeType.Iron:\r\n _animator.SetTrigger(\"Mine\");\r\n Interacting = true;\r\n break;\r\n default:\r\n Interacting = false;\r\n break;\r\n }\r\n for (int i = 0; i < _tools.Length; i++)\r\n {\r\n _tools[i].SetActive(((int)resourceType) == i);\r\n }\r\n _target = res;\r\n```\r\n\r\n3. 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:\r\n\r\n```csharp\r\n SpacetimeDBClient.instance.Subscribe(new List()\r\n {\r\n \"SELECT * FROM Config\",\r\n \"SELECT * FROM SpawnableEntityComponent\",\r\n \"SELECT * FROM PlayerComponent\",\r\n \"SELECT * FROM MobileEntityComponent\",\r\n // Our new tables for part 2 of the tutorial\r\n \"SELECT * FROM ResourceNodeComponent\",\r\n \"SELECT * FROM StaticLocationComponent\"\r\n });\r\n```\r\n\r\n4. Next let's add an `OnInsert` handler for the `ResourceNodeComponent`. Add the following line to the `Start` function.\r\n\r\n```csharp\r\n ResourceNodeComponent.OnInsert += ResourceNodeComponent_OnInsert;\r\n```\r\n\r\n5. 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.\r\n\r\nTo get the position and the rotation of the node, we look up the `StaticLocationComponent` for this entity by using the EntityId.\r\n\r\n```csharp\r\n private void ResourceNodeComponent_OnInsert(ResourceNodeComponent insertedValue, ReducerEvent callInfo)\r\n {\r\n switch(insertedValue.ResourceType)\r\n {\r\n case ResourceNodeType.Iron:\r\n var iron = Instantiate(IronPrefab);\r\n StaticLocationComponent loc = StaticLocationComponent.FilterByEntityId(insertedValue.EntityId);\r\n Vector3 nodePos = new Vector3(loc.Location.X, 0.0f, loc.Location.Z);\r\n iron.transform.position = new Vector3(nodePos.x, MathUtil.GetTerrainHeight(nodePos), nodePos.z);\r\n iron.transform.rotation = Quaternion.Euler(0.0f, loc.Rotation, 0.0f);\r\n break;\r\n }\r\n }\r\n```\r\n\r\n### Step 4: Play the Game!\r\n\r\n6. Hit Play in the Unity Editor and you should now see your resource nodes spawning in the world!\r\n", - "editUrl": "Part%202%20-%20Resources%20And%20Scheduling.md", - "jumpLinks": [ - { - "title": "Part 2 - Resources and Scheduling", - "route": "part-2-resources-and-scheduling", - "depth": 1 - }, - { - "title": "Add Resource Node Spawner", - "route": "add-resource-node-spawner", - "depth": 2 - }, - { - "title": "Step 1: Add the SpacetimeDB Tables for Resource Nodes", - "route": "step-1-add-the-spacetimedb-tables-for-resource-nodes", - "depth": 3 - }, - { - "title": "Step 2: Write our Resource Spawner Repeating Reducer", - "route": "step-2-write-our-resource-spawner-repeating-reducer", - "depth": 3 - }, - { - "title": "Step 3: Spawn the Resource Nodes on the Client", - "route": "step-3-spawn-the-resource-nodes-on-the-client", - "depth": 3 - }, - { - "title": "Step 4: Play the Game!", - "route": "step-4-play-the-game-", - "depth": 3 - } - ], - "pages": [] - }, - { - "title": "Part 3 - BitCraft Mini", - "identifier": "Part 3 - BitCraft Mini", - "indexIdentifier": "Part 3 - BitCraft Mini", - "hasPages": false, - "content": "# Part 3 - BitCraft Mini\r\n\r\nBitCraft 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.\r\n\r\n## 1. Download\r\n\r\nYou can git-clone BitCraftMini from here:\r\n\r\n```plaintext\r\ngit clone ssh://git@github.com/clockworklabs/BitCraftMini\r\n```\r\n\r\nOnce you have downloaded BitCraftMini, you will need to compile the spacetime module.\r\n\r\n## 2. Compile the Spacetime Module\r\n\r\nIn order to compile the BitCraftMini module, you will need to install cargo. You can install cargo from here:\r\n\r\n> https://www.rust-lang.org/tools/install\r\n\r\nOnce you have cargo installed, you can compile and publish the module with these commands:\r\n\r\n```bash\r\ncd BitCraftMini/Server\r\nspacetime publish\r\n```\r\n\r\n`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:\r\n\r\n```plaintext\r\n$ spacetime publish\r\ninfo: component 'rust-std' for target 'wasm32-unknown-unknown' is up to date\r\n Finished release [optimized] target(s) in 0.03s\r\nPublish finished successfully.\r\nCreated new database with address: c91c17ecdcea8a05302be2bad9dd59b3\r\n```\r\n\r\nOptionally, you can specify a name when you publish the module:\r\n\r\n```bash\r\nspacetime publish \"unique-module-name\"\r\n```\r\n\r\nCurrently, 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.\r\n\r\nIn 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:\r\n\r\n```bash\r\nspacetime call \"\" \"initialize\" \"[]\"\r\n```\r\n\r\nHere 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.\r\n\r\nAfter you have called `initialize()` on the spacetime module you shouldgenerate the client files:\r\n\r\n```bash\r\nspacetime generate --out-dir ../Client/Assets/_Project/autogen --lang=cs\r\n```\r\n\r\nHere is some sample output:\r\n\r\n```plaintext\r\n$ spacetime generate --out-dir ../Client/Assets/_Project/autogen --lang cs\r\ninfo: component 'rust-std' for target 'wasm32-unknown-unknown' is up to date\r\n Finished release [optimized] target(s) in 0.03s\r\ncompilation took 234.613518ms\r\nGenerate finished successfully.\r\n```\r\n\r\nIf you've gotten this message then everything should be working properly so far.\r\n\r\n## 3. Replace address in BitCraftMiniGameManager\r\n\r\nThe following settings are exposed in the `BitCraftMiniGameManager` inspector: Module Address, Host Name, and SSL Enabled.\r\n\r\nOpen the Main scene in Unity and click on the `GameManager` object in the heirarchy. The inspector window will look like this:\r\n\r\n![GameManager-Inspector](/images/unity-tutorial/GameManager-Inspector.JPG)\r\n\r\nUpdate 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)\r\n\r\n## 4. Play Mode\r\n\r\nYou 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.\r\n\r\n## 5. Editing the Module\r\n\r\nIf you want to make further updates to the module, make sure to use this publish command instead:\r\n\r\n```bash\r\nspacetime publish \r\n```\r\n\r\nWhere `` is your own address. If you do this instead then you won't have to change the address inside of `BitCraftMiniGameManager.cs`\r\n\r\nWhen you change the server module you should also regenerate the client files as well:\r\n\r\n```bash\r\nspacetime generate --out-dir ../Client/Assets/_Project/autogen --lang=cs\r\n```\r\n\r\nYou may want to consider putting these 2 commands into a simple shell script to make the process a bit cleaner.\r\n", - "editUrl": "Part%203%20-%20BitCraft%20Mini.md", - "jumpLinks": [ - { - "title": "Part 3 - BitCraft Mini", - "route": "part-3-bitcraft-mini", - "depth": 1 - }, - { - "title": "1. Download", - "route": "1-download", - "depth": 2 - }, - { - "title": "2. Compile the Spacetime Module", - "route": "2-compile-the-spacetime-module", - "depth": 2 - }, - { - "title": "3. Replace address in BitCraftMiniGameManager", - "route": "3-replace-address-in-bitcraftminigamemanager", - "depth": 2 - }, - { - "title": "4. Play Mode", - "route": "4-play-mode", - "depth": 2 - }, - { - "title": "5. Editing the Module", - "route": "5-editing-the-module", - "depth": 2 - } - ], - "pages": [] - } - ], - "previousKey": { - "title": "Cloud Testnet", - "route": "index", - "depth": 1 - }, - "nextKey": { - "title": "Server Module Languages", - "route": "index", - "depth": 1 - } - }, - { - "title": "Server Module Languages", - "identifier": "Server Module Languages", - "indexIdentifier": "index", - "comingSoon": false, - "hasPages": true, - "editUrl": "Server%20Module%20Languages/index.md", - "jumpLinks": [], - "pages": [ - { - "title": "C#", - "identifier": "C#", - "indexIdentifier": "index", - "comingSoon": false, - "tag": "Expiremental", - "hasPages": true, - "editUrl": "C%23/index.md", - "jumpLinks": [], - "pages": [ - { - "title": "C# Module Quickstart", - "identifier": "index", - "indexIdentifier": "index", - "content": "# C# Module Quickstart\r\n\r\nIn this tutorial, we'll implement a simple chat server as a SpacetimeDB module.\r\n\r\nA 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.\r\n\r\nEach SpacetimeDB module defines a set of tables and a set of reducers.\r\n\r\nEach table is defined as a C# `class` annotated with `[SpacetimeDB.Table]`, where an instance represents a row, and each field represents a column.\r\n\r\nA 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.\r\n\r\n## Install SpacetimeDB\r\n\r\nIf 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.\r\n\r\n## Install .NET\r\n\r\nNext we need to [install .NET](https://dotnet.microsoft.com/en-us/download/dotnet) so that we can build and publish our module.\r\n\r\n## Project structure\r\n\r\nCreate and enter a directory `quickstart-chat`:\r\n\r\n```bash\r\nmkdir quickstart-chat\r\ncd quickstart-chat\r\n```\r\n\r\nNow create `server`, our module, which runs in the database:\r\n\r\n```bash\r\nspacetime init --lang csharp server\r\n```\r\n\r\n## Declare imports\r\n\r\n`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.\r\n\r\nTo the top of `server/Lib.cs`, add some imports we'll be using:\r\n\r\n```C#\r\nusing System.Runtime.CompilerServices;\r\nusing SpacetimeDB.Module;\r\nusing static SpacetimeDB.Runtime;\r\n```\r\n\r\n- `System.Runtime.CompilerServices` allows us to use the `ModuleInitializer` attribute, which we'll use to register our `OnConnect` and `OnDisconnect` callbacks.\r\n- `SpacetimeDB.Module` contains the special attributes we'll use to define our module.\r\n- `SpacetimeDB.Runtime` contains the raw API bindings SpacetimeDB uses to communicate with the database.\r\n\r\nWe also need to create our static module class which all of the module code will live in. In `server/Lib.cs`, add:\r\n\r\n```csharp\r\nstatic partial class Module\r\n{\r\n}\r\n```\r\n\r\n## Define tables\r\n\r\nTo 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.\r\n\r\nFor 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.\r\n\r\nIn `server/Lib.cs`, add the definition of the table `User` to the `Module` class:\r\n\r\n```C#\r\n [SpacetimeDB.Table]\r\n public partial class User\r\n {\r\n [SpacetimeDB.Column(ColumnAttrs.PrimaryKey)]\r\n public Identity Identity;\r\n public string? Name;\r\n public bool Online;\r\n }\r\n```\r\n\r\nFor 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.\r\n\r\nIn `server/Lib.cs`, add the definition of the table `Message` to the `Module` class:\r\n\r\n```C#\r\n [SpacetimeDB.Table]\r\n public partial class Message\r\n {\r\n public Identity Sender;\r\n public long Sent;\r\n public string Text = \"\";\r\n }\r\n```\r\n\r\n## Set users' names\r\n\r\nWe 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.\r\n\r\nEach 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`.\r\n\r\nIt'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.\r\n\r\nIn `server/Lib.cs`, add to the `Module` class:\r\n\r\n```C#\r\n [SpacetimeDB.Reducer]\r\n public static void SetName(DbEventArgs dbEvent, string name)\r\n {\r\n name = ValidateName(name);\r\n\r\n var user = User.FindByIdentity(dbEvent.Sender);\r\n if (user is not null)\r\n {\r\n user.Name = name;\r\n User.UpdateByIdentity(dbEvent.Sender, user);\r\n }\r\n }\r\n```\r\n\r\nFor now, we'll just do a bare minimum of validation, rejecting the empty name. You could extend this in various ways, like:\r\n\r\n- Comparing against a blacklist for moderation purposes.\r\n- Unicode-normalizing names.\r\n- Rejecting names that contain non-printable characters, or removing characters or replacing them with a placeholder.\r\n- Rejecting or truncating long names.\r\n- Rejecting duplicate names.\r\n\r\nIn `server/Lib.cs`, add to the `Module` class:\r\n\r\n```C#\r\n /// Takes a name and checks if it's acceptable as a user's name.\r\n public static string ValidateName(string name)\r\n {\r\n if (string.IsNullOrEmpty(name))\r\n {\r\n throw new Exception(\"Names must not be empty\");\r\n }\r\n return name;\r\n }\r\n```\r\n\r\n## Send messages\r\n\r\nWe 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`.\r\n\r\nIn `server/Lib.cs`, add to the `Module` class:\r\n\r\n```C#\r\n [SpacetimeDB.Reducer]\r\n public static void SendMessage(DbEventArgs dbEvent, string text)\r\n {\r\n text = ValidateMessage(text);\r\n Log(text);\r\n new Message\r\n {\r\n Sender = dbEvent.Sender,\r\n Text = text,\r\n Sent = dbEvent.Time.ToUnixTimeMilliseconds(),\r\n }.Insert();\r\n }\r\n```\r\n\r\nWe'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.\r\n\r\nIn `server/Lib.cs`, add to the `Module` class:\r\n\r\n```C#\r\n /// Takes a message's text and checks if it's acceptable to send.\r\n public static string ValidateMessage(string text)\r\n {\r\n if (string.IsNullOrEmpty(text))\r\n {\r\n throw new ArgumentException(\"Messages must not be empty\");\r\n }\r\n return text;\r\n }\r\n```\r\n\r\nYou could extend the validation in `ValidateMessage` in similar ways to `ValidateName`, or add additional checks to `SendMessage`, like:\r\n\r\n- Rejecting messages from senders who haven't set their names.\r\n- Rate-limiting users so they can't send new messages too quickly.\r\n\r\n## Set users' online status\r\n\r\nIn 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.\r\n\r\nWe'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`.\r\n\r\nIn `server/Lib.cs`, add the definition of the connect reducer to the `Module` class:\r\n\r\n```C#\r\n [ModuleInitializer]\r\n public static void Init()\r\n {\r\n OnConnect += (dbEventArgs) =>\r\n {\r\n Log($\"Connect {dbEventArgs.Sender}\");\r\n var user = User.FindByIdentity(dbEventArgs.Sender);\r\n\r\n if (user is not null)\r\n {\r\n // If this is a returning user, i.e., we already have a `User` with this `Identity`,\r\n // set `Online: true`, but leave `Name` and `Identity` unchanged.\r\n user.Online = true;\r\n User.UpdateByIdentity(dbEventArgs.Sender, user);\r\n }\r\n else\r\n {\r\n // If this is a new user, create a `User` object for the `Identity`,\r\n // which is online, but hasn't set a name.\r\n new User\r\n {\r\n Name = null,\r\n Identity = dbEventArgs.Sender,\r\n Online = true,\r\n }.Insert();\r\n }\r\n };\r\n }\r\n```\r\n\r\nSimilarly, 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.\r\n\r\nAdd the following code after the `OnConnect` lambda:\r\n\r\n```C#\r\n OnDisconnect += (dbEventArgs) =>\r\n {\r\n var user = User.FindByIdentity(dbEventArgs.Sender);\r\n\r\n if (user is not null)\r\n {\r\n // This user should exist, so set `Online: false`.\r\n user.Online = false;\r\n User.UpdateByIdentity(dbEventArgs.Sender, user);\r\n }\r\n else\r\n {\r\n // User does not exist, log warning\r\n Log($\"Warning: No user found for disconnected client.\");\r\n }\r\n };\r\n```\r\n\r\n## Publish the module\r\n\r\nAnd 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 ``.\r\n\r\nFrom the `quickstart-chat` directory, run:\r\n\r\n```bash\r\nspacetime publish --project-path server \r\n```\r\n\r\n## Call Reducers\r\n\r\nYou can use the CLI (command line interface) to run reducers. The arguments to the reducer are passed in JSON format.\r\n\r\n```bash\r\nspacetime call send_message '[\"Hello, World!\"]'\r\n```\r\n\r\nOnce we've called our `send_message` reducer, we can check to make sure it ran by running the `logs` command.\r\n\r\n```bash\r\nspacetime logs \r\n```\r\n\r\nYou should now see the output that your module printed in the database.\r\n\r\n```bash\r\ninfo: Hello, World!\r\n```\r\n\r\n## SQL Queries\r\n\r\nSpacetimeDB 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.\r\n\r\n```bash\r\nspacetime sql \"SELECT * FROM Message\"\r\n```\r\n\r\n```bash\r\n text\r\n---------\r\n \"Hello, World!\"\r\n```\r\n\r\n## What's next?\r\n\r\nYou'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).\r\n\r\nIf 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).\r\n", - "hasPages": false, - "editUrl": "index.md", - "jumpLinks": [ - { - "title": "C# Module Quickstart", - "route": "c-module-quickstart", - "depth": 1 - }, - { - "title": "Install SpacetimeDB", - "route": "install-spacetimedb", - "depth": 2 - }, - { - "title": "Install .NET", - "route": "install-net", - "depth": 2 - }, - { - "title": "Project structure", - "route": "project-structure", - "depth": 2 - }, - { - "title": "Declare imports", - "route": "declare-imports", - "depth": 2 - }, - { - "title": "Define tables", - "route": "define-tables", - "depth": 2 - }, - { - "title": "Set users' names", - "route": "set-users-names", - "depth": 2 - }, - { - "title": "Send messages", - "route": "send-messages", - "depth": 2 - }, - { - "title": "Set users' online status", - "route": "set-users-online-status", - "depth": 2 - }, - { - "title": "Publish the module", - "route": "publish-the-module", - "depth": 2 - }, - { - "title": "Call Reducers", - "route": "call-reducers", - "depth": 2 - }, - { - "title": "SQL Queries", - "route": "sql-queries", - "depth": 2 - }, - { - "title": "What's next?", - "route": "what-s-next-", - "depth": 2 - } - ], - "pages": [] - }, - { - "title": "SpacetimeDB C# Modules", - "identifier": "ModuleReference", - "indexIdentifier": "ModuleReference", - "hasPages": false, - "content": "# SpacetimeDB C# Modules\r\n\r\nYou can use the [C# SpacetimeDB library](https://github.com/clockworklabs/SpacetimeDBLibCSharp) to write modules in C# which interact with the SpacetimeDB database.\r\n\r\nIt 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.\r\n\r\n## Example\r\n\r\nLet's start with a heavily commented version of the default example from the landing page:\r\n\r\n```csharp\r\n// These imports bring into the scope common APIs you'll need to expose items from your module and to interact with the database runtime.\r\nusing SpacetimeDB.Module;\r\nusing static SpacetimeDB.Runtime;\r\n\r\n// Roslyn generators are statically generating extra code as-if they were part of the source tree, so,\r\n// in order to inject new methods, types they operate on as well as their parents have to be marked as `partial`.\r\n//\r\n// We start with the top-level `Module` class for the module itself.\r\nstatic partial class Module\r\n{\r\n // `[SpacetimeDB.Table]` registers a struct or a class as a SpacetimeDB table.\r\n //\r\n // It generates methods to insert, filter, update, and delete rows of the given type in the table.\r\n [SpacetimeDB.Table]\r\n public partial struct Person\r\n {\r\n // `[SpacetimeDB.Column]` allows to specify column attributes / constraints such as\r\n // \"this field should be unique\" or \"this field should get automatically assigned auto-incremented value\".\r\n [SpacetimeDB.Column(ColumnAttrs.Unique | ColumnAttrs.AutoInc)]\r\n public int Id;\r\n public string Name;\r\n public int Age;\r\n }\r\n\r\n // `[SpacetimeDB.Reducer]` marks a static method as a SpacetimeDB reducer.\r\n //\r\n // Reducers are functions that can be invoked from the database runtime.\r\n // They can't return values, but can throw errors that will be caught and reported back to the runtime.\r\n [SpacetimeDB.Reducer]\r\n public static void Add(string name, int age)\r\n {\r\n // We can skip (or explicitly set to zero) auto-incremented fields when creating new rows.\r\n var person = new Person { Name = name, Age = age };\r\n // `Insert()` method is auto-generated and will insert the given row into the table.\r\n person.Insert();\r\n // After insertion, the auto-incremented fields will be populated with their actual values.\r\n //\r\n // `Log()` function is provided by the runtime and will print the message to the database log.\r\n // It should be used instead of `Console.WriteLine()` or similar functions.\r\n Log($\"Inserted {person.Name} under #{person.Id}\");\r\n }\r\n\r\n [SpacetimeDB.Reducer]\r\n public static void SayHello()\r\n {\r\n // Each table type gets a static Iter() method that can be used to iterate over the entire table.\r\n foreach (var person in Person.Iter())\r\n {\r\n Log($\"Hello, {person.Name}!\");\r\n }\r\n Log(\"Hello, World!\");\r\n }\r\n}\r\n```\r\n\r\n## API reference\r\n\r\nNow we'll get into details on all the APIs SpacetimeDB provides for writing modules in C#.\r\n\r\n### Logging\r\n\r\nFirst of all, logging as we're likely going to use it a lot for debugging and reporting errors.\r\n\r\n`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.\r\n\r\nSupported log levels are provided by the `LogLevel` enum:\r\n\r\n```csharp\r\npublic enum LogLevel\r\n{\r\n Error,\r\n Warn,\r\n Info,\r\n Debug,\r\n Trace,\r\n Panic\r\n}\r\n```\r\n\r\nIf omitted, the log level will default to `Info`, so these two forms are equivalent:\r\n\r\n```csharp\r\nLog(\"Hello, World!\");\r\nLog(\"Hello, World!\", LogLevel.Info);\r\n```\r\n\r\n### Supported types\r\n\r\n#### Built-in types\r\n\r\nThe following types are supported out of the box and can be stored in the database tables directly or as part of more complex types:\r\n\r\n- `bool`\r\n- `byte`, `sbyte`\r\n- `short`, `ushort`\r\n- `int`, `uint`\r\n- `long`, `ulong`\r\n- `float`, `double`\r\n- `string`\r\n- [`Int128`](https://learn.microsoft.com/en-us/dotnet/api/system.int128), [`UInt128`](https://learn.microsoft.com/en-us/dotnet/api/system.uint128)\r\n- `T[]` - arrays of supported values.\r\n- [`List`](https://learn.microsoft.com/en-us/dotnet/api/system.collections.generic.list-1)\r\n- [`Dictionary`](https://learn.microsoft.com/en-us/dotnet/api/system.collections.generic.dictionary-2)\r\n\r\nAnd a couple of special custom types:\r\n\r\n- `SpacetimeDB.SATS.Unit` - semantically equivalent to an empty struct, sometimes useful in generic contexts where C# doesn't permit `void`.\r\n- `Identity` (`SpacetimeDB.Runtime.Identity`) - a unique identifier for each connected client; internally a byte blob but can be printed, hashed and compared for equality.\r\n\r\n#### Custom types\r\n\r\n`[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.\r\n\r\nAny `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.\r\n\r\n```csharp\r\n[SpacetimeDB.Type]\r\npublic partial struct Point\r\n{\r\n public int x;\r\n public int y;\r\n}\r\n```\r\n\r\n`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.\r\n\r\n```csharp\r\n[SpacetimeDB.Type]\r\npublic enum Color\r\n{\r\n Red,\r\n Green,\r\n Blue,\r\n}\r\n```\r\n\r\n#### Tagged enums\r\n\r\nSpacetimeDB has support for tagged enums which can be found in languages like Rust, but not C#.\r\n\r\nTo 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.\r\n\r\nIt 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.\r\n\r\n```csharp\r\n// Example declaration:\r\n[SpacetimeDB.Type]\r\npartial struct Option : SpacetimeDB.TaggedEnum<(T Some, Unit None)> { }\r\n\r\n// Usage:\r\nvar option = new Option { Some = 42 };\r\nif (option.IsSome)\r\n{\r\n Log($\"Value: {option.Some}\");\r\n}\r\n```\r\n\r\n### Tables\r\n\r\n`[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.\r\n\r\nIt implies `[SpacetimeDB.Type]`, so you must not specify both attributes on the same type.\r\n\r\n```csharp\r\n[SpacetimeDB.Table]\r\npublic partial struct Person\r\n{\r\n [SpacetimeDB.Column(ColumnAttrs.Unique | ColumnAttrs.AutoInc)]\r\n public int Id;\r\n public string Name;\r\n public int Age;\r\n}\r\n```\r\n\r\nThe example above will generate the following extra methods:\r\n\r\n```csharp\r\npublic partial struct Person\r\n{\r\n // Inserts current instance as a new row into the table.\r\n public void Insert();\r\n\r\n // Returns an iterator over all rows in the table, e.g.:\r\n // `for (var person in Person.Iter()) { ... }`\r\n public static IEnumerable Iter();\r\n\r\n // Returns an iterator over all rows in the table that match the given filter, e.g.:\r\n // `for (var person in Person.Query(p => p.Age >= 18)) { ... }`\r\n public static IEnumerable Query(Expression> filter);\r\n\r\n // Generated for each column:\r\n\r\n // Returns an iterator over all rows in the table that have the given value in the `Name` column.\r\n public static IEnumerable FilterByName(string name);\r\n public static IEnumerable FilterByAge(int age);\r\n\r\n // Generated for each unique column:\r\n\r\n // Finds a row in the table with the given value in the `Id` column and returns it, or `null` if no such row exists.\r\n public static Person? FindById(int id);\r\n // 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.\r\n public static bool DeleteById(int id);\r\n // 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.\r\n public static bool UpdateById(int oldId, Person newValue);\r\n}\r\n```\r\n\r\n#### Column attributes\r\n\r\nAttribute `[SpacetimeDB.Column]` can be used on any field of a `SpacetimeDB.Table`-marked `struct` or `class` to customize column attributes as seen above.\r\n\r\nThe supported column attributes are:\r\n\r\n- `ColumnAttrs.AutoInc` - this column should be auto-incremented.\r\n- `ColumnAttrs.Unique` - this column should be unique.\r\n- `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.\r\n\r\nThese attributes are bitflags and can be combined together, but you can also use some predefined shortcut aliases:\r\n\r\n- `ColumnAttrs.Identity` - same as `ColumnAttrs.Unique | ColumnAttrs.AutoInc`.\r\n- `ColumnAttrs.PrimaryKeyAuto` - same as `ColumnAttrs.PrimaryKey | ColumnAttrs.AutoInc`.\r\n\r\n### Reducers\r\n\r\nAttribute `[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.\r\n\r\n```csharp\r\n[SpacetimeDB.Reducer]\r\npublic static void Add(string name, int age)\r\n{\r\n var person = new Person { Name = name, Age = age };\r\n person.Insert();\r\n Log($\"Inserted {person.Name} under #{person.Id}\");\r\n}\r\n```\r\n\r\nIf 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:\r\n\r\n```csharp\r\n[SpacetimeDB.Reducer]\r\npublic static void PrintInfo(DbEventArgs e)\r\n{\r\n Log($\"Sender: {e.Sender}\");\r\n Log($\"Time: {e.Time}\");\r\n}\r\n```\r\n\r\n`[SpacetimeDB.Reducer]` also generates a function to schedule the given reducer in the future.\r\n\r\nSince 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`.\r\n\r\n```csharp\r\n// Example reducer:\r\n[SpacetimeDB.Reducer]\r\npublic static void Add(string name, int age) { ... }\r\n\r\n// Auto-generated by the codegen:\r\npublic static void ScheduleAdd(DateTimeOffset time, string name, int age) { ... }\r\n\r\n// Usage from another reducer:\r\n[SpacetimeDB.Reducer]\r\npublic static void AddIn5Minutes(DbEventArgs e, string name, int age)\r\n{\r\n // Note that we're using `e.Time` instead of `DateTimeOffset.Now` which is not allowed in modules.\r\n var scheduleToken = ScheduleAdd(e.Time.AddMinutes(5), name, age);\r\n\r\n // We can cancel the scheduled reducer by calling `Cancel()` on the returned token.\r\n scheduleToken.Cancel();\r\n}\r\n```\r\n\r\n#### Special reducers\r\n\r\nThese 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:\r\n\r\n- `ReducerKind.Init` - this reducer will be invoked when the module is first published.\r\n- `ReducerKind.Update` - this reducer will be invoked when the module is updated.\r\n\r\nExample:\r\n\r\n```csharp\r\n[SpacetimeDB.Reducer(ReducerKind.Init)]\r\npublic static void Init()\r\n{\r\n Log(\"...and we're live!\");\r\n}\r\n```\r\n\r\n### Connection events\r\n\r\n`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.\r\n\r\n```csharp\r\n[SpacetimeDB.Reducer(ReducerKind.Init)]\r\npublic static void Init()\r\n{\r\n OnConnect += (e) => Log($\"Client {e.Sender} connected!\");\r\n OnDisconnect += (e) => Log($\"Client {e.Sender} disconnected!\");\r\n}\r\n```\r\n", - "editUrl": "ModuleReference.md", - "jumpLinks": [ - { - "title": "SpacetimeDB C# Modules", - "route": "spacetimedb-c-modules", - "depth": 1 - }, - { - "title": "Example", - "route": "example", - "depth": 2 - }, - { - "title": "API reference", - "route": "api-reference", - "depth": 2 - }, - { - "title": "Logging", - "route": "logging", - "depth": 3 - }, - { - "title": "Supported types", - "route": "supported-types", - "depth": 3 - }, - { - "title": "Built-in types", - "route": "built-in-types", - "depth": 4 - }, - { - "title": "Custom types", - "route": "custom-types", - "depth": 4 - }, - { - "title": "Tagged enums", - "route": "tagged-enums", - "depth": 4 - }, - { - "title": "Tables", - "route": "tables", - "depth": 3 - }, - { - "title": "Column attributes", - "route": "column-attributes", - "depth": 4 - }, - { - "title": "Reducers", - "route": "reducers", - "depth": 3 - }, - { - "title": "Special reducers", - "route": "special-reducers", - "depth": 4 - }, - { - "title": "Connection events", - "route": "connection-events", - "depth": 3 - } - ], - "pages": [] - } - ] - }, - { - "title": "Server Module Overview", - "identifier": "index", - "indexIdentifier": "index", - "content": "# Server Module Overview\r\n\r\nServer modules are the core of a SpacetimeDB application. They define the structure of the database and the server-side logic that processes and handles client requests. These functions are called reducers and are transactional, meaning they ensure data consistency and integrity. Reducers can perform operations such as inserting, updating, and deleting data in the database.\r\n\r\nIn the following sections, we'll cover the basics of server modules and how to create and deploy them.\r\n\r\n## Supported Languages\r\n\r\n### Rust\r\n\r\nAs 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.\r\n\r\n- [Rust Module Reference](/docs/server-languages/rust/rust-module-reference)\r\n- [Rust Module Quickstart Guide](/docs/server-languages/rust/rust-module-quickstart-guide)\r\n\r\n### C#\r\n\r\nWe 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.\r\n\r\n- [C# Module Reference](/docs/server-languages/csharp/csharp-module-reference)\r\n- [C# Module Quickstart Guide](/docs/server-languages/csharp/csharp-module-quickstart-guide)\r\n\r\n### Coming Soon\r\n\r\nWe have plans to support additional languages in the future.\r\n\r\n- Python\r\n- Typescript\r\n- C++\r\n- Lua\r\n", - "hasPages": false, - "editUrl": "index.md", - "jumpLinks": [ - { - "title": "Server Module Overview", - "route": "server-module-overview", - "depth": 1 - }, - { - "title": "Supported Languages", - "route": "supported-languages", - "depth": 2 - }, - { - "title": "Rust", - "route": "rust", - "depth": 3 - }, - { - "title": "C#", - "route": "c-", - "depth": 3 - }, - { - "title": "Coming Soon", - "route": "coming-soon", - "depth": 3 - } - ], - "pages": [] - }, - { - "title": "Rust", - "identifier": "Rust", - "indexIdentifier": "index", - "comingSoon": false, - "hasPages": true, - "editUrl": "Rust/index.md", - "jumpLinks": [], - "pages": [ - { - "title": "Rust Module Quickstart", - "identifier": "index", - "indexIdentifier": "index", - "content": "# Rust Module Quickstart\r\n\r\nIn this tutorial, we'll implement a simple chat server as a SpacetimeDB module.\r\n\r\nA 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.\r\n\r\nEach SpacetimeDB module defines a set of tables and a set of reducers.\r\n\r\nEach table is defined as a Rust `struct` annotated with `#[spacetimedb(table)]`, where an instance represents a row, and each field represents a column.\r\n\r\nA 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.\r\n\r\n## Install SpacetimeDB\r\n\r\nIf 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.\r\n\r\n## Install Rust\r\n\r\nNext we need to [install Rust](https://www.rust-lang.org/tools/install) so that we can create our database module.\r\n\r\nOn MacOS and Linux run this command to install the Rust compiler:\r\n\r\n```bash\r\ncurl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh\r\n```\r\n\r\nIf you're on Windows, go [here](https://learn.microsoft.com/en-us/windows/dev-environment/rust/setup).\r\n\r\n## Project structure\r\n\r\nCreate and enter a directory `quickstart-chat`:\r\n\r\n```bash\r\nmkdir quickstart-chat\r\ncd quickstart-chat\r\n```\r\n\r\nNow create `server`, our module, which runs in the database:\r\n\r\n```bash\r\nspacetime init --lang rust server\r\n```\r\n\r\n## Declare imports\r\n\r\n`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.\r\n\r\nTo the top of `server/src/lib.rs`, add some imports we'll be using:\r\n\r\n```rust\r\nuse spacetimedb::{spacetimedb, ReducerContext, Identity, Timestamp};\r\n```\r\n\r\nFrom `spacetimedb`, we import:\r\n\r\n- `spacetimedb`, an attribute macro we'll use to define tables and reducers.\r\n- `ReducerContext`, a special argument passed to each reducer.\r\n- `Identity`, a unique identifier for each connected client.\r\n- `Timestamp`, a point in time. Specifically, an unsigned 64-bit count of milliseconds since the UNIX epoch.\r\n\r\n## Define tables\r\n\r\nTo 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.\r\n\r\nFor 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.\r\n\r\nTo `server/src/lib.rs`, add the definition of the table `User`:\r\n\r\n```rust\r\n#[spacetimedb(table)]\r\npub struct User {\r\n #[primarykey]\r\n identity: Identity,\r\n name: Option,\r\n online: bool,\r\n}\r\n```\r\n\r\nFor 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.\r\n\r\nTo `server/src/lib.rs`, add the definition of the table `Message`:\r\n\r\n```rust\r\n#[spacetimedb(table)]\r\npub struct Message {\r\n sender: Identity,\r\n sent: Timestamp,\r\n text: String,\r\n}\r\n```\r\n\r\n## Set users' names\r\n\r\nWe 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.\r\n\r\nEach 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`.\r\n\r\nIt'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.\r\n\r\nTo `server/src/lib.rs`, add:\r\n\r\n```rust\r\n#[spacetimedb(reducer)]\r\n/// Clientss invoke this reducer to set their user names.\r\npub fn set_name(ctx: ReducerContext, name: String) -> Result<(), String> {\r\n let name = validate_name(name)?;\r\n if let Some(user) = User::filter_by_identity(&ctx.sender) {\r\n User::update_by_identity(&ctx.sender, User { name: Some(name), ..user });\r\n Ok(())\r\n } else {\r\n Err(\"Cannot set name for unknown user\".to_string())\r\n }\r\n}\r\n```\r\n\r\nFor now, we'll just do a bare minimum of validation, rejecting the empty name. You could extend this in various ways, like:\r\n\r\n- Comparing against a blacklist for moderation purposes.\r\n- Unicode-normalizing names.\r\n- Rejecting names that contain non-printable characters, or removing characters or replacing them with a placeholder.\r\n- Rejecting or truncating long names.\r\n- Rejecting duplicate names.\r\n\r\nTo `server/src/lib.rs`, add:\r\n\r\n```rust\r\n/// Takes a name and checks if it's acceptable as a user's name.\r\nfn validate_name(name: String) -> Result {\r\n if name.is_empty() {\r\n Err(\"Names must not be empty\".to_string())\r\n } else {\r\n Ok(name)\r\n }\r\n}\r\n```\r\n\r\n## Send messages\r\n\r\nWe 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`.\r\n\r\nTo `server/src/lib.rs`, add:\r\n\r\n```rust\r\n#[spacetimedb(reducer)]\r\n/// Clients invoke this reducer to send messages.\r\npub fn send_message(ctx: ReducerContext, text: String) -> Result<(), String> {\r\n let text = validate_message(text)?;\r\n log::info!(\"{}\", text);\r\n Message::insert(Message {\r\n sender: ctx.sender,\r\n text,\r\n sent: ctx.timestamp,\r\n });\r\n Ok(())\r\n}\r\n```\r\n\r\nWe'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.\r\n\r\nTo `server/src/lib.rs`, add:\r\n\r\n```rust\r\n/// Takes a message's text and checks if it's acceptable to send.\r\nfn validate_message(text: String) -> Result {\r\n if text.is_empty() {\r\n Err(\"Messages must not be empty\".to_string())\r\n } else {\r\n Ok(text)\r\n }\r\n}\r\n```\r\n\r\nYou could extend the validation in `validate_message` in similar ways to `validate_name`, or add additional checks to `send_message`, like:\r\n\r\n- Rejecting messages from senders who haven't set their names.\r\n- Rate-limiting users so they can't send new messages too quickly.\r\n\r\n## Set users' online status\r\n\r\nWhenever 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.\r\n\r\nWe'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`.\r\n\r\nTo `server/src/lib.rs`, add the definition of the connect reducer:\r\n\r\n```rust\r\n#[spacetimedb(connect)]\r\n// Called when a client connects to the SpacetimeDB\r\npub fn identity_connected(ctx: ReducerContext) {\r\n if let Some(user) = User::filter_by_identity(&ctx.sender) {\r\n // If this is a returning user, i.e. we already have a `User` with this `Identity`,\r\n // set `online: true`, but leave `name` and `identity` unchanged.\r\n User::update_by_identity(&ctx.sender, User { online: true, ..user });\r\n } else {\r\n // If this is a new user, create a `User` row for the `Identity`,\r\n // which is online, but hasn't set a name.\r\n User::insert(User {\r\n name: None,\r\n identity: ctx.sender,\r\n online: true,\r\n }).unwrap();\r\n }\r\n}\r\n```\r\n\r\nSimilarly, 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.\r\n\r\n```rust\r\n#[spacetimedb(disconnect)]\r\n// Called when a client disconnects from SpacetimeDB\r\npub fn identity_disconnected(ctx: ReducerContext) {\r\n if let Some(user) = User::filter_by_identity(&ctx.sender) {\r\n User::update_by_identity(&ctx.sender, User { online: false, ..user });\r\n } else {\r\n // This branch should be unreachable,\r\n // as it doesn't make sense for a client to disconnect without connecting first.\r\n log::warn!(\"Disconnect event for unknown user with identity {:?}\", ctx.sender);\r\n }\r\n}\r\n```\r\n\r\n## Publish the module\r\n\r\nAnd 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 ``.\r\n\r\nFrom the `quickstart-chat` directory, run:\r\n\r\n```bash\r\nspacetime publish --project-path server \r\n```\r\n\r\n## Call Reducers\r\n\r\nYou can use the CLI (command line interface) to run reducers. The arguments to the reducer are passed in JSON format.\r\n\r\n```bash\r\nspacetime call send_message '[\"Hello, World!\"]'\r\n```\r\n\r\nOnce we've called our `send_message` reducer, we can check to make sure it ran by running the `logs` command.\r\n\r\n```bash\r\nspacetime logs \r\n```\r\n\r\nYou should now see the output that your module printed in the database.\r\n\r\n```bash\r\ninfo: Hello, World!\r\n```\r\n\r\n## SQL Queries\r\n\r\nSpacetimeDB 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.\r\n\r\n```bash\r\nspacetime sql \"SELECT * FROM Message\"\r\n```\r\n\r\n```bash\r\n text\r\n---------\r\n \"Hello, World!\"\r\n```\r\n\r\n## What's next?\r\n\r\nYou can find the full code for this module [in the SpacetimeDB module examples](https://github.com/clockworklabs/SpacetimeDB/tree/master/modules/quickstart-chat).\r\n\r\nYou'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).\r\n\r\nIf 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).\r\n", - "hasPages": false, - "editUrl": "index.md", - "jumpLinks": [ - { - "title": "Rust Module Quickstart", - "route": "rust-module-quickstart", - "depth": 1 - }, - { - "title": "Install SpacetimeDB", - "route": "install-spacetimedb", - "depth": 2 - }, - { - "title": "Install Rust", - "route": "install-rust", - "depth": 2 - }, - { - "title": "Project structure", - "route": "project-structure", - "depth": 2 - }, - { - "title": "Declare imports", - "route": "declare-imports", - "depth": 2 - }, - { - "title": "Define tables", - "route": "define-tables", - "depth": 2 - }, - { - "title": "Set users' names", - "route": "set-users-names", - "depth": 2 - }, - { - "title": "Send messages", - "route": "send-messages", - "depth": 2 - }, - { - "title": "Set users' online status", - "route": "set-users-online-status", - "depth": 2 - }, - { - "title": "Publish the module", - "route": "publish-the-module", - "depth": 2 - }, - { - "title": "Call Reducers", - "route": "call-reducers", - "depth": 2 - }, - { - "title": "SQL Queries", - "route": "sql-queries", - "depth": 2 - }, - { - "title": "What's next?", - "route": "what-s-next-", - "depth": 2 - } - ], - "pages": [] - }, - { - "title": "SpacetimeDB Rust Modules", - "identifier": "ModuleReference", - "indexIdentifier": "ModuleReference", - "hasPages": false, - "content": "# SpacetimeDB Rust Modules\r\n\r\nRust clients of SpacetimeDB use the [Rust SpacetimeDB module library][module library] to write modules which interact with the SpacetimeDB database.\r\n\r\nFirst, the `spacetimedb` library provides a number of macros for creating tables and Rust `struct`s corresponding to rows in those tables.\r\n\r\nThen the client API allows interacting with the database inside special functions called reducers.\r\n\r\nThis 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.\r\n\r\nDerive 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.\r\n\r\n```rust\r\n#[derive(Debug)]\r\nstruct Location {\r\n x: u32,\r\n y: u32,\r\n}\r\n```\r\n\r\n## SpacetimeDB Macro basics\r\n\r\nLet'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.\r\n\r\n```rust\r\n// In this small example, we have two rust imports:\r\n// |spacetimedb::spacetimedb| is the most important attribute we'll be using.\r\n// |spacetimedb::println| is like regular old |println|, but outputting to the module's logs.\r\nuse spacetimedb::{spacetimedb, println};\r\n\r\n// This macro lets us interact with a SpacetimeDB table of Person rows.\r\n// We can insert and delete into, and query, this table by the collection\r\n// of functions generated by the macro.\r\n#[spacetimedb(table)]\r\npub struct Person {\r\n name: String,\r\n}\r\n\r\n// This is the other key macro we will be using. A reducer is a\r\n// stored procedure that lives in the database, and which can\r\n// be invoked remotely.\r\n#[spacetimedb(reducer)]\r\npub fn add(name: String) {\r\n // |Person| is a totally ordinary Rust struct. We can construct\r\n // one from the given name as we typically would.\r\n let person = Person { name };\r\n\r\n // Here's our first generated function! Given a |Person| object,\r\n // we can insert it into the table:\r\n Person::insert(person)\r\n}\r\n\r\n// Here's another reducer. Notice that this one doesn't take any arguments, while\r\n// |add| did take one. Reducers can take any number of arguments, as long as\r\n// SpacetimeDB knows about all their types. Reducers also have to be top level\r\n// functions, not methods.\r\n#[spacetimedb(reducer)]\r\npub fn say_hello() {\r\n // Here's the next of our generated functions: |iter()|. This\r\n // iterates over all the columns in the |Person| table in SpacetimeDB.\r\n for person in Person::iter() {\r\n // Reducers run in a very constrained and sandboxed environment,\r\n // and in particular, can't do most I/O from the Rust standard library.\r\n // We provide an alternative |spacetimedb::println| which is just like\r\n // the std version, excepted it is redirected out to the module's logs.\r\n println!(\"Hello, {}!\", person.name);\r\n }\r\n println!(\"Hello, World!\");\r\n}\r\n\r\n// Reducers can't return values, but can return errors. To do so,\r\n// the reducer must have a return type of `Result<(), T>`, for any `T` that\r\n// implements `Debug`. Such errors returned from reducers will be formatted and\r\n// printed out to logs.\r\n#[spacetimedb(reducer)]\r\npub fn add_person(name: String) -> Result<(), String> {\r\n if name.is_empty() {\r\n return Err(\"Name cannot be empty\");\r\n }\r\n\r\n Person::insert(Person { name })\r\n}\r\n```\r\n\r\n## Macro API\r\n\r\nNow we'll get into details on all the macro APIs SpacetimeDB provides, starting with all the variants of the `spacetimedb` attribute.\r\n\r\n### Defining tables\r\n\r\n`#[spacetimedb(table)]` takes no further arguments, and is applied to a Rust struct with named fields:\r\n\r\n```rust\r\n#[spacetimedb(table)]\r\nstruct Table {\r\n field1: String,\r\n field2: u32,\r\n}\r\n```\r\n\r\nThis 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.\r\n\r\nThe 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.\r\n\r\nThis is automatically defined for built in numeric types:\r\n\r\n- `bool`\r\n- `u8`, `u16`, `u32`, `u64`, `u128`\r\n- `i8`, `i16`, `i32`, `i64`, `i128`\r\n- `f32`, `f64`\r\n\r\nAnd common data structures:\r\n\r\n- `String` and `&str`, utf-8 string data\r\n- `()`, the unit type\r\n- `Option where T: SpacetimeType`\r\n- `Vec where T: SpacetimeType`\r\n\r\nAll `#[spacetimedb(table)]` types are `SpacetimeType`s, and accordingly, all of their fields have to be.\r\n\r\n```rust\r\n#[spacetimedb(table)]\r\nstruct AnotherTable {\r\n // Fine, some builtin types.\r\n id: u64,\r\n name: Option,\r\n\r\n // Fine, another table type.\r\n table: Table,\r\n\r\n // Fine, another type we explicitly make serializable.\r\n serial: Serial,\r\n}\r\n```\r\n\r\nIf 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.\r\n\r\nWe can derive `SpacetimeType` on `struct`s and `enum`s with members that are themselves `SpacetimeType`s.\r\n\r\n```rust\r\n#[derive(SpacetimeType)]\r\nenum Serial {\r\n Builtin(f64),\r\n Compound {\r\n s: String,\r\n bs: Vec,\r\n }\r\n}\r\n```\r\n\r\nOnce 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.\r\n\r\n```rust\r\n#[spacetimedb(table)]\r\nstruct Person {\r\n #[unique]\r\n id: u64,\r\n\r\n name: String,\r\n address: String,\r\n}\r\n```\r\n\r\n### Defining reducers\r\n\r\n`#[spacetimedb(reducer)]` optionally takes a single argument, which is a frequency at which the reducer will be automatically called by the database.\r\n\r\n`#[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>`.\r\n\r\n```rust\r\n#[spacetimedb(reducer)]\r\nfn give_player_item(player_id: u64, item_id: u64) -> Result<(), GameErr> {\r\n // Notice how the exact name of the filter function derives from\r\n // the name of the field of the struct.\r\n let mut item = Item::filter_by_item_id(id).ok_or(GameErr::InvalidId)?;\r\n item.owner = Some(player_id);\r\n Item::update_by_id(id, item);\r\n Ok(())\r\n}\r\n\r\nstruct Item {\r\n #[unique]\r\n item_id: u64,\r\n\r\n owner: Option,\r\n}\r\n```\r\n\r\nNote that reducers can call non-reducer functions, including standard library functions.\r\n\r\nReducers 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.\r\n\r\nBoth of these examples are invoked every second.\r\n\r\n```rust\r\n#[spacetimedb(reducer, repeat = 1s)]\r\nfn every_second() {}\r\n\r\n#[spacetimedb(reducer, repeat = 1000ms)]\r\nfn every_thousand_milliseconds() {}\r\n```\r\n\r\nFinally, reducers can also receive a ReducerContext object, or the Timestamp at which they are invoked, just by taking parameters of those types first.\r\n\r\n```rust\r\n#[spacetimedb(reducer, repeat = 1s)]\r\nfn tick_timestamp(time: Timestamp) {\r\n println!(\"tick at {time}\");\r\n}\r\n\r\n#[spacetimedb(reducer, repeat = 500ms)]\r\nfn tick_ctx(ctx: ReducerContext) {\r\n println!(\"tick at {}\", ctx.timestamp)\r\n}\r\n```\r\n\r\nNote 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.\r\n\r\nThere 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.\r\n\r\n#[SpacetimeType]\r\n\r\n#[sats]\r\n\r\n## Client API\r\n\r\nBesides 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.\r\n\r\n### `println!` and friends\r\n\r\nBecause 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.\r\n\r\nSpacetimeDB 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.\r\n\r\nLogs for a module can be viewed with the `spacetime logs` command from the CLI.\r\n\r\n```rust\r\nuse spacetimedb::{\r\n println,\r\n print,\r\n eprintln,\r\n eprint,\r\n dbg,\r\n};\r\n\r\n#[spacetimedb(reducer)]\r\nfn output(i: i32) {\r\n // These will be logged at log::Level::Info.\r\n println!(\"an int with a trailing newline: {i}\");\r\n print!(\"some more text...\\n\");\r\n\r\n // These log at log::Level::Error.\r\n eprint!(\"Oops...\");\r\n eprintln!(\", we hit an error\");\r\n\r\n // Just like std::dbg!, this prints its argument and returns the value,\r\n // as a drop-in way to print expressions. So this will print out |i|\r\n // before passing the value of |i| along to the calling function.\r\n //\r\n // The output is logged log::Level::Debug.\r\n OutputtedNumbers::insert(dbg!(i));\r\n}\r\n```\r\n\r\n### Generated functions on a SpacetimeDB table\r\n\r\nWe'll work off these structs to see what functions SpacetimeDB generates:\r\n\r\nThis table has a plain old column.\r\n\r\n```rust\r\n#[spacetimedb(table)]\r\nstruct Ordinary {\r\n ordinary_field: u64,\r\n}\r\n```\r\n\r\nThis 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.\r\n\r\n```rust\r\n#[spacetimedb(table)]\r\nstruct Unique {\r\n // A unique column:\r\n #[unique]\r\n unique_field: u64,\r\n}\r\n```\r\n\r\nThis 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.\r\n\r\nOnly integer types can be `#[unique]`: `u8`, `u16`, `u32`, `u64`, `u128`, `i8`, `i16`, `i32`, `i64` and `i128`.\r\n\r\n```rust\r\n#[spacetimedb(table)]\r\nstruct Autoinc {\r\n #[autoinc]\r\n autoinc_field: u64,\r\n}\r\n```\r\n\r\nThese attributes can be combined, to create an automatically assigned ID usable for filtering.\r\n\r\n```rust\r\n#[spacetimedb(table)]\r\nstruct Identity {\r\n #[autoinc]\r\n #[unique]\r\n id_field: u64,\r\n}\r\n```\r\n\r\n### Insertion\r\n\r\nWe'll talk about insertion first, as there a couple of special semantics to know about.\r\n\r\nWhen we define |Ordinary| as a spacetimedb table, we get the ability to insert into it with the generated `Ordinary::insert` method.\r\n\r\nInserting takes a single argument, the row to insert. When there are no unique fields in the row, the return value is the inserted row.\r\n\r\n```rust\r\n#[spacetimedb(reducer)]\r\nfn insert_ordinary(value: u64) {\r\n let ordinary = Ordinary { ordinary_field: value };\r\n let result = Ordinary::insert(ordinary);\r\n assert_eq!(ordinary.ordinary_field, result.ordinary_field);\r\n}\r\n```\r\n\r\nWhen there is a unique column constraint on the table, insertion can fail if a uniqueness constraint is violated.\r\n\r\nIf we insert two rows which have the same value of a unique column, the second will fail.\r\n\r\n```rust\r\n#[spacetimedb(reducer)]\r\nfn insert_unique(value: u64) {\r\n let result = Ordinary::insert(Unique { unique_field: value });\r\n assert!(result.is_ok());\r\n\r\n let result = Ordinary::insert(Unique { unique_field: value });\r\n assert!(result.is_err());\r\n}\r\n```\r\n\r\nWhen inserting a table with an `#[autoinc]` column, the database will automatically overwrite whatever we give it with an atomically increasing value.\r\n\r\nThe returned row has the `autoinc` column set to the value that was actually written into the database.\r\n\r\n```rust\r\n#[spacetimedb(reducer)]\r\nfn insert_autoinc() {\r\n for i in 1..=10 {\r\n // These will have values of 1, 2, ..., 10\r\n // at rest in the database, regardless of\r\n // what value is actually present in the\r\n // insert call.\r\n let actual = Autoinc::insert(Autoinc { autoinc_field: 23 })\r\n assert_eq!(actual.autoinc_field, i);\r\n }\r\n}\r\n\r\n#[spacetimedb(reducer)]\r\nfn insert_id() {\r\n for _ in 0..10 {\r\n // These also will have values of 1, 2, ..., 10.\r\n // There's no collision and silent failure to insert,\r\n // because the value of the field is ignored and overwritten\r\n // with the automatically incremented value.\r\n Identity::insert(Identity { autoinc_field: 23 })\r\n }\r\n}\r\n```\r\n\r\n### Iterating\r\n\r\nGiven a table, we can iterate over all the rows in it.\r\n\r\n```rust\r\n#[spacetimedb(table)]\r\nstruct Person {\r\n #[unique]\r\n id: u64,\r\n\r\n age: u32,\r\n name: String,\r\n address: String,\r\n}\r\n```\r\n\r\n// Every table structure an iter function, like:\r\n\r\n```rust\r\nfn MyTable::iter() -> TableIter\r\n```\r\n\r\n`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.\r\n\r\n```\r\n#[spacetimedb(reducer)]\r\nfn iteration() {\r\n let mut addresses = HashSet::new();\r\n\r\n for person in Person::iter() {\r\n addresses.insert(person.address);\r\n }\r\n\r\n for address in addresses.iter() {\r\n println!(\"{address}\");\r\n }\r\n}\r\n```\r\n\r\n### Filtering\r\n\r\nOften, we don't need to look at the entire table, and instead are looking for rows with specific values in certain columns.\r\n\r\nOur `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.\r\n\r\nThe name of the filter method just corresponds to the column name.\r\n\r\n```rust\r\n#[spacetimedb(reducer)]\r\nfn filtering(id: u64) {\r\n match Person::filter_by_id(&id) {\r\n Some(person) => println!(\"Found {person}\"),\r\n None => println!(\"No person with id {id}\"),\r\n }\r\n}\r\n```\r\n\r\nOur `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`.\r\n\r\n```rust\r\n#[spacetimedb(reducer)]\r\nfn filtering_non_unique() {\r\n for person in Person::filter_by_age(&21) {\r\n println!(\"{person} has turned 21\");\r\n }\r\n}\r\n```\r\n\r\n### Deleting\r\n\r\nLike filtering, we can delete by a unique column instead of the entire row.\r\n\r\n```rust\r\n#[spacetimedb(reducer)]\r\nfn delete_id(id: u64) {\r\n Person::delete_by_id(&id)\r\n}\r\n```\r\n\r\n[macro library]: https://github.com/clockworklabs/SpacetimeDB/tree/master/crates/bindings-macro\r\n[module library]: https://github.com/clockworklabs/SpacetimeDB/tree/master/crates/lib\r\n[demo]: /#demo\r\n", - "editUrl": "ModuleReference.md", - "jumpLinks": [ - { - "title": "SpacetimeDB Rust Modules", - "route": "spacetimedb-rust-modules", - "depth": 1 - }, - { - "title": "SpacetimeDB Macro basics", - "route": "spacetimedb-macro-basics", - "depth": 2 - }, - { - "title": "Macro API", - "route": "macro-api", - "depth": 2 - }, - { - "title": "Defining tables", - "route": "defining-tables", - "depth": 3 - }, - { - "title": "Defining reducers", - "route": "defining-reducers", - "depth": 3 - }, - { - "title": "Client API", - "route": "client-api", - "depth": 2 - }, - { - "title": "`println!` and friends", - "route": "-println-and-friends", - "depth": 3 - }, - { - "title": "Generated functions on a SpacetimeDB table", - "route": "generated-functions-on-a-spacetimedb-table", - "depth": 3 - }, - { - "title": "Insertion", - "route": "insertion", - "depth": 3 - }, - { - "title": "Iterating", - "route": "iterating", - "depth": 3 - }, - { - "title": "Filtering", - "route": "filtering", - "depth": 3 - }, - { - "title": "Deleting", - "route": "deleting", - "depth": 3 - } - ], - "pages": [] - } - ] - } - ], - "previousKey": { - "title": "Unity Tutorial", - "route": "index", - "depth": 1 - }, - "nextKey": { - "title": "Client SDK Languages", - "route": "index", - "depth": 1 - } - }, - { - "title": "Client SDK Languages", - "identifier": "Client SDK Languages", - "indexIdentifier": "index", - "comingSoon": false, - "hasPages": true, - "editUrl": "Client%20SDK%20Languages/index.md", - "jumpLinks": [], - "pages": [ - { - "title": "C#", - "identifier": "C#", - "indexIdentifier": "index", - "comingSoon": false, - "hasPages": true, - "editUrl": "C%23/index.md", - "jumpLinks": [], - "pages": [ - { - "title": "C# Client SDK Quick Start", - "identifier": "index", - "indexIdentifier": "index", - "content": "# C# Client SDK Quick Start\r\n\r\nIn this guide we'll show you how to get up and running with a simple SpacetimDB app with a client written in C#.\r\n\r\nWe'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.\r\n\r\n## Project structure\r\n\r\nEnter 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:\r\n\r\n```bash\r\ncd quickstart-chat\r\n```\r\n\r\nWithin it, create a new C# console application project called `client` using either Visual Studio or the .NET CLI:\r\n\r\n```bash\r\ndotnet new console -o client\r\n```\r\n\r\nOpen the project in your IDE of choice.\r\n\r\n## Add the NuGet package for the C# SpacetimeDB SDK\r\n\r\nAdd the `spacetimedbsdk` [NuGet package](https://www.nuget.org/packages/spacetimedbsdk) using Visual Studio NuGet package manager or via the .NET CLI\r\n\r\n```bash\r\ndotnet add package spacetimedbsdk\r\n```\r\n\r\n## Generate your module types\r\n\r\nThe `spacetime` CLI's `generate` command will generate client-side interfaces for the tables, reducers and types defined in your server module.\r\n\r\nIn your `quickstart-chat` directory, run:\r\n\r\n```bash\r\nmkdir -p client/module_bindings\r\nspacetime generate --lang csharp --out-dir client/module_bindings --project-path server\r\n```\r\n\r\nTake a look inside `client/module_bindings`. The CLI should have generated five files:\r\n\r\n```\r\nmodule_bindings\r\n├── Message.cs\r\n├── ReducerEvent.cs\r\n├── SendMessageReducer.cs\r\n├── SetNameReducer.cs\r\n└── User.cs\r\n```\r\n\r\n## Add imports to Program.cs\r\n\r\nOpen `client/Program.cs` and add the following imports:\r\n\r\n```csharp\r\nusing SpacetimeDB;\r\nusing SpacetimeDB.Types;\r\nusing System.Collections.Concurrent;\r\n```\r\n\r\nWe 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`:\r\n\r\n```csharp\r\n// our local client SpacetimeDB identity\r\nIdentity? local_identity = null;\r\n// declare a thread safe queue to store commands in format (command, args)\r\nConcurrentQueue<(string,string)> input_queue = new ConcurrentQueue<(string, string)>();\r\n// declare a threadsafe cancel token to cancel the process loop\r\nCancellationTokenSource cancel_token = new CancellationTokenSource();\r\n```\r\n\r\n## Define Main function\r\n\r\nWe'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:\r\n\r\n1. Initialize the AuthToken module, which loads and stores our authentication token to/from local storage.\r\n2. Create the SpacetimeDBClient instance.\r\n3. 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.\r\n4. 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.\r\n5. Start the input loop, which reads commands from standard input and sends them to the processing thread.\r\n6. When the input loop exits, stop the processing thread and wait for it to exit.\r\n\r\n```csharp\r\nvoid Main()\r\n{\r\n AuthToken.Init(\".spacetime_csharp_quickstart\");\r\n\r\n // create the client, pass in a logger to see debug messages\r\n SpacetimeDBClient.CreateInstance(new ConsoleLogger());\r\n\r\n RegisterCallbacks();\r\n\r\n // spawn a thread to call process updates and process commands\r\n var thread = new Thread(ProcessThread);\r\n thread.Start();\r\n\r\n InputLoop();\r\n\r\n // this signals the ProcessThread to stop\r\n cancel_token.Cancel();\r\n thread.Join();\r\n}\r\n```\r\n\r\n## Register callbacks\r\n\r\nWe need to handle several sorts of events:\r\n\r\n1. `onConnect`: When we connect, we will call `Subscribe` to tell the module what tables we care about.\r\n2. `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.\r\n3. `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.\r\n4. `User.OnInsert`: When a new user joins, we'll print a message introducing them.\r\n5. `User.OnUpdate`: When a user is updated, we'll print their new name, or declare their new online status.\r\n6. `Message.OnInsert`: When we receive a new message, we'll print it.\r\n7. `Reducer.OnSetNameEvent`: If the server rejects our attempt to set our name, we'll print an error.\r\n8. `Reducer.OnSendMessageEvent`: If the server rejects a message we send, we'll print an error.\r\n\r\n```csharp\r\nvoid RegisterCallbacks()\r\n{\r\n SpacetimeDBClient.instance.onConnect += OnConnect;\r\n SpacetimeDBClient.instance.onIdentityReceived += OnIdentityReceived;\r\n SpacetimeDBClient.instance.onSubscriptionApplied += OnSubscriptionApplied;\r\n\r\n User.OnInsert += User_OnInsert;\r\n User.OnUpdate += User_OnUpdate;\r\n\r\n Message.OnInsert += Message_OnInsert;\r\n\r\n Reducer.OnSetNameEvent += Reducer_OnSetNameEvent;\r\n Reducer.OnSendMessageEvent += Reducer_OnSendMessageEvent;\r\n}\r\n```\r\n\r\n### Notify about new users\r\n\r\nFor 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, which are automatically generated for each table by `spacetime generate`.\r\n\r\nThese callbacks can fire in two contexts:\r\n\r\n- After a reducer runs, when the client's cache is updated about changes to subscribed rows.\r\n- After calling `subscribe`, when the client's cache is initialized with all existing matching rows.\r\n\r\nThis 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.\r\n\r\n`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.\r\n\r\nWhenever 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.\r\n\r\n```csharp\r\nstring UserNameOrIdentity(User user) => user.Name ?? Identity.From(user.Identity).ToString()!.Substring(0, 8);\r\n\r\nvoid User_OnInsert(User insertedValue, ReducerEvent? dbEvent)\r\n{\r\n if(insertedValue.Online)\r\n {\r\n Console.WriteLine($\"{UserNameOrIdentity(insertedValue)} is online\");\r\n }\r\n}\r\n```\r\n\r\n### Notify about updated users\r\n\r\nBecause 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.\r\n\r\n`OnUpdate` callbacks take three arguments: the old row, the new row, and a `ReducerEvent`.\r\n\r\nIn our module, users can be updated for three reasons:\r\n\r\n1. They've set their name using the `SetName` reducer.\r\n2. They're an existing user re-connecting, so their `Online` has been set to `true`.\r\n3. They've disconnected, so their `Online` has been set to `false`.\r\n\r\nWe'll print an appropriate message in each of these cases.\r\n\r\n```csharp\r\nvoid User_OnUpdate(User oldValue, User newValue, ReducerEvent dbEvent)\r\n{\r\n if(oldValue.Name != newValue.Name)\r\n {\r\n Console.WriteLine($\"{UserNameOrIdentity(oldValue)} renamed to {newValue.Name}\");\r\n }\r\n if(oldValue.Online != newValue.Online)\r\n {\r\n if(newValue.Online)\r\n {\r\n Console.WriteLine($\"{UserNameOrIdentity(newValue)} connected.\");\r\n }\r\n else\r\n {\r\n Console.WriteLine($\"{UserNameOrIdentity(newValue)} disconnected.\");\r\n }\r\n }\r\n}\r\n```\r\n\r\n### Print messages\r\n\r\nWhen 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.\r\n\r\nTo 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.\r\n\r\nWe'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`.\r\n\r\n```csharp\r\nvoid PrintMessage(Message message)\r\n{\r\n var sender = User.FilterByIdentity(message.Sender);\r\n var senderName = \"unknown\";\r\n if(sender != null)\r\n {\r\n senderName = UserNameOrIdentity(sender);\r\n }\r\n\r\n Console.WriteLine($\"{senderName}: {message.Text}\");\r\n}\r\n\r\nvoid Message_OnInsert(Message insertedValue, ReducerEvent? dbEvent)\r\n{\r\n if(dbEvent != null)\r\n {\r\n PrintMessage(insertedValue);\r\n }\r\n}\r\n```\r\n\r\n### Warn if our name was rejected\r\n\r\nWe can also register callbacks to run each time a reducer is invoked. We register these callbacks using the `OnReducerEvent` method of the `Reducer` namespace, which is automatically implemented for each reducer by `spacetime generate`.\r\n\r\nEach reducer callback takes one fixed argument:\r\n\r\nThe ReducerEvent that triggered the callback. It contains several fields. The ones we care about are:\r\n\r\n1. The `Identity` of the client that called the reducer.\r\n2. The `Status` of the reducer run, one of `Committed`, `Failed` or `OutOfEnergy`.\r\n3. The error message, if any, that the reducer returned.\r\n\r\nIt also takes a variable amount of additional arguments that match the reducer's arguments.\r\n\r\nThese callbacks will be invoked in one of two cases:\r\n\r\n1. If the reducer was successful and altered any of our subscribed rows.\r\n2. If we requested an invocation which failed.\r\n\r\nNote that a status of `Failed` or `OutOfEnergy` implies that the caller identity is our own identity.\r\n\r\nWe already handle successful `SetName` invocations using our `User.OnUpdate` 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 `Reducer_OnSetNameEvent` as a `Reducer.OnSetNameEvent` callback which checks if the reducer failed, and if it did, prints an error message including the rejected name.\r\n\r\nWe'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.\r\n\r\n```csharp\r\nvoid Reducer_OnSetNameEvent(ReducerEvent reducerEvent, string name)\r\n{\r\n if(reducerEvent.Identity == local_identity && reducerEvent.Status == ClientApi.Event.Types.Status.Failed)\r\n {\r\n Console.Write($\"Failed to change name to {name}\");\r\n }\r\n}\r\n```\r\n\r\n### Warn if our message was rejected\r\n\r\nWe handle warnings on rejected messages the same way as rejected names, though the types and the error message are different.\r\n\r\n```csharp\r\nvoid Reducer_OnSendMessageEvent(ReducerEvent reducerEvent, string text)\r\n{\r\n if (reducerEvent.Identity == local_identity && reducerEvent.Status == ClientApi.Event.Types.Status.Failed)\r\n {\r\n Console.Write($\"Failed to send message {text}\");\r\n }\r\n}\r\n```\r\n\r\n## Connect callback\r\n\r\nOnce 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.\r\n\r\n```csharp\r\nvoid OnConnect()\r\n{\r\n SpacetimeDBClient.instance.Subscribe(new List { \"SELECT * FROM User\", \"SELECT * FROM Message\" });\r\n}\r\n```\r\n\r\n## OnIdentityReceived callback\r\n\r\nThis 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.\r\n\r\n```csharp\r\nvoid OnIdentityReceived(string authToken, Identity identity)\r\n{\r\n local_identity = identity;\r\n AuthToken.SaveToken(authToken);\r\n}\r\n```\r\n\r\n## OnSubscriptionApplied callback\r\n\r\nOnce 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.\r\n\r\n```csharp\r\nvoid PrintMessagesInOrder()\r\n{\r\n foreach (Message message in Message.Iter().OrderBy(item => item.Sent))\r\n {\r\n PrintMessage(message);\r\n }\r\n}\r\n\r\nvoid OnSubscriptionApplied()\r\n{\r\n Console.WriteLine(\"Connected\");\r\n PrintMessagesInOrder();\r\n}\r\n```\r\n\r\n\r\n\r\n## Process thread\r\n\r\nSince the input loop will be blocking, we'll run our processing code in a separate thread. This thread will:\r\n\r\n1. 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.\r\n\r\n`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.\r\n\r\n2. 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.\r\n\r\n3. Finally, Close the connection to the module.\r\n\r\n```csharp\r\nconst string HOST = \"localhost:3000\";\r\nconst string DBNAME = \"chat\";\r\nconst bool SSL_ENABLED = false;\r\n\r\nvoid ProcessThread()\r\n{\r\n SpacetimeDBClient.instance.Connect(AuthToken.Token, HOST, DBNAME, SSL_ENABLED);\r\n\r\n // loop until cancellation token\r\n while (!cancel_token.IsCancellationRequested)\r\n {\r\n SpacetimeDBClient.instance.Update();\r\n\r\n ProcessCommands();\r\n\r\n Thread.Sleep(100);\r\n }\r\n\r\n SpacetimeDBClient.instance.Close();\r\n}\r\n```\r\n\r\n## Input loop and ProcessCommands\r\n\r\nThe 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.\r\n\r\nSupported Commands:\r\n\r\n1. Send a message: `message`, send the message to the module by calling `Reducer.SendMessage` which is automatically generated by `spacetime generate`.\r\n\r\n2. Set name: `name`, will send the new name to the module by calling `Reducer.SetName` which is automatically generated by `spacetime generate`.\r\n\r\n```csharp\r\nvoid InputLoop()\r\n{\r\n while (true)\r\n {\r\n var input = Console.ReadLine();\r\n if(input == null)\r\n {\r\n break;\r\n }\r\n\r\n if(input.StartsWith(\"/name \"))\r\n {\r\n input_queue.Enqueue((\"name\", input.Substring(6)));\r\n continue;\r\n }\r\n else\r\n {\r\n input_queue.Enqueue((\"message\", input));\r\n }\r\n }\r\n}\r\n\r\nvoid ProcessCommands()\r\n{\r\n // process input queue commands\r\n while (input_queue.TryDequeue(out var command))\r\n {\r\n switch (command.Item1)\r\n {\r\n case \"message\":\r\n Reducer.SendMessage(command.Item2);\r\n break;\r\n case \"name\":\r\n Reducer.SetName(command.Item2);\r\n break;\r\n }\r\n }\r\n}\r\n```\r\n\r\n## Run the client\r\n\r\nFinally we just need to add a call to `Main` in `Program.cs`:\r\n\r\n```csharp\r\nMain();\r\n```\r\n\r\nNow we can run the client, by hitting start in Visual Studio or running the following command in the `client` directory:\r\n\r\n```bash\r\ndotnet run --project client\r\n```\r\n\r\n## What's next?\r\n\r\nCongratulations! 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.\r\n", - "hasPages": false, - "editUrl": "index.md", - "jumpLinks": [ - { - "title": "C# Client SDK Quick Start", - "route": "c-client-sdk-quick-start", - "depth": 1 - }, - { - "title": "Project structure", - "route": "project-structure", - "depth": 2 - }, - { - "title": "Add the NuGet package for the C# SpacetimeDB SDK", - "route": "add-the-nuget-package-for-the-c-spacetimedb-sdk", - "depth": 2 - }, - { - "title": "Generate your module types", - "route": "generate-your-module-types", - "depth": 2 - }, - { - "title": "Add imports to Program.cs", - "route": "add-imports-to-program-cs", - "depth": 2 - }, - { - "title": "Define Main function", - "route": "define-main-function", - "depth": 2 - }, - { - "title": "Register callbacks", - "route": "register-callbacks", - "depth": 2 - }, - { - "title": "Notify about new users", - "route": "notify-about-new-users", - "depth": 3 - }, - { - "title": "Notify about updated users", - "route": "notify-about-updated-users", - "depth": 3 - }, - { - "title": "Print messages", - "route": "print-messages", - "depth": 3 - }, - { - "title": "Warn if our name was rejected", - "route": "warn-if-our-name-was-rejected", - "depth": 3 - }, - { - "title": "Warn if our message was rejected", - "route": "warn-if-our-message-was-rejected", - "depth": 3 - }, - { - "title": "Connect callback", - "route": "connect-callback", - "depth": 2 - }, - { - "title": "OnIdentityReceived callback", - "route": "onidentityreceived-callback", - "depth": 2 - }, - { - "title": "OnSubscriptionApplied callback", - "route": "onsubscriptionapplied-callback", - "depth": 2 - }, - { - "title": "Process thread", - "route": "process-thread", - "depth": 2 - }, - { - "title": "Input loop and ProcessCommands", - "route": "input-loop-and-processcommands", - "depth": 2 - }, - { - "title": "Run the client", - "route": "run-the-client", - "depth": 2 - }, - { - "title": "What's next?", - "route": "what-s-next-", - "depth": 2 - } - ], - "pages": [] - }, - { - "title": "The SpacetimeDB C# client SDK", - "identifier": "SDK Reference", - "indexIdentifier": "SDK Reference", - "hasPages": false, - "content": "# The SpacetimeDB C# client SDK\r\n\r\nThe SpacetimeDB client C# for Rust contains all the tools you need to build native clients for SpacetimeDB modules using C#.\r\n\r\n## Table of Contents\r\n\r\n- [The SpacetimeDB C# client SDK](#the-spacetimedb-c-client-sdk)\r\n - [Table of Contents](#table-of-contents)\r\n - [Install the SDK](#install-the-sdk)\r\n - [Using the `dotnet` CLI tool](#using-the-dotnet-cli-tool)\r\n - [Using Unity](#using-unity)\r\n - [Generate module bindings](#generate-module-bindings)\r\n - [Initialization](#initialization)\r\n - [Static Method `SpacetimeDBClient.CreateInstance`](#static-method-spacetimedbclientcreateinstance)\r\n - [Property `SpacetimeDBClient.instance`](#property-spacetimedbclientinstance)\r\n - [Class `NetworkManager`](#class-networkmanager)\r\n - [Method `SpacetimeDBClient.Connect`](#method-spacetimedbclientconnect)\r\n - [Event `SpacetimeDBClient.onIdentityReceived`](#event-spacetimedbclientonidentityreceived)\r\n - [Event `SpacetimeDBClient.onConnect`](#event-spacetimedbclientonconnect)\r\n - [Subscribe to queries](#subscribe-to-queries)\r\n - [Method `SpacetimeDBClient.Subscribe`](#method-spacetimedbclientsubscribe)\r\n - [Event `SpacetimeDBClient.onSubscriptionApplied`](#event-spacetimedbclientonsubscriptionapplied)\r\n - [View rows of subscribed tables](#view-rows-of-subscribed-tables)\r\n - [Class `{TABLE}`](#class-table)\r\n - [Static Method `{TABLE}.Iter`](#static-method-tableiter)\r\n - [Static Method `{TABLE}.FilterBy{COLUMN}`](#static-method-tablefilterbycolumn)\r\n - [Static Method `{TABLE}.Count`](#static-method-tablecount)\r\n - [Static Event `{TABLE}.OnInsert`](#static-event-tableoninsert)\r\n - [Static Event `{TABLE}.OnBeforeDelete`](#static-event-tableonbeforedelete)\r\n - [Static Event `{TABLE}.OnDelete`](#static-event-tableondelete)\r\n - [Static Event `{TABLE}.OnUpdate`](#static-event-tableonupdate)\r\n - [Observe and invoke reducers](#observe-and-invoke-reducers)\r\n - [Class `Reducer`](#class-reducer)\r\n - [Static Method `Reducer.{REDUCER}`](#static-method-reducerreducer)\r\n - [Static Event `Reducer.On{REDUCER}`](#static-event-reduceronreducer)\r\n - [Class `ReducerEvent`](#class-reducerevent)\r\n - [Enum `Status`](#enum-status)\r\n - [Variant `Status.Committed`](#variant-statuscommitted)\r\n - [Variant `Status.Failed`](#variant-statusfailed)\r\n - [Variant `Status.OutOfEnergy`](#variant-statusoutofenergy)\r\n - [Identity management](#identity-management)\r\n - [Class `AuthToken`](#class-authtoken)\r\n - [Static Method `AuthToken.Init`](#static-method-authtokeninit)\r\n - [Static Property `AuthToken.Token`](#static-property-authtokentoken)\r\n - [Static Method `AuthToken.SaveToken`](#static-method-authtokensavetoken)\r\n - [Class `Identity`](#class-identity)\r\n - [Customizing logging](#customizing-logging)\r\n - [Interface `ISpacetimeDBLogger`](#interface-ispacetimedblogger)\r\n - [Class `ConsoleLogger`](#class-consolelogger)\r\n - [Class `UnityDebugLogger`](#class-unitydebuglogger)\r\n\r\n## Install the SDK\r\n\r\n### Using the `dotnet` CLI tool\r\n\r\nIf you would like to create a console application using .NET, you can create a new project using `dotnet new console` and add the SpacetimeDB SDK to your dependencies:\r\n\r\n```bash\r\ndotnet add package spacetimedbsdk\r\n```\r\n\r\n(See also the [CSharp Quickstart](./CSharpSDKQuickStart) for an in-depth example of such a console application.)\r\n\r\n### Using Unity\r\n\r\nTo install the SpacetimeDB SDK into a Unity project, download the SpacetimeDB SDK from the following link.\r\n\r\nhttps://sdk.spacetimedb.com/SpacetimeDBUnitySDK.unitypackage\r\n\r\nIn Unity navigate to the `Assets > Import Package > Custom Package...` menu in the menu bar. Select your `SpacetimeDBUnitySDK.unitypackage` file and leave all folders checked.\r\n\r\n(See also the [Unity Quickstart](./UnityQuickStart) and [Unity Tutorial](./UnityTutorialPart1).)\r\n\r\n## Generate module bindings\r\n\r\nEach SpacetimeDB client depends on some bindings specific to your module. Create a `module_bindings` directory in your project's directory and generate the C# interface files using the Spacetime CLI. From your project directory, run:\r\n\r\n```bash\r\nmkdir -p module_bindings\r\nspacetime generate --lang cs --out-dir module_bindings --project-path PATH-TO-MODULE-DIRECTORY\r\n```\r\n\r\nReplace `PATH-TO-MODULE-DIRECTORY` with the path to your SpacetimeDB module.\r\n\r\n## Initialization\r\n\r\n### Static Method `SpacetimeDBClient.CreateInstance`\r\n\r\n```cs\r\nnamespace SpacetimeDB {\r\n\r\npublic class SpacetimeDBClient {\r\n public static void CreateInstance(ISpacetimeDBLogger loggerToUse);\r\n}\r\n\r\n}\r\n```\r\n\r\nCreate a global SpacetimeDBClient instance, accessible via [`SpacetimeDBClient.instance`](#property-spacetimedbclientinstance)\r\n\r\n| Argument | Type | Meaning |\r\n| ------------- | ----------------------------------------------------- | --------------------------------- |\r\n| `loggerToUse` | [`ISpacetimeDBLogger`](#interface-ispacetimedblogger) | The logger to use to log messages |\r\n\r\nThere is a provided logger called [`ConsoleLogger`](#class-consolelogger) which logs to `System.Console`, and can be used as follows:\r\n\r\n```cs\r\nusing SpacetimeDB;\r\nusing SpacetimeDB.Types;\r\nSpacetimeDBClient.CreateInstance(new ConsoleLogger());\r\n```\r\n\r\n### Property `SpacetimeDBClient.instance`\r\n\r\n```cs\r\nnamespace SpacetimeDB {\r\n\r\npublic class SpacetimeDBClient {\r\n public static SpacetimeDBClient instance;\r\n}\r\n\r\n}\r\n```\r\n\r\nThis is the global instance of a SpacetimeDB client in a particular .NET/Unity process. Much of the SDK is accessible through this instance.\r\n\r\n### Class `NetworkManager`\r\n\r\nThe 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.\r\n\r\n![Unity-AddNetworkManager](/images/unity-tutorial/Unity-AddNetworkManager.JPG)\r\n\r\nThis 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.\r\n\r\n### Method `SpacetimeDBClient.Connect`\r\n\r\n```cs\r\nnamespace SpacetimeDB {\r\n\r\nclass SpacetimeDBClient {\r\n public void Connect(\r\n string? token,\r\n string host,\r\n string addressOrName,\r\n bool sslEnabled = true\r\n );\r\n}\r\n\r\n}\r\n```\r\n\r\n\r\n\r\nConnect to a database named `addressOrName` accessible over the internet at the URI `host`.\r\n\r\n| Argument | Type | Meaning |\r\n| --------------- | --------- | -------------------------------------------------------------------------- |\r\n| `token` | `string?` | Identity token to use, if one is available. |\r\n| `host` | `string` | URI of the SpacetimeDB instance running the module. |\r\n| `addressOrName` | `string` | Address or name of the module. |\r\n| `sslEnabled` | `bool` | Whether or not to use SSL when connecting to SpacetimeDB. Default: `true`. |\r\n\r\nIf 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).\r\n\r\n```cs\r\nusing SpacetimeDB;\r\nusing SpacetimeDB.Types;\r\n\r\nconst string DBNAME = \"chat\";\r\n\r\n// Connect to a local DB with a fresh identity\r\nSpacetimeDBClient.instance.Connect(null, \"localhost:3000\", DBNAME, false);\r\n\r\n// Connect to cloud with a fresh identity\r\nSpacetimeDBClient.instance.Connect(null, \"dev.spacetimedb.net\", DBNAME, true);\r\n\r\n// Connect to cloud using a saved identity from the filesystem, or get a new one and save it\r\nAuthToken.Init();\r\nIdentity localIdentity;\r\nSpacetimeDBClient.instance.Connect(AuthToken.Token, \"dev.spacetimedb.net\", DBNAME, true);\r\nSpacetimeDBClient.instance.onIdentityReceived += (string authToken, Identity identity) {\r\n AuthToken.SaveToken(authToken);\r\n localIdentity = identity;\r\n}\r\n```\r\n\r\n(You should probably also store the returned `Identity` somewhere; see the [`onIdentityReceived`](#event-spacetimedbclientonidentityreceived) event.)\r\n\r\n### Event `SpacetimeDBClient.onIdentityReceived`\r\n\r\n```cs\r\nnamespace SpacetimeDB {\r\n\r\nclass SpacetimeDBClient {\r\n public event Action onIdentityReceived;\r\n}\r\n\r\n}\r\n```\r\n\r\nCalled 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.\r\n\r\nTo 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.\r\n\r\nIf 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`.\r\n\r\n```cs\r\n// Connect to cloud using a saved identity from the filesystem, or get a new one and save it\r\nAuthToken.Init();\r\nIdentity localIdentity;\r\nSpacetimeDBClient.instance.Connect(AuthToken.Token, \"dev.spacetimedb.net\", DBNAME, true);\r\nSpacetimeDBClient.instance.onIdentityReceived += (string authToken, Identity identity) {\r\n AuthToken.SaveToken(authToken);\r\n localIdentity = identity;\r\n}\r\n```\r\n\r\n### Event `SpacetimeDBClient.onConnect`\r\n\r\n```cs\r\nnamespace SpacetimeDB {\r\n\r\nclass SpacetimeDBClient {\r\n public event Action onConnect;\r\n}\r\n\r\n}\r\n```\r\n\r\nAllows registering delegates to be invoked upon authentication with the database.\r\n\r\nOnce this occurs, the SDK is prepared for calls to [`SpacetimeDBClient.Subscribe`](#method-spacetimedbclientsubscribe).\r\n\r\n## Subscribe to queries\r\n\r\n### Method `SpacetimeDBClient.Subscribe`\r\n\r\n```cs\r\nnamespace SpacetimeDB {\r\n\r\nclass SpacetimeDBClient {\r\n public void Subscribe(List queries);\r\n}\r\n\r\n}\r\n```\r\n\r\n| Argument | Type | Meaning |\r\n| --------- | -------------- | ---------------------------- |\r\n| `queries` | `List` | SQL queries to subscribe to. |\r\n\r\nSubscribe to a set of queries, to be notified when rows which match those queries are altered.\r\n\r\n`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.\r\n\r\nThe `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.\r\n\r\nA 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.\r\n\r\n```cs\r\nusing SpacetimeDB;\r\nusing SpacetimeDB.Types;\r\n\r\nvoid Main()\r\n{\r\n AuthToken.Init();\r\n SpacetimeDBClient.CreateInstance(new ConsoleLogger());\r\n\r\n SpacetimeDBClient.instance.onConnect += OnConnect;\r\n\r\n // Our module contains a table named \"Loot\"\r\n Loot.OnInsert += Loot_OnInsert;\r\n\r\n SpacetimeDBClient.instance.Connect(/* ... */);\r\n}\r\n\r\nvoid OnConnect()\r\n{\r\n SpacetimeDBClient.instance.Subscribe(new List {\r\n \"SELECT * FROM Loot\"\r\n });\r\n}\r\n\r\nvoid Loot_OnInsert(\r\n Loot loot,\r\n ReducerEvent? event\r\n) {\r\n Console.Log($\"Loaded loot {loot.itemType} at coordinates {loot.position}\");\r\n}\r\n```\r\n\r\n### Event `SpacetimeDBClient.onSubscriptionApplied`\r\n\r\n```cs\r\nnamespace SpacetimeDB {\r\n\r\nclass SpacetimeDBClient {\r\n public event Action onSubscriptionApplied;\r\n}\r\n\r\n}\r\n```\r\n\r\nRegister a delegate to be invoked when a subscription is registered with the database.\r\n\r\n```cs\r\nusing SpacetimeDB;\r\n\r\nvoid OnSubscriptionApplied()\r\n{\r\n Console.WriteLine(\"Now listening on queries.\");\r\n}\r\n\r\nvoid Main()\r\n{\r\n // ...initialize...\r\n SpacetimeDBClient.instance.onSubscriptionApplied += OnSubscriptionApplied;\r\n}\r\n```\r\n\r\n## View rows of subscribed tables\r\n\r\nThe 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).\r\n\r\nONLY 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.\r\n\r\nIn 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.\r\n\r\nTo 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.\r\n\r\n### Class `{TABLE}`\r\n\r\nFor 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.\r\n\r\nStatic Methods:\r\n\r\n- [`{TABLE}.Iter()`](#static-method-tableiter) iterates all subscribed rows in the client cache.\r\n- [`{TABLE}.FilterBy{COLUMN}(value)`](#static-method-tablefilterbycolumn) filters subscribed rows in the client cache by a column value.\r\n- [`{TABLE}.Count()`](#static-method-tablecount) counts the number of subscribed rows in the client cache.\r\n\r\nStatic Events:\r\n\r\n- [`{TABLE}.OnInsert`](#static-event-tableoninsert) is called when a row is inserted into the client cache.\r\n- [`{TABLE}.OnBeforeDelete`](#static-event-tableonbeforedelete) is called when a row is about to be removed from the client cache.\r\n- If the table has a primary key attribute, [`{TABLE}.OnUpdate`](#static-event-tableonupdate) is called when a row is updated.\r\n- [`{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.\r\n\r\nNote 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.\r\n\r\n#### Static Method `{TABLE}.Iter`\r\n\r\n```cs\r\nnamespace SpacetimeDB.Types {\r\n\r\nclass TABLE {\r\n public static System.Collections.Generic.IEnumerable
Iter();\r\n}\r\n\r\n}\r\n```\r\n\r\nIterate over all the subscribed rows in the table. This method is only available after [`SpacetimeDBClient.onSubscriptionApplied`](#event-spacetimedbclientonsubscriptionapplied) has occurred.\r\n\r\nWhen iterating over rows and filtering for those containing a particular column, [`TableType::filter`](#method-filter) will be more efficient, so prefer it when possible.\r\n\r\n```cs\r\nusing SpacetimeDB;\r\nusing SpacetimeDB.Types;\r\n\r\nSpacetimeDBClient.instance.onConnect += (string authToken, Identity identity) => {\r\n SpacetimeDBClient.instance.Subscribe(new List { \"SELECT * FROM User\" });\r\n};\r\nSpacetimeDBClient.instance.onSubscriptionApplied += () => {\r\n // Will print a line for each `User` row in the database.\r\n foreach (var user in User.Iter()) {\r\n Console.WriteLine($\"User: {user.Name}\");\r\n }\r\n};\r\nSpacetimeDBClient.instance.connect(/* ... */);\r\n```\r\n\r\n#### Static Method `{TABLE}.FilterBy{COLUMN}`\r\n\r\n```cs\r\nnamespace SpacetimeDB.Types {\r\n\r\nclass TABLE {\r\n // If the column has no #[unique] or #[primarykey] constraint\r\n public static System.Collections.Generic.IEnumerable
FilterBySender(COLUMNTYPE value);\r\n\r\n // If the column has a #[unique] or #[primarykey] constraint\r\n public static TABLE? FilterBySender(COLUMNTYPE value);\r\n}\r\n\r\n}\r\n```\r\n\r\nFor 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`.\r\n\r\nThe method's return type depends on the column's attributes:\r\n\r\n- For unique columns, including those annotated `#[unique]` and `#[primarykey]`, the `filterBy{COLUMN}` method returns a `{TABLE}?`, where `{TABLE}` is the [table class](#class-table).\r\n- For non-unique columns, the `filter_by` method returns an `IEnumerator<{TABLE}>`.\r\n\r\n#### Static Method `{TABLE}.Count`\r\n\r\n```cs\r\nnamespace SpacetimeDB.Types {\r\n\r\nclass TABLE {\r\n public static int Count();\r\n}\r\n\r\n}\r\n```\r\n\r\nReturn the number of subscribed rows in the table, or 0 if there is no active connection.\r\n\r\n```cs\r\nusing SpacetimeDB;\r\nusing SpacetimeDB.Types;\r\n\r\nSpacetimeDBClient.instance.onConnect += (string authToken, Identity identity) => {\r\n SpacetimeDBClient.instance.Subscribe(new List { \"SELECT * FROM User\" });\r\n};\r\nSpacetimeDBClient.instance.onSubscriptionApplied += () => {\r\n Console.WriteLine($\"There are {User.Count()} users in the database.\");\r\n};\r\nSpacetimeDBClient.instance.connect(/* ... */);\r\n```\r\n\r\n#### Static Event `{TABLE}.OnInsert`\r\n\r\n```cs\r\nnamespace SpacetimeDB.Types {\r\n\r\nclass TABLE {\r\n public delegate void InsertEventHandler(\r\n TABLE insertedValue,\r\n ReducerEvent? dbEvent\r\n );\r\n public static event InsertEventHandler OnInsert;\r\n}\r\n\r\n}\r\n```\r\n\r\nRegister a delegate for when a subscribed row is newly inserted into the database.\r\n\r\nThe delegate takes two arguments:\r\n\r\n- A [`{TABLE}`](#class-table) instance with the data of the inserted row\r\n- 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.\r\n\r\n```cs\r\nusing SpacetimeDB;\r\nusing SpacetimeDB.Types;\r\n\r\n/* initialize, subscribe to table User... */\r\n\r\nUser.OnInsert += (User user, ReducerEvent? reducerEvent) => {\r\n if (reducerEvent == null) {\r\n Console.WriteLine($\"New user '{user.Name}' received during subscription update.\");\r\n } else {\r\n Console.WriteLine($\"New user '{user.Name}' inserted by reducer {reducerEvent.Reducer}.\");\r\n }\r\n};\r\n```\r\n\r\n#### Static Event `{TABLE}.OnBeforeDelete`\r\n\r\n```cs\r\nnamespace SpacetimeDB.Types {\r\n\r\nclass TABLE {\r\n public delegate void DeleteEventHandler(\r\n TABLE deletedValue,\r\n ReducerEvent dbEvent\r\n );\r\n public static event DeleteEventHandler OnBeforeDelete;\r\n}\r\n\r\n}\r\n```\r\n\r\nRegister 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.\r\n\r\nThe delegate takes two arguments:\r\n\r\n- A [`{TABLE}`](#class-table) instance with the data of the deleted row\r\n- A [`ReducerEvent`](#class-reducerevent), which contains the data of the reducer that deleted the row.\r\n\r\nThis 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.\r\n\r\n```cs\r\nusing SpacetimeDB;\r\nusing SpacetimeDB.Types;\r\n\r\n/* initialize, subscribe to table User... */\r\n\r\nUser.OnBeforeDelete += (User user, ReducerEvent reducerEvent) => {\r\n Console.WriteLine($\"User '{user.Name}' deleted by reducer {reducerEvent.Reducer}.\");\r\n};\r\n```\r\n\r\n#### Static Event `{TABLE}.OnDelete`\r\n\r\n```cs\r\nnamespace SpacetimeDB.Types {\r\n\r\nclass TABLE {\r\n public delegate void DeleteEventHandler(\r\n TABLE deletedValue,\r\n SpacetimeDB.ReducerEvent dbEvent\r\n );\r\n public static event DeleteEventHandler OnDelete;\r\n}\r\n\r\n}\r\n```\r\n\r\nRegister 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).\r\n\r\nThe delegate takes two arguments:\r\n\r\n- A [`{TABLE}`](#class-table) instance with the data of the deleted row\r\n- A [`ReducerEvent`](#class-reducerevent), which contains the data of the reducer that deleted the row.\r\n\r\n```cs\r\nusing SpacetimeDB;\r\nusing SpacetimeDB.Types;\r\n\r\n/* initialize, subscribe to table User... */\r\n\r\nUser.OnBeforeDelete += (User user, ReducerEvent reducerEvent) => {\r\n Console.WriteLine($\"User '{user.Name}' deleted by reducer {reducerEvent.Reducer}.\");\r\n};\r\n```\r\n\r\n#### Static Event `{TABLE}.OnUpdate`\r\n\r\n```cs\r\nnamespace SpacetimeDB.Types {\r\n\r\nclass TABLE {\r\n public delegate void UpdateEventHandler(\r\n TABLE oldValue,\r\n TABLE newValue,\r\n ReducerEvent dbEvent\r\n );\r\n public static event UpdateEventHandler OnUpdate;\r\n}\r\n\r\n}\r\n```\r\n\r\nRegister 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.\r\n\r\nThe delegate takes three arguments:\r\n\r\n- A [`{TABLE}`](#class-table) instance with the old data of the updated row\r\n- A [`{TABLE}`](#class-table) instance with the new data of the updated row\r\n- A [`ReducerEvent`](#class-reducerevent), which contains the data of the reducer that updated the row.\r\n\r\n```cs\r\nusing SpacetimeDB;\r\nusing SpacetimeDB.Types;\r\n\r\n/* initialize, subscribe to table User... */\r\n\r\nUser.OnUpdate += (User oldUser, User newUser, ReducerEvent reducerEvent) => {\r\n Debug.Assert(oldUser.UserId == newUser.UserId, \"Primary key never changes in an update\");\r\n\r\n Console.WriteLine($\"User with ID {oldUser.UserId} had name changed \"+\r\n $\"from '{oldUser.Name}' to '{newUser.Name}' by reducer {reducerEvent.Reducer}.\");\r\n};\r\n```\r\n\r\n## Observe and invoke reducers\r\n\r\n\"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.\r\n\r\n`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).\r\n\r\n### Class `Reducer`\r\n\r\n```cs\r\nnamespace SpacetimeDB.Types {\r\n\r\nclass Reducer {}\r\n\r\n}\r\n```\r\n\r\nThis class contains a static method and event for each reducer defined in a module.\r\n\r\n#### Static Method `Reducer.{REDUCER}`\r\n\r\n```cs\r\nnamespace SpacetimeDB.Types {\r\nclass Reducer {\r\n\r\n/* void {REDUCER_NAME}(...ARGS...) */\r\n\r\n}\r\n}\r\n```\r\n\r\nFor 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`.\r\n\r\nReducers 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.\r\n\r\nFor reducers which accept a `ReducerContext` as their first argument, the `ReducerContext` is not included in the generated function's argument list.\r\n\r\nFor example, if we define a reducer in Rust as follows:\r\n\r\n```rust\r\n#[spacetimedb(reducer)]\r\npub fn set_name(\r\n ctx: ReducerContext,\r\n user_id: u64,\r\n name: String\r\n) -> Result<(), Error>;\r\n```\r\n\r\nThe following C# static method will be generated:\r\n\r\n```cs\r\nnamespace SpacetimeDB.Types {\r\nclass Reducer {\r\n\r\npublic static void SendMessage(UInt64 userId, string name);\r\n\r\n}\r\n}\r\n```\r\n\r\n#### Static Event `Reducer.On{REDUCER}`\r\n\r\n```cs\r\nnamespace SpacetimeDB.Types {\r\nclass Reducer {\r\n\r\npublic delegate void /*{REDUCER}*/Handler(ReducerEvent reducerEvent, /* {ARGS...} */);\r\n\r\npublic static event /*{REDUCER}*/Handler On/*{REDUCER}*/Event;\r\n\r\n}\r\n}\r\n```\r\n\r\nFor 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`.\r\n\r\nThe 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.\r\n\r\nFor example, if we define a reducer in Rust as follows:\r\n\r\n```rust\r\n#[spacetimedb(reducer)]\r\npub fn set_name(\r\n ctx: ReducerContext,\r\n user_id: u64,\r\n name: String\r\n) -> Result<(), Error>;\r\n```\r\n\r\nThe following C# static method will be generated:\r\n\r\n```cs\r\nnamespace SpacetimeDB.Types {\r\nclass Reducer {\r\n\r\npublic delegate void SetNameHandler(\r\n ReducerEvent reducerEvent,\r\n UInt64 userId,\r\n string name\r\n);\r\npublic static event SetNameHandler OnSetNameEvent;\r\n\r\n}\r\n}\r\n```\r\n\r\nWhich can be used as follows:\r\n\r\n```cs\r\n/* initialize, wait for onSubscriptionApplied... */\r\n\r\nReducer.SetNameHandler += (\r\n ReducerEvent reducerEvent,\r\n UInt64 userId,\r\n string name\r\n) => {\r\n if (reducerEvent.Status == ClientApi.Event.Types.Status.Committed) {\r\n Console.WriteLine($\"User with id {userId} set name to {name}\");\r\n } else if (reducerEvent.Status == ClientApi.Event.Types.Status.Failed) {\r\n Console.WriteLine(\r\n $\"User with id {userId} failed to set name to {name}:\"\r\n + reducerEvent.ErrMessage\r\n );\r\n } else if (reducerEvent.Status == ClientApi.Event.Types.Status.OutOfEnergy) {\r\n Console.WriteLine(\r\n $\"User with id {userId} failed to set name to {name}:\"\r\n + \"Invoker ran out of energy\"\r\n );\r\n }\r\n};\r\nReducer.SetName(USER_ID, NAME);\r\n```\r\n\r\n### Class `ReducerEvent`\r\n\r\n`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`.\r\n\r\nFor example, the example project shown in the Rust Module quickstart will generate the following (abridged) code.\r\n\r\n```cs\r\nnamespace SpacetimeDB.Types {\r\n\r\npublic enum ReducerType\r\n{\r\n /* A member for each reducer in the module, with names converted to PascalCase */\r\n None,\r\n SendMessage,\r\n SetName,\r\n}\r\npublic partial class SendMessageArgsStruct\r\n{\r\n /* A member for each argument of the reducer SendMessage, with names converted to PascalCase. */\r\n public string Text;\r\n}\r\npublic partial class SetNameArgsStruct\r\n{\r\n /* A member for each argument of the reducer SetName, with names converted to PascalCase. */\r\n public string Name;\r\n}\r\npublic partial class ReducerEvent : ReducerEventBase {\r\n // Which reducer was invoked\r\n public ReducerType Reducer { get; }\r\n // If event.Reducer == ReducerType.SendMessage, the arguments\r\n // sent to the SendMessage reducer. Otherwise, accesses will\r\n // throw a runtime error.\r\n public SendMessageArgsStruct SendMessageArgs { get; }\r\n // If event.Reducer == ReducerType.SetName, the arguments\r\n // passed to the SetName reducer. Otherwise, accesses will\r\n // throw a runtime error.\r\n public SetNameArgsStruct SetNameArgs { get; }\r\n\r\n /* Additional information, present on any ReducerEvent */\r\n // The name of the reducer.\r\n public string ReducerName { get; }\r\n // The timestamp of the reducer invocation inside the database.\r\n public ulong Timestamp { get; }\r\n // The identity of the client that invoked the reducer.\r\n public SpacetimeDB.Identity Identity { get; }\r\n // Whether the reducer succeeded, failed, or ran out of energy.\r\n public ClientApi.Event.Types.Status Status { get; }\r\n // If event.Status == Status.Failed, the error message returned from inside the module.\r\n public string ErrMessage { get; }\r\n}\r\n\r\n}\r\n```\r\n\r\n#### Enum `Status`\r\n\r\n```cs\r\nnamespace ClientApi {\r\npublic sealed partial class Event {\r\npublic static partial class Types {\r\n\r\npublic enum Status {\r\n Committed = 0,\r\n Failed = 1,\r\n OutOfEnergy = 2,\r\n}\r\n\r\n}\r\n}\r\n}\r\n```\r\n\r\nAn enum whose variants represent possible reducer completion statuses of a reducer invocation.\r\n\r\n##### Variant `Status.Committed`\r\n\r\nThe reducer finished successfully, and its row changes were committed to the database.\r\n\r\n##### Variant `Status.Failed`\r\n\r\nThe reducer failed, either by panicking or returning a `Err`.\r\n\r\n##### Variant `Status.OutOfEnergy`\r\n\r\nThe reducer was canceled because the module owner had insufficient energy to allow it to run to completion.\r\n\r\n## Identity management\r\n\r\n### Class `AuthToken`\r\n\r\nThe AuthToken helper class handles creating and saving SpacetimeDB identity tokens in the filesystem.\r\n\r\n#### Static Method `AuthToken.Init`\r\n\r\n```cs\r\nnamespace SpacetimeDB {\r\n\r\nclass AuthToken {\r\n public static void Init(\r\n string configFolder = \".spacetime_csharp_sdk\",\r\n string configFile = \"settings.ini\",\r\n string? configRoot = null\r\n );\r\n}\r\n\r\n}\r\n```\r\n\r\nCreates a file `$\"{configRoot}/{configFolder}/{configFile}\"` to store tokens.\r\nIf no arguments are passed, the default is `\"%HOME%/.spacetime_csharp_sdk/settings.ini\"`.\r\n\r\n| Argument | Type | Meaning |\r\n| -------------- | -------- | ---------------------------------------------------------------------------------- |\r\n| `configFolder` | `string` | The folder to store the config file in. Default is `\"spacetime_csharp_sdk\"`. |\r\n| `configFile` | `string` | The name of the config file. Default is `\"settings.ini\"`. |\r\n| `configRoot` | `string` | The root folder to store the config file in. Default is the user's home directory. |\r\n\r\n#### Static Property `AuthToken.Token`\r\n\r\n```cs\r\nnamespace SpacetimeDB {\r\n\r\nclass AuthToken {\r\n public static string? Token { get; }\r\n}\r\n\r\n}\r\n```\r\n\r\nThe auth token stored on the filesystem, if one exists.\r\n\r\n#### Static Method `AuthToken.SaveToken`\r\n\r\n```cs\r\nnamespace SpacetimeDB {\r\n\r\nclass AuthToken {\r\n public static void SaveToken(string token);\r\n}\r\n\r\n}\r\n```\r\n\r\nSave a token to the filesystem.\r\n\r\n### Class `Identity`\r\n\r\n```cs\r\nnamespace SpacetimeDB {\r\n\r\npublic struct Identity : IEquatable\r\n{\r\n public byte[] Bytes { get; }\r\n public static Identity From(byte[] bytes);\r\n public bool Equals(Identity other);\r\n public static bool operator ==(Identity a, Identity b);\r\n public static bool operator !=(Identity a, Identity b);\r\n}\r\n\r\n}\r\n```\r\n\r\nA unique public identifier for a client connected to a database.\r\n\r\nColumns 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.\r\n\r\n## Customizing logging\r\n\r\nThe 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.\r\n\r\nThis 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).\r\n\r\nOutside of unity, all you need to do is the following:\r\n\r\n```cs\r\nusing SpacetimeDB;\r\nusing SpacetimeDB.Types;\r\nSpacetimeDBClient.CreateInstance(new ConsoleLogger());\r\n```\r\n\r\n### Interface `ISpacetimeDBLogger`\r\n\r\n```cs\r\nnamespace SpacetimeDB\r\n{\r\n\r\npublic interface ISpacetimeDBLogger\r\n{\r\n void Log(string message);\r\n void LogError(string message);\r\n void LogWarning(string message);\r\n void LogException(Exception e);\r\n}\r\n\r\n}\r\n```\r\n\r\nThis 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.\r\n\r\n### Class `ConsoleLogger`\r\n\r\n```cs\r\nnamespace SpacetimeDB {\r\n\r\npublic class ConsoleLogger : ISpacetimeDBLogger {}\r\n\r\n}\r\n```\r\n\r\nAn `ISpacetimeDBLogger` implementation for regular .NET applications, using `Console.Write` when logs are received.\r\n\r\n### Class `UnityDebugLogger`\r\n\r\n```cs\r\nnamespace SpacetimeDB {\r\n\r\npublic class UnityDebugLogger : ISpacetimeDBLogger {}\r\n\r\n}\r\n```\r\n\r\nAn `ISpacetimeDBLogger` implementation for Unity, using the Unity `Debug.Log` api.\r\n", - "editUrl": "SDK%20Reference.md", - "jumpLinks": [ - { - "title": "The SpacetimeDB C# client SDK", - "route": "the-spacetimedb-c-client-sdk", - "depth": 1 - }, - { - "title": "Table of Contents", - "route": "table-of-contents", - "depth": 2 - }, - { - "title": "Install the SDK", - "route": "install-the-sdk", - "depth": 2 - }, - { - "title": "Using the `dotnet` CLI tool", - "route": "using-the-dotnet-cli-tool", - "depth": 3 - }, - { - "title": "Using Unity", - "route": "using-unity", - "depth": 3 - }, - { - "title": "Generate module bindings", - "route": "generate-module-bindings", - "depth": 2 - }, - { - "title": "Initialization", - "route": "initialization", - "depth": 2 - }, - { - "title": "Static Method `SpacetimeDBClient.CreateInstance`", - "route": "static-method-spacetimedbclient-createinstance-", - "depth": 3 - }, - { - "title": "Property `SpacetimeDBClient.instance`", - "route": "property-spacetimedbclient-instance-", - "depth": 3 - }, - { - "title": "Class `NetworkManager`", - "route": "class-networkmanager-", - "depth": 3 - }, - { - "title": "Method `SpacetimeDBClient.Connect`", - "route": "method-spacetimedbclient-connect-", - "depth": 3 - }, - { - "title": "Event `SpacetimeDBClient.onIdentityReceived`", - "route": "event-spacetimedbclient-onidentityreceived-", - "depth": 3 - }, - { - "title": "Event `SpacetimeDBClient.onConnect`", - "route": "event-spacetimedbclient-onconnect-", - "depth": 3 - }, - { - "title": "Subscribe to queries", - "route": "subscribe-to-queries", - "depth": 2 - }, - { - "title": "Method `SpacetimeDBClient.Subscribe`", - "route": "method-spacetimedbclient-subscribe-", - "depth": 3 - }, - { - "title": "Event `SpacetimeDBClient.onSubscriptionApplied`", - "route": "event-spacetimedbclient-onsubscriptionapplied-", - "depth": 3 - }, - { - "title": "View rows of subscribed tables", - "route": "view-rows-of-subscribed-tables", - "depth": 2 - }, - { - "title": "Class `{TABLE}`", - "route": "class-table-", - "depth": 3 - }, - { - "title": "Static Method `{TABLE}.Iter`", - "route": "static-method-table-iter-", - "depth": 4 - }, - { - "title": "Static Method `{TABLE}.FilterBy{COLUMN}`", - "route": "static-method-table-filterby-column-", - "depth": 4 - }, - { - "title": "Static Method `{TABLE}.Count`", - "route": "static-method-table-count-", - "depth": 4 - }, - { - "title": "Static Event `{TABLE}.OnInsert`", - "route": "static-event-table-oninsert-", - "depth": 4 - }, - { - "title": "Static Event `{TABLE}.OnBeforeDelete`", - "route": "static-event-table-onbeforedelete-", - "depth": 4 - }, - { - "title": "Static Event `{TABLE}.OnDelete`", - "route": "static-event-table-ondelete-", - "depth": 4 - }, - { - "title": "Static Event `{TABLE}.OnUpdate`", - "route": "static-event-table-onupdate-", - "depth": 4 - }, - { - "title": "Observe and invoke reducers", - "route": "observe-and-invoke-reducers", - "depth": 2 - }, - { - "title": "Class `Reducer`", - "route": "class-reducer-", - "depth": 3 - }, - { - "title": "Static Method `Reducer.{REDUCER}`", - "route": "static-method-reducer-reducer-", - "depth": 4 - }, - { - "title": "Static Event `Reducer.On{REDUCER}`", - "route": "static-event-reducer-on-reducer-", - "depth": 4 - }, - { - "title": "Class `ReducerEvent`", - "route": "class-reducerevent-", - "depth": 3 - }, - { - "title": "Enum `Status`", - "route": "enum-status-", - "depth": 4 - }, - { - "title": "Variant `Status.Committed`", - "route": "variant-status-committed-", - "depth": 5 - }, - { - "title": "Variant `Status.Failed`", - "route": "variant-status-failed-", - "depth": 5 - }, - { - "title": "Variant `Status.OutOfEnergy`", - "route": "variant-status-outofenergy-", - "depth": 5 - }, - { - "title": "Identity management", - "route": "identity-management", - "depth": 2 - }, - { - "title": "Class `AuthToken`", - "route": "class-authtoken-", - "depth": 3 - }, - { - "title": "Static Method `AuthToken.Init`", - "route": "static-method-authtoken-init-", - "depth": 4 - }, - { - "title": "Static Property `AuthToken.Token`", - "route": "static-property-authtoken-token-", - "depth": 4 - }, - { - "title": "Static Method `AuthToken.SaveToken`", - "route": "static-method-authtoken-savetoken-", - "depth": 4 - }, - { - "title": "Class `Identity`", - "route": "class-identity-", - "depth": 3 - }, - { - "title": "Customizing logging", - "route": "customizing-logging", - "depth": 2 - }, - { - "title": "Interface `ISpacetimeDBLogger`", - "route": "interface-ispacetimedblogger-", - "depth": 3 - }, - { - "title": "Class `ConsoleLogger`", - "route": "class-consolelogger-", - "depth": 3 - }, - { - "title": "Class `UnityDebugLogger`", - "route": "class-unitydebuglogger-", - "depth": 3 - } - ], - "pages": [] - } - ] - }, - { - "title": "Welcome to Client SDK Languages# SpacetimeDB Client SDKs Overview", - "identifier": "index", - "indexIdentifier": "index", - "content": "# Welcome to Client SDK Languages# SpacetimeDB Client SDKs Overview\r\n\r\nThe SpacetimeDB Client SDKs provide a comprehensive interface to interact with the SpacetimeDB server engine from various programming languages. Currently, SDKs are available for\r\n\r\n- [Rust](/docs/client-languages/rust/rust-sdk-reference) - [(Quickstart)](/docs/client-languages/rust/rust-sdk-quickstart-guide)\r\n- [C#](/docs/client-languages/csharp/csharp-sdk-reference) - [(Quickstart)](/docs/client-languages/csharp/csharp-sdk-quickstart-guide)\r\n- [TypeScript](/docs/client-languages/typescript/typescript-sdk-reference) - [(Quickstart)](client-languages/typescript/typescript-sdk-quickstart-guide)\r\n- [Python](/docs/client-languages/python/python-sdk-reference) - [(Quickstart)](/docs/python/python-sdk-quickstart-guide)\r\n\r\n## Key Features\r\n\r\nThe SpacetimeDB Client SDKs offer the following key functionalities:\r\n\r\n### Connection Management\r\n\r\nThe SDKs handle the process of connecting and disconnecting from the SpacetimeDB server, simplifying this process for the client applications.\r\n\r\n### Authentication\r\n\r\nThe SDKs support authentication using an auth token, allowing clients to securely establish a session with the SpacetimeDB server.\r\n\r\n### Local Database View\r\n\r\nEach client can define a local view of the database via a subscription consisting of a set of queries. This local view is maintained by the server and populated into a local cache on the client side.\r\n\r\n### Reducer Calls\r\n\r\nThe SDKs allow clients to call transactional functions (reducers) on the server.\r\n\r\n### Callback Registrations\r\n\r\nThe SpacetimeDB Client SDKs offer powerful callback functionality that allow clients to monitor changes in their local database view. These callbacks come in two forms:\r\n\r\n#### Connection and Subscription Callbacks\r\n\r\nClients 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.\r\n\r\n#### Row Update Callbacks\r\n\r\nClients can register callbacks that trigger when any row in their local cache is updated by the server. These callbacks contain information about the reducer that triggered the change. This feature enables clients to react to changes in data that they're interested in.\r\n\r\n#### Reducer Call Callbacks\r\n\r\nClients can also register callbacks that fire when a reducer call modifies something in the client's local view. This allows the client to know when a transactional function it has executed has had an effect on the data it cares about.\r\n\r\nAdditionally, when a client makes a reducer call that fails, the SDK triggers the registered reducer callback on the client that initiated the failed call with the error message that was returned from the server. This allows for appropriate error handling or user notifications.\r\n\r\n## Choosing a Language\r\n\r\nWhen selecting a language for your client application with SpacetimeDB, a variety of factors come into play. While the functionality of the SDKs remains consistent across different languages, the choice of language will often depend on the specific needs and context of your application. Here are a few considerations:\r\n\r\n### Team Expertise\r\n\r\nThe familiarity of your development team with a particular language can greatly influence your choice. You might want to choose a language that your team is most comfortable with to increase productivity and reduce development time.\r\n\r\n### Application Type\r\n\r\nDifferent 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.\r\n\r\n### Performance\r\n\r\nThe performance characteristics of the different languages can also be a factor. If your application is performance-critical, you might opt for Rust, known for its speed and memory efficiency.\r\n\r\n### Platform Support\r\n\r\nThe platform you're targeting can also influence your choice. For instance, if you're developing a game or a 3D application using the Unity engine, you'll want to choose the C# SDK, as Unity uses C# as its primary scripting language.\r\n\r\n### Ecosystem and Libraries\r\n\r\nEach language has its own ecosystem of libraries and tools that can help in developing your application. If there's a library in a particular language that you want to use, it may influence your choice.\r\n\r\nRemember, 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.\r\n\r\nYou 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.\r\n", - "hasPages": false, - "editUrl": "index.md", - "jumpLinks": [ - { - "title": "Welcome to Client SDK Languages# SpacetimeDB Client SDKs Overview", - "route": "welcome-to-client-sdk-languages-spacetimedb-client-sdks-overview", - "depth": 1 - }, - { - "title": "Key Features", - "route": "key-features", - "depth": 2 - }, - { - "title": "Connection Management", - "route": "connection-management", - "depth": 3 - }, - { - "title": "Authentication", - "route": "authentication", - "depth": 3 - }, - { - "title": "Local Database View", - "route": "local-database-view", - "depth": 3 - }, - { - "title": "Reducer Calls", - "route": "reducer-calls", - "depth": 3 - }, - { - "title": "Callback Registrations", - "route": "callback-registrations", - "depth": 3 - }, - { - "title": "Connection and Subscription Callbacks", - "route": "connection-and-subscription-callbacks", - "depth": 4 - }, - { - "title": "Row Update Callbacks", - "route": "row-update-callbacks", - "depth": 4 - }, - { - "title": "Reducer Call Callbacks", - "route": "reducer-call-callbacks", - "depth": 4 - }, - { - "title": "Choosing a Language", - "route": "choosing-a-language", - "depth": 2 - }, - { - "title": "Team Expertise", - "route": "team-expertise", - "depth": 3 - }, - { - "title": "Application Type", - "route": "application-type", - "depth": 3 - }, - { - "title": "Performance", - "route": "performance", - "depth": 3 - }, - { - "title": "Platform Support", - "route": "platform-support", - "depth": 3 - }, - { - "title": "Ecosystem and Libraries", - "route": "ecosystem-and-libraries", - "depth": 3 - } - ], - "pages": [] - }, - { - "title": "Python", - "identifier": "Python", - "indexIdentifier": "index", - "comingSoon": false, - "hasPages": true, - "editUrl": "Python/index.md", - "jumpLinks": [], - "pages": [ - { - "title": "Python Client SDK Quick Start", - "identifier": "index", - "indexIdentifier": "index", - "content": "# Python Client SDK Quick Start\r\n\r\nIn this guide, we'll show you how to get up and running with a simple SpacetimDB app with a client written in Python.\r\n\r\nWe'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.\r\n\r\n## Install the SpacetimeDB SDK Python Package\r\n\r\n1. Run pip install\r\n\r\n```bash\r\npip install spacetimedb_sdk\r\n```\r\n\r\n## Project structure\r\n\r\nEnter the directory `quickstart-chat` you created in the Rust or C# Module Quickstart guides and create a `client` folder:\r\n\r\n```bash\r\ncd quickstart-chat\r\nmkdir client\r\n```\r\n\r\n## Create the Python main file\r\n\r\nCreate a file called `main.py` in the `client` and open it in your favorite editor. We prefer [VS Code](https://code.visualstudio.com/).\r\n\r\n## Add imports\r\n\r\nWe need to add several imports for this quickstart:\r\n\r\n- [`asyncio`](https://docs.python.org/3/library/asyncio.html) is required to run the async code in the SDK.\r\n- [`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.\r\n- [`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.\r\n\r\n- `spacetimedb_sdk.spacetimedb_async_client.SpacetimeDBAsyncClient` is the async wrapper around the SpacetimeDB client which we use to interact with our SpacetimeDB module.\r\n- `spacetimedb_sdk.local_config` is an optional helper module to load the auth token from local storage.\r\n\r\n```python\r\nimport asyncio\r\nfrom multiprocessing import Queue\r\nimport threading\r\n\r\nfrom spacetimedb_sdk.spacetimedb_async_client import SpacetimeDBAsyncClient\r\nimport spacetimedb_sdk.local_config as local_config\r\n```\r\n\r\n## Generate your module types\r\n\r\nThe `spacetime` CLI's `generate` command will generate client-side interfaces for the tables, reducers and types defined in your server module.\r\n\r\nIn your `client` directory, run:\r\n\r\n```bash\r\nmkdir -p module_bindings\r\nspacetime generate --lang python --out-dir src/module_bindings --project_path ../server\r\n```\r\n\r\nTake a look inside `client/module_bindings`. The CLI should have generated five files:\r\n\r\n```\r\nmodule_bindings\r\n+-- message.py\r\n+-- send_message_reducer.py\r\n+-- set_name_reducer.py\r\n+-- user.py\r\n```\r\n\r\nNow we import these types by adding the following lines to `main.py`:\r\n\r\n```python\r\nimport module_bindings\r\nfrom module_bindings.user import User\r\nfrom module_bindings.message import Message\r\nimport module_bindings.send_message_reducer as send_message_reducer\r\nimport module_bindings.set_name_reducer as set_name_reducer\r\n```\r\n\r\n## Global variables\r\n\r\nNext we will add our global `input_queue` and `local_identity` variables which we will explain later when they are used.\r\n\r\n```python\r\ninput_queue = Queue()\r\nlocal_identity = None\r\n```\r\n\r\n## Define main function\r\n\r\nWe'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:\r\n\r\n1. Init the optional local config module. The first parameter is the directory name to be created in the user home directory.\r\n1. Create our async SpacetimeDB client.\r\n1. Register our callbacks.\r\n1. Start the async client in a thread.\r\n1. Run a loop to read user input and send it to a repeating event in the async client.\r\n1. When the user exits, stop the async client and exit the program.\r\n\r\n```python\r\nif __name__ == \"__main__\":\r\n local_config.init(\".spacetimedb-python-quickstart\")\r\n\r\n spacetime_client = SpacetimeDBAsyncClient(module_bindings)\r\n\r\n register_callbacks(spacetime_client)\r\n\r\n thread = threading.Thread(target=run_client, args=(spacetime_client,))\r\n thread.start()\r\n\r\n input_loop()\r\n\r\n spacetime_client.force_close()\r\n thread.join()\r\n```\r\n\r\n## Register callbacks\r\n\r\nWe need to handle several sorts of events:\r\n\r\n1. OnSubscriptionApplied is a special callback that is executed when the local client cache is populated. We will talk more about this later.\r\n2. When a new user joins or a user is updated, we'll print an appropriate message.\r\n3. When we receive a new message, we'll print it.\r\n4. If the server rejects our attempt to set our name, we'll print an error.\r\n5. If the server rejects a message we send, we'll print an error.\r\n6. 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.\r\n\r\nBecause python requires functions to be defined before they're used, the following code must be added to `main.py` before main block:\r\n\r\n```python\r\ndef register_callbacks(spacetime_client):\r\n spacetime_client.client.register_on_subscription_applied(on_subscription_applied)\r\n\r\n User.register_row_update(on_user_row_update)\r\n Message.register_row_update(on_message_row_update)\r\n\r\n set_name_reducer.register_on_set_name(on_set_name_reducer)\r\n send_message_reducer.register_on_send_message(on_send_message_reducer)\r\n\r\n spacetime_client.schedule_event(0.1, check_commands)\r\n```\r\n\r\n### Handling User row updates\r\n\r\nFor 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`.\r\n\r\nThese callbacks can fire in two contexts:\r\n\r\n- After a reducer runs, when the client's cache is updated about changes to subscribed rows.\r\n- After calling `subscribe`, when the client's cache is initialized with all existing matching rows.\r\n\r\nThis 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.\r\n\r\nWe are also going to check for updates to the user row. This can happen for three reasons:\r\n\r\n1. They've set their name using the `set_name` reducer.\r\n2. They're an existing user re-connecting, so their `online` has been set to `true`.\r\n3. They've disconnected, so their `online` has been set to `false`.\r\n\r\nWe'll print an appropriate message in each of these cases.\r\n\r\n`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.\r\n\r\nWhenever 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.\r\n\r\nAdd these functions before the `register_callbacks` function:\r\n\r\n```python\r\ndef user_name_or_identity(user):\r\n if user.name:\r\n return user.name\r\n else:\r\n return (str(user.identity))[:8]\r\n\r\ndef on_user_row_update(row_op, user_old, user, reducer_event):\r\n if row_op == \"insert\":\r\n if user.online:\r\n print(f\"User {user_name_or_identity(user)} connected.\")\r\n elif row_op == \"update\":\r\n if user_old.online and not user.online:\r\n print(f\"User {user_name_or_identity(user)} disconnected.\")\r\n elif not user_old.online and user.online:\r\n print(f\"User {user_name_or_identity(user)} connected.\")\r\n\r\n if user_old.name != user.name:\r\n print(\r\n f\"User {user_name_or_identity(user_old)} renamed to {user_name_or_identity(user)}.\"\r\n )\r\n```\r\n\r\n### Print messages\r\n\r\nWhen 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.\r\n\r\nTo 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.\r\n\r\nWe'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`.\r\n\r\nAdd these functions before the `register_callbacks` function:\r\n\r\n```python\r\ndef on_message_row_update(row_op, message_old, message, reducer_event):\r\n if reducer_event is not None and row_op == \"insert\":\r\n print_message(message)\r\n\r\ndef print_message(message):\r\n user = User.filter_by_identity(message.sender)\r\n user_name = \"unknown\"\r\n if user is not None:\r\n user_name = user_name_or_identity(user)\r\n\r\n print(f\"{user_name}: {message.text}\")\r\n```\r\n\r\n### Warn if our name was rejected\r\n\r\nWe 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`.\r\n\r\nEach reducer callback takes three fixed arguments:\r\n\r\n1. The `Identity` of the client who requested the reducer invocation.\r\n2. The `Status` of the reducer run, one of `committed`, `failed` or `outofenergy`.\r\n3. The `Message` returned by the reducer in error cases, or `None` if the reducer succeeded.\r\n\r\nIt also takes a variable number of arguments which match the calling arguments of the reducer.\r\n\r\nThese callbacks will be invoked in one of two cases:\r\n\r\n1. If the reducer was successful and altered any of our subscribed rows.\r\n2. If we requested an invocation which failed.\r\n\r\nNote that a status of `failed` or `outofenergy` implies that the caller identity is our own identity.\r\n\r\nWe 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.\r\n\r\nWe'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.\r\n\r\nAdd this function before the `register_callbacks` function:\r\n\r\n```python\r\ndef on_set_name_reducer(sender, status, message, name):\r\n if sender == local_identity:\r\n if status == \"failed\":\r\n print(f\"Failed to set name: {message}\")\r\n```\r\n\r\n### Warn if our message was rejected\r\n\r\nWe handle warnings on rejected messages the same way as rejected names, though the types and the error message are different.\r\n\r\nAdd this function before the `register_callbacks` function:\r\n\r\n```python\r\ndef on_send_message_reducer(sender, status, message, msg):\r\n if sender == local_identity:\r\n if status == \"failed\":\r\n print(f\"Failed to send message: {message}\")\r\n```\r\n\r\n### OnSubscriptionApplied callback\r\n\r\nThis 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.\r\n\r\nIn 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.\r\n\r\nAdd these functions before the `register_callbacks` function:\r\n\r\n```python\r\ndef print_messages_in_order():\r\n all_messages = sorted(Message.iter(), key=lambda x: x.sent)\r\n for entry in all_messages:\r\n print(f\"{user_name_or_identity(User.filter_by_identity(entry.sender))}: {entry.text}\")\r\n\r\ndef on_subscription_applied():\r\n print(f\"\\nSYSTEM: Connected.\")\r\n print_messages_in_order()\r\n```\r\n\r\n### Check commands repeating event\r\n\r\nWe'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.\r\n\r\nIf 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.\r\n\r\nAdd these functions before the `register_callbacks` function:\r\n\r\n```python\r\ndef check_commands():\r\n global input_queue\r\n\r\n if not input_queue.empty():\r\n choice = input_queue.get()\r\n if choice[0] == \"name\":\r\n set_name_reducer.set_name(choice[1])\r\n else:\r\n send_message_reducer.send_message(choice[1])\r\n\r\n spacetime_client.schedule_event(0.1, check_commands)\r\n```\r\n\r\n### OnConnect callback\r\n\r\nThis 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.\r\n\r\nThe `on_connect` callback takes two arguments:\r\n\r\n1. The `Auth Token` is the equivalent of your private key. This is the only way to authenticate with the SpacetimeDB module as this user.\r\n2. 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.\r\n\r\nTo 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.\r\n\r\nThe `on_connect` callback is passed to the client connect function so it just needs to be defined before the `run_client` described next.\r\n\r\n```python\r\ndef on_connect(auth_token, identity):\r\n global local_identity\r\n local_identity = identity\r\n\r\n local_config.set_string(\"auth_token\", auth_token)\r\n```\r\n\r\n## Async client thread\r\n\r\nWe are going to write a function that starts the async client, which will be executed on a separate thread.\r\n\r\n```python\r\ndef run_client(spacetime_client):\r\n asyncio.run(\r\n spacetime_client.run(\r\n local_config.get_string(\"auth_token\"),\r\n \"localhost:3000\",\r\n \"chat\",\r\n False,\r\n on_connect,\r\n [\"SELECT * FROM User\", \"SELECT * FROM Message\"],\r\n )\r\n )\r\n```\r\n\r\n## Input loop\r\n\r\nFinally, we need a function to be executed on the main loop which listens for user input and adds it to the queue.\r\n\r\n```python\r\ndef input_loop():\r\n global input_queue\r\n\r\n while True:\r\n user_input = input()\r\n if len(user_input) == 0:\r\n return\r\n elif user_input.startswith(\"/name \"):\r\n input_queue.put((\"name\", user_input[6:]))\r\n else:\r\n input_queue.put((\"message\", user_input))\r\n```\r\n\r\n## Run the client\r\n\r\nMake 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.\r\n\r\nRun the client:\r\n\r\n```bash\r\npython main.py\r\n```\r\n\r\nIf 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.\r\n\r\n```bash\r\npython main.py --client 2\r\n```\r\n\r\n## Next steps\r\n\r\nCongratulations! 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.\r\n\r\nFor 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.\r\n", - "hasPages": false, - "editUrl": "index.md", - "jumpLinks": [ - { - "title": "Python Client SDK Quick Start", - "route": "python-client-sdk-quick-start", - "depth": 1 - }, - { - "title": "Install the SpacetimeDB SDK Python Package", - "route": "install-the-spacetimedb-sdk-python-package", - "depth": 2 - }, - { - "title": "Project structure", - "route": "project-structure", - "depth": 2 - }, - { - "title": "Create the Python main file", - "route": "create-the-python-main-file", - "depth": 2 - }, - { - "title": "Add imports", - "route": "add-imports", - "depth": 2 - }, - { - "title": "Generate your module types", - "route": "generate-your-module-types", - "depth": 2 - }, - { - "title": "Global variables", - "route": "global-variables", - "depth": 2 - }, - { - "title": "Define main function", - "route": "define-main-function", - "depth": 2 - }, - { - "title": "Register callbacks", - "route": "register-callbacks", - "depth": 2 - }, - { - "title": "Handling User row updates", - "route": "handling-user-row-updates", - "depth": 3 - }, - { - "title": "Print messages", - "route": "print-messages", - "depth": 3 - }, - { - "title": "Warn if our name was rejected", - "route": "warn-if-our-name-was-rejected", - "depth": 3 - }, - { - "title": "Warn if our message was rejected", - "route": "warn-if-our-message-was-rejected", - "depth": 3 - }, - { - "title": "OnSubscriptionApplied callback", - "route": "onsubscriptionapplied-callback", - "depth": 3 - }, - { - "title": "Check commands repeating event", - "route": "check-commands-repeating-event", - "depth": 3 - }, - { - "title": "OnConnect callback", - "route": "onconnect-callback", - "depth": 3 - }, - { - "title": "Async client thread", - "route": "async-client-thread", - "depth": 2 - }, - { - "title": "Input loop", - "route": "input-loop", - "depth": 2 - }, - { - "title": "Run the client", - "route": "run-the-client", - "depth": 2 - }, - { - "title": "Next steps", - "route": "next-steps", - "depth": 2 - } - ], - "pages": [] - }, - { - "title": "The SpacetimeDB Python client SDK", - "identifier": "SDK Reference", - "indexIdentifier": "SDK Reference", - "hasPages": false, - "content": "# The SpacetimeDB Python client SDK\r\n\r\nThe SpacetimeDB client SDK for Python contains all the tools you need to build native clients for SpacetimeDB modules using Python.\r\n\r\n## Install the SDK\r\n\r\nUse pip to install the SDK:\r\n\r\n```bash\r\npip install spacetimedb-sdk\r\n```\r\n\r\n## Generate module bindings\r\n\r\nEach 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:\r\n\r\n```bash\r\nmkdir -p module_bindings\r\nspacetime generate --lang python \\\r\n --out-dir module_bindings \\\r\n --project-path PATH-TO-MODULE-DIRECTORY\r\n```\r\n\r\nReplace `PATH-TO-MODULE-DIRECTORY` with the path to your SpacetimeDB module.\r\n\r\nImport your bindings in your client's code:\r\n\r\n```python\r\nimport module_bindings\r\n```\r\n\r\n## Basic vs Async SpacetimeDB Client\r\n\r\nThis SDK provides two different client modules for interacting with your SpacetimeDB module.\r\n\r\nThe 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.\r\n\r\nThe 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.\r\n\r\n## Common Client Reference\r\n\r\nThe following functions and types are used in both the Basic and Async clients.\r\n\r\n### API at a glance\r\n\r\n| Definition | Description |\r\n| ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |\r\n| Type [`Identity`](#type-identity) | A unique public identifier for a client. |\r\n| Type [`ReducerEvent`](#type-reducerevent) | `class` containing information about the reducer that triggered a row update event. |\r\n| Type [`module_bindings::{TABLE}`](#type-table) | Autogenerated `class` type for a table, holding one row. |\r\n| 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. |\r\n| Method [`module_bindings::{TABLE}::iter`](#method-iter) | Autogenerated method to iterate over all subscribed rows. |\r\n| Method [`module_bindings::{TABLE}::register_row_update`](#method-register_row_update) | Autogenerated method to register a callback that fires when a row changes. |\r\n| Function [`module_bindings::{REDUCER_NAME}::{REDUCER_NAME}`](#function-reducer) | Autogenerated function to invoke a reducer. |\r\n| 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. |\r\n\r\n### Type `Identity`\r\n\r\n```python\r\nclass Identity:\r\n @staticmethod\r\n def from_string(string)\r\n\r\n @staticmethod\r\n def from_bytes(data)\r\n\r\n def __str__(self)\r\n\r\n def __eq__(self, other)\r\n```\r\n\r\n| Member | Args | Meaning |\r\n| ------------- | ---------- | ------------------------------------ |\r\n| `from_string` | `str` | Create an Identity from a hex string |\r\n| `from_bytes` | `bytes` | Create an Identity from raw bytes |\r\n| `__str__` | `None` | Convert the Identity to a hex string |\r\n| `__eq__` | `Identity` | Compare two Identities for equality |\r\n\r\nA unique public identifier for a client connected to a database.\r\n\r\n### Type `ReducerEvent`\r\n\r\n```python\r\nclass ReducerEvent:\r\n def __init__(self, caller_identity, reducer_name, status, message, args):\r\n self.caller_identity = caller_identity\r\n self.reducer_name = reducer_name\r\n self.status = status\r\n self.message = message\r\n self.args = args\r\n```\r\n\r\n| Member | Args | Meaning |\r\n| ----------------- | ----------- | --------------------------------------------------------------------------- |\r\n| `caller_identity` | `Identity` | The identity of the user who invoked the reducer |\r\n| `reducer_name` | `str` | The name of the reducer that was invoked |\r\n| `status` | `str` | The status of the reducer invocation (\"committed\", \"failed\", \"outofenergy\") |\r\n| `message` | `str` | The message returned by the reducer if it fails |\r\n| `args` | `List[str]` | The arguments passed to the reducer |\r\n\r\nThis class contains the information about a reducer event to be passed to row update callbacks.\r\n\r\n### Type `{TABLE}`\r\n\r\n```python\r\nclass TABLE:\r\n\tis_table_class = True\r\n\r\n\tprimary_key = \"identity\"\r\n\r\n\t@classmethod\r\n\tdef register_row_update(cls, callback: Callable[[str,TABLE,TABLE,ReducerEvent], None])\r\n\r\n\t@classmethod\r\n\tdef iter(cls) -> Iterator[User]\r\n\r\n\t@classmethod\r\n\tdef filter_by_COLUMN_NAME(cls, COLUMN_VALUE) -> TABLE\r\n```\r\n\r\nThis class is autogenerated for each table in your module. It contains methods for filtering and iterating over subscribed rows.\r\n\r\n### Method `filter_by_{COLUMN}`\r\n\r\n```python\r\ndef filter_by_COLUMN(self, COLUMN_VALUE) -> TABLE\r\n```\r\n\r\n| Argument | Type | Meaning |\r\n| -------------- | ------------- | ---------------------- |\r\n| `column_value` | `COLUMN_TYPE` | The value to filter by |\r\n\r\nFor 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`.\r\n\r\nThe method's return type depends on the column's attributes:\r\n\r\n- 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).\r\n- For non-unique columns, the `filter_by` method returns an `Iterator` that can be used in a `for` loop.\r\n\r\n### Method `iter`\r\n\r\n```python\r\ndef iter(self) -> Iterator[TABLE]\r\n```\r\n\r\nIterate over all the subscribed rows in the table.\r\n\r\n### Method `register_row_update`\r\n\r\n```python\r\ndef register_row_update(self, callback: Callable[[str,TABLE,TABLE,ReducerEvent], None])\r\n```\r\n\r\n| Argument | Type | Meaning |\r\n| ---------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------ |\r\n| `callback` | `Callable[[str,TABLE,TABLE,ReducerEvent]` | Callback to be invoked when a row is updated (Args: row_op, old_value, new_value, reducer_event) |\r\n\r\nRegister a callback function to be executed when a row is updated. Callback arguments are:\r\n\r\n- `row_op`: The type of row update event. One of `\"insert\"`, `\"delete\"`, or `\"update\"`.\r\n- `old_value`: The previous value of the row, `None` if the row was inserted.\r\n- `new_value`: The new value of the row, `None` if the row was deleted.\r\n- `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.\r\n\r\n### Function `{REDUCER_NAME}`\r\n\r\n```python\r\ndef {REDUCER_NAME}(arg1, arg2)\r\n```\r\n\r\nThis 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.\r\n\r\n### Function `register_on_{REDUCER_NAME}`\r\n\r\n```python\r\ndef register_on_{REDUCER_NAME}(callback: Callable[[Identity, str, str, ARG1_TYPE, ARG1_TYPE], None])\r\n```\r\n\r\n| Argument | Type | Meaning |\r\n| ---------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- |\r\n| `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) |\r\n\r\nRegister a callback function to be executed when the reducer is invoked. Callback arguments are:\r\n\r\n- `caller_identity`: The identity of the user who invoked the reducer.\r\n- `status`: The status of the reducer invocation (\"committed\", \"failed\", \"outofenergy\").\r\n- `message`: The message returned by the reducer if it fails.\r\n- `args`: Variable number of arguments passed to the reducer.\r\n\r\n## Async Client Reference\r\n\r\n### API at a glance\r\n\r\n| Definition | Description |\r\n| ----------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |\r\n| Function [`SpacetimeDBAsyncClient::run`](#function-run) | Run the client. This function will not return until the client is closed. |\r\n| Function [`SpacetimeDBAsyncClient::subscribe`](#function-subscribe) | Subscribe to receive data and transaction updates for the provided queries. |\r\n| 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. |\r\n| Function [`SpacetimeDBAsyncClient::force_close`](#function-force_close) | Signal the client to stop processing events and close the connection to the server. |\r\n| Function [`SpacetimeDBAsyncClient::schedule_event`](#function-schedule_event) | Schedule an event to be fired after a delay |\r\n\r\n### Function `run`\r\n\r\n```python\r\nasync def run(\r\n self,\r\n auth_token,\r\n host,\r\n address_or_name,\r\n ssl_enabled,\r\n on_connect,\r\n subscription_queries=[],\r\n )\r\n```\r\n\r\nRun the client. This function will not return until the client is closed.\r\n\r\n| Argument | Type | Meaning |\r\n| ---------------------- | --------------------------------- | -------------------------------------------------------------- |\r\n| `auth_token` | `str` | Auth token to authenticate the user. (None if new user) |\r\n| `host` | `str` | Hostname of SpacetimeDB server |\r\n| `address_or_name` | `&str` | Name or address of the module. |\r\n| `ssl_enabled` | `bool` | Whether to use SSL when connecting to the server. |\r\n| `on_connect` | `Callable[[str, Identity], None]` | Callback to be invoked when the client connects to the server. |\r\n| `subscription_queries` | `List[str]` | List of queries to subscribe to. |\r\n\r\nIf `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.\r\n\r\nIf 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)\r\n\r\n```python\r\nasyncio.run(\r\n spacetime_client.run(\r\n AUTH_TOKEN,\r\n \"localhost:3000\",\r\n \"my-module-name\",\r\n False,\r\n on_connect,\r\n [\"SELECT * FROM User\", \"SELECT * FROM Message\"],\r\n )\r\n)\r\n```\r\n\r\n### Function `subscribe`\r\n\r\n```rust\r\ndef subscribe(self, queries: List[str])\r\n```\r\n\r\nSubscribe to a set of queries, to be notified when rows which match those queries are altered.\r\n\r\n| Argument | Type | Meaning |\r\n| --------- | ----------- | ---------------------------- |\r\n| `queries` | `List[str]` | SQL queries to subscribe to. |\r\n\r\nThe `queries` should be a slice of strings representing SQL queries.\r\n\r\nA 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`.\r\n\r\nThis should be called before the async client is started with [`run`](#function-run).\r\n\r\n```python\r\nspacetime_client.subscribe([\"SELECT * FROM User;\", \"SELECT * FROM Message;\"])\r\n```\r\n\r\nSubscribe to a set of queries, to be notified when rows which match those queries are altered.\r\n\r\n### Function `register_on_subscription_applied`\r\n\r\n```python\r\ndef register_on_subscription_applied(self, callback)\r\n```\r\n\r\nRegister a callback function to be executed when the local cache is updated as a result of a change to the subscription queries.\r\n\r\n| Argument | Type | Meaning |\r\n| ---------- | -------------------- | ------------------------------------------------------ |\r\n| `callback` | `Callable[[], None]` | Callback to be invoked when subscriptions are applied. |\r\n\r\nThe callback will be invoked after a successful [`subscribe`](#function-subscribe) call when the initial set of matching rows becomes available.\r\n\r\n```python\r\nspacetime_client.register_on_subscription_applied(on_subscription_applied)\r\n```\r\n\r\n### Function `force_close`\r\n\r\n```python\r\ndef force_close(self)\r\n)\r\n```\r\n\r\nSignal the client to stop processing events and close the connection to the server.\r\n\r\n```python\r\nspacetime_client.force_close()\r\n```\r\n\r\n### Function `schedule_event`\r\n\r\n```python\r\ndef schedule_event(self, delay_secs, callback, *args)\r\n```\r\n\r\nSchedule an event to be fired after a delay\r\n\r\nTo create a repeating event, call schedule_event() again from within the callback function.\r\n\r\n| Argument | Type | Meaning |\r\n| ------------ | -------------------- | -------------------------------------------------------------- |\r\n| `delay_secs` | `float` | number of seconds to wait before firing the event |\r\n| `callback` | `Callable[[], None]` | Callback to be invoked when the event fires. |\r\n| `args` | `*args` | Variable number of arguments to pass to the callback function. |\r\n\r\n```python\r\ndef application_tick():\r\n # ... do some work\r\n\r\n spacetime_client.schedule_event(0.1, application_tick)\r\n\r\nspacetime_client.schedule_event(0.1, application_tick)\r\n```\r\n\r\n## Basic Client Reference\r\n\r\n### API at a glance\r\n\r\n| Definition | Description |\r\n| ---------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |\r\n| Function [`SpacetimeDBClient::init`](#function-init) | Create a network manager instance. |\r\n| Function [`SpacetimeDBClient::subscribe`](#function-subscribe) | Subscribe to receive data and transaction updates for the provided queries. |\r\n| Function [`SpacetimeDBClient::register_on_event`](#function-register_on_event) | Register a callback function to handle transaction update events. |\r\n| Function [`SpacetimeDBClient::unregister_on_event`](#function-unregister_on_event) | Unregister a callback function that was previously registered using `register_on_event`. |\r\n| 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. |\r\n| Function [`SpacetimeDBClient::unregister_on_subscription_applied`](#function-unregister_on_subscription_applied) | Unregister a callback function from the subscription update event. |\r\n| Function [`SpacetimeDBClient::update`](#function-update) | Process all pending incoming messages from the SpacetimeDB module. |\r\n| Function [`SpacetimeDBClient::close`](#function-close) | Close the WebSocket connection. |\r\n| Type [`TransactionUpdateMessage`](#type-transactionupdatemessage) | Represents a transaction update message. |\r\n\r\n### Function `init`\r\n\r\n```python\r\n@classmethod\r\ndef init(\r\n auth_token: str,\r\n host: str,\r\n address_or_name: str,\r\n ssl_enabled: bool,\r\n autogen_package: module,\r\n on_connect: Callable[[], NoneType] = None,\r\n on_disconnect: Callable[[str], NoneType] = None,\r\n on_identity: Callable[[str, Identity], NoneType] = None,\r\n on_error: Callable[[str], NoneType] = None\r\n)\r\n```\r\n\r\nCreate a network manager instance.\r\n\r\n| Argument | Type | Meaning |\r\n| ----------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |\r\n| `auth_token` | `str` | This is the token generated by SpacetimeDB that matches the user's identity. If None, token will be generated |\r\n| `host` | `str` | Hostname:port for SpacetimeDB connection |\r\n| `address_or_name` | `str` | The name or address of the database to connect to |\r\n| `ssl_enabled` | `bool` | Whether to use SSL when connecting to the server. |\r\n| `autogen_package` | `ModuleType` | Python package where SpacetimeDB module generated files are located. |\r\n| `on_connect` | `Callable[[], None]` | Optional callback called when a connection is made to the SpacetimeDB module. |\r\n| `on_disconnect` | `Callable[[str], None]` | Optional callback called when the Python client is disconnected from the SpacetimeDB module. The argument is the close message. |\r\n| `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. |\r\n| `on_error` | `Callable[[str], None]` | Optional callback called when the Python client connection encounters an error. The argument is the error message. |\r\n\r\nThis 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.\r\n\r\n```python\r\nSpacetimeDBClient.init(autogen, on_connect=self.on_connect)\r\n```\r\n\r\n### Function `subscribe`\r\n\r\n```python\r\ndef subscribe(queries: List[str])\r\n```\r\n\r\nSubscribe to receive data and transaction updates for the provided queries.\r\n\r\n| Argument | Type | Meaning |\r\n| --------- | ----------- | -------------------------------------------------------------------------------------------------------- |\r\n| `queries` | `List[str]` | A list of queries to subscribe to. Each query is a string representing an sql formatted query statement. |\r\n\r\nThis 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.\r\n\r\n```python\r\nqueries = [\"SELECT * FROM table1\", \"SELECT * FROM table2 WHERE col2 = 0\"]\r\nSpacetimeDBClient.instance.subscribe(queries)\r\n```\r\n\r\n### Function `register_on_event`\r\n\r\n```python\r\ndef register_on_event(callback: Callable[[TransactionUpdateMessage], NoneType])\r\n```\r\n\r\nRegister a callback function to handle transaction update events.\r\n\r\n| Argument | Type | Meaning |\r\n| ---------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\r\n| `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. |\r\n\r\nThis 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.\r\n\r\n```python\r\ndef handle_event(transaction_update):\r\n # Code to handle the transaction update event\r\n\r\nSpacetimeDBClient.instance.register_on_event(handle_event)\r\n```\r\n\r\n### Function `unregister_on_event`\r\n\r\n```python\r\ndef unregister_on_event(callback: Callable[[TransactionUpdateMessage], NoneType])\r\n```\r\n\r\nUnregister a callback function that was previously registered using `register_on_event`.\r\n\r\n| Argument | Type | Meaning |\r\n| ---------- | -------------------------------------------- | ------------------------------------ |\r\n| `callback` | `Callable[[TransactionUpdateMessage], None]` | The callback function to unregister. |\r\n\r\n```python\r\nSpacetimeDBClient.instance.unregister_on_event(handle_event)\r\n```\r\n\r\n### Function `register_on_subscription_applied`\r\n\r\n```python\r\ndef register_on_subscription_applied(callback: Callable[[], NoneType])\r\n```\r\n\r\nRegister a callback function to be executed when the local cache is updated as a result of a change to the subscription queries.\r\n\r\n| Argument | Type | Meaning |\r\n| ---------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |\r\n| `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. |\r\n\r\n```python\r\ndef subscription_callback():\r\n # Code to be executed on each subscription update\r\n\r\nSpacetimeDBClient.instance.register_on_subscription_applied(subscription_callback)\r\n```\r\n\r\n### Function `unregister_on_subscription_applied`\r\n\r\n```python\r\ndef unregister_on_subscription_applied(callback: Callable[[], NoneType])\r\n```\r\n\r\nUnregister a callback function from the subscription update event.\r\n\r\n| Argument | Type | Meaning |\r\n| ---------- | -------------------- | -------------------------------------------------------------------------------------------------------- |\r\n| `callback` | `Callable[[], None]` | A callback function that was previously registered with the `register_on_subscription_applied` function. |\r\n\r\n```python\r\ndef subscription_callback():\r\n # Code to be executed on each subscription update\r\n\r\nSpacetimeDBClient.instance.register_on_subscription_applied(subscription_callback)\r\n```\r\n\r\n### Function `update`\r\n\r\n```python\r\ndef update()\r\n```\r\n\r\nProcess all pending incoming messages from the SpacetimeDB module.\r\n\r\nThis function must be called on a regular interval in the main loop to process incoming messages.\r\n\r\n```python\r\nwhile True:\r\n SpacetimeDBClient.instance.update() # Call the update function in a loop to process incoming messages\r\n # Additional logic or code can be added here\r\n```\r\n\r\n### Function `close`\r\n\r\n```python\r\ndef close()\r\n```\r\n\r\nClose the WebSocket connection.\r\n\r\nThis function closes the WebSocket connection to the SpacetimeDB module.\r\n\r\n```python\r\nSpacetimeDBClient.instance.close()\r\n```\r\n\r\n### Type `TransactionUpdateMessage`\r\n\r\n```python\r\nclass TransactionUpdateMessage:\r\n def __init__(\r\n self,\r\n caller_identity: Identity,\r\n status: str,\r\n message: str,\r\n reducer_name: str,\r\n args: Dict\r\n )\r\n```\r\n\r\n| Member | Args | Meaning |\r\n| ----------------- | ---------- | ------------------------------------------------- |\r\n| `caller_identity` | `Identity` | The identity of the caller. |\r\n| `status` | `str` | The status of the transaction. |\r\n| `message` | `str` | A message associated with the transaction update. |\r\n| `reducer_name` | `str` | The reducer used for the transaction. |\r\n| `args` | `Dict` | Additional arguments for the transaction. |\r\n\r\nRepresents a transaction update message. Used in on_event callbacks.\r\n\r\nFor more details, see [`register_on_event`](#function-register_on_event).\r\n", - "editUrl": "SDK%20Reference.md", - "jumpLinks": [ - { - "title": "The SpacetimeDB Python client SDK", - "route": "the-spacetimedb-python-client-sdk", - "depth": 1 - }, - { - "title": "Install the SDK", - "route": "install-the-sdk", - "depth": 2 - }, - { - "title": "Generate module bindings", - "route": "generate-module-bindings", - "depth": 2 - }, - { - "title": "Basic vs Async SpacetimeDB Client", - "route": "basic-vs-async-spacetimedb-client", - "depth": 2 - }, - { - "title": "Common Client Reference", - "route": "common-client-reference", - "depth": 2 - }, - { - "title": "API at a glance", - "route": "api-at-a-glance", - "depth": 3 - }, - { - "title": "Type `Identity`", - "route": "type-identity-", - "depth": 3 - }, - { - "title": "Type `ReducerEvent`", - "route": "type-reducerevent-", - "depth": 3 - }, - { - "title": "Type `{TABLE}`", - "route": "type-table-", - "depth": 3 - }, - { - "title": "Method `filter_by_{COLUMN}`", - "route": "method-filter_by_-column-", - "depth": 3 - }, - { - "title": "Method `iter`", - "route": "method-iter-", - "depth": 3 - }, - { - "title": "Method `register_row_update`", - "route": "method-register_row_update-", - "depth": 3 - }, - { - "title": "Function `{REDUCER_NAME}`", - "route": "function-reducer_name-", - "depth": 3 - }, - { - "title": "Function `register_on_{REDUCER_NAME}`", - "route": "function-register_on_-reducer_name-", - "depth": 3 - }, - { - "title": "Async Client Reference", - "route": "async-client-reference", - "depth": 2 - }, - { - "title": "API at a glance", - "route": "api-at-a-glance", - "depth": 3 - }, - { - "title": "Function `run`", - "route": "function-run-", - "depth": 3 - }, - { - "title": "Function `subscribe`", - "route": "function-subscribe-", - "depth": 3 - }, - { - "title": "Function `register_on_subscription_applied`", - "route": "function-register_on_subscription_applied-", - "depth": 3 - }, - { - "title": "Function `force_close`", - "route": "function-force_close-", - "depth": 3 - }, - { - "title": "Function `schedule_event`", - "route": "function-schedule_event-", - "depth": 3 - }, - { - "title": "Basic Client Reference", - "route": "basic-client-reference", - "depth": 2 - }, - { - "title": "API at a glance", - "route": "api-at-a-glance", - "depth": 3 - }, - { - "title": "Function `init`", - "route": "function-init-", - "depth": 3 - }, - { - "title": "Function `subscribe`", - "route": "function-subscribe-", - "depth": 3 - }, - { - "title": "Function `register_on_event`", - "route": "function-register_on_event-", - "depth": 3 - }, - { - "title": "Function `unregister_on_event`", - "route": "function-unregister_on_event-", - "depth": 3 - }, - { - "title": "Function `register_on_subscription_applied`", - "route": "function-register_on_subscription_applied-", - "depth": 3 - }, - { - "title": "Function `unregister_on_subscription_applied`", - "route": "function-unregister_on_subscription_applied-", - "depth": 3 - }, - { - "title": "Function `update`", - "route": "function-update-", - "depth": 3 - }, - { - "title": "Function `close`", - "route": "function-close-", - "depth": 3 - }, - { - "title": "Type `TransactionUpdateMessage`", - "route": "type-transactionupdatemessage-", - "depth": 3 - } - ], - "pages": [] - } - ] - }, - { - "title": "Rust", - "identifier": "Rust", - "indexIdentifier": "index", - "comingSoon": false, - "hasPages": true, - "editUrl": "Rust/index.md", - "jumpLinks": [], - "pages": [ - { - "title": "Rust Client SDK Quick Start", - "identifier": "index", - "indexIdentifier": "index", - "content": "# Rust Client SDK Quick Start\r\n\r\nIn this guide we'll show you how to get up and running with a simple SpacetimDB app with a client written in Rust.\r\n\r\nWe'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.\r\n\r\n## Project structure\r\n\r\nEnter 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:\r\n\r\n```bash\r\ncd quickstart-chat\r\n```\r\n\r\nWithin it, create a `client` crate, our client application, which users run locally:\r\n\r\n```bash\r\ncargo new client\r\n```\r\n\r\n## Depend on `spacetimedb-sdk` and `hex`\r\n\r\n`client/Cargo.toml` should be initialized without any dependencies. We'll need two:\r\n\r\n- [`spacetimedb-sdk`](https://crates.io/crates/spacetimedb-sdk), which defines client-side interfaces for interacting with a remote SpacetimeDB module.\r\n- [`hex`](https://crates.io/crates/hex), which we'll use to print unnamed users' identities as hexadecimal strings.\r\n\r\nBelow the `[dependencies]` line in `client/Cargo.toml`, add:\r\n\r\n```toml\r\nspacetimedb-sdk = \"0.6\"\r\nhex = \"0.4\"\r\n```\r\n\r\nMake sure you depend on the same version of `spacetimedb-sdk` as is reported by the SpacetimeDB CLI tool's `spacetime version`!\r\n\r\n## Clear `client/src/main.rs`\r\n\r\n`client/src/main.rs` should be initialized with a trivial \"Hello world\" program. Clear it out so we can write our chat client.\r\n\r\nIn your `quickstart-chat` directory, run:\r\n\r\n```bash\r\nrm client/src/main.rs\r\ntouch client/src/main.rs\r\n```\r\n\r\n## Generate your module types\r\n\r\nThe `spacetime` CLI's `generate` command will generate client-side interfaces for the tables, reducers and types defined in your server module.\r\n\r\nIn your `quickstart-chat` directory, run:\r\n\r\n```bash\r\nmkdir -p client/src/module_bindings\r\nspacetime generate --lang rust --out-dir client/src/module_bindings --project-path server\r\n```\r\n\r\nTake a look inside `client/src/module_bindings`. The CLI should have generated five files:\r\n\r\n```\r\nmodule_bindings\r\n├── message.rs\r\n├── mod.rs\r\n├── send_message_reducer.rs\r\n├── set_name_reducer.rs\r\n└── user.rs\r\n```\r\n\r\nWe need to declare the module in our client crate, and we'll want to import its definitions.\r\n\r\nTo `client/src/main.rs`, add:\r\n\r\n```rust\r\nmod module_bindings;\r\nuse module_bindings::*;\r\n```\r\n\r\n## Add more imports\r\n\r\nWe'll need a whole boatload of imports from `spacetimedb_sdk`, which we'll describe when we use them.\r\n\r\nTo `client/src/main.rs`, add:\r\n\r\n```rust\r\nuse spacetimedb_sdk::{\r\n disconnect,\r\n identity::{load_credentials, once_on_connect, save_credentials, Credentials, Identity},\r\n on_disconnect, on_subscription_applied,\r\n reducer::Status,\r\n subscribe,\r\n table::{TableType, TableWithPrimaryKey},\r\n};\r\n```\r\n\r\n## Define main function\r\n\r\nWe'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:\r\n\r\n1. 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.\r\n2. Establish a connection to the database. This will involve authenticating with our credentials, if we're a returning user.\r\n3. Subscribe to receive updates on tables.\r\n4. Loop, processing user input from standard input. This will be how we enable users to set their names and send messages.\r\n5. Close our connection. This one is easy; we just call `spacetimedb_sdk::disconnect`.\r\n\r\nTo `client/src/main.rs`, add:\r\n\r\n```rust\r\nfn main() {\r\n register_callbacks();\r\n connect_to_db();\r\n subscribe_to_tables();\r\n user_input_loop();\r\n}\r\n```\r\n\r\n## Register callbacks\r\n\r\nWe need to handle several sorts of events:\r\n\r\n1. 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.\r\n2. When a new user joins, we'll print a message introducing them.\r\n3. When a user is updated, we'll print their new name, or declare their new online status.\r\n4. When we receive a new message, we'll print it.\r\n5. When we're informed of the backlog of past messages, we'll sort them and print them in order.\r\n6. If the server rejects our attempt to set our name, we'll print an error.\r\n7. If the server rejects a message we send, we'll print an error.\r\n8. When our connection ends, we'll print a note, then exit the process.\r\n\r\nTo `client/src/main.rs`, add:\r\n\r\n```rust\r\n/// Register all the callbacks our app will use to respond to database events.\r\nfn register_callbacks() {\r\n // When we receive our `Credentials`, save them to a file.\r\n once_on_connect(on_connected);\r\n\r\n // When a new user joins, print a notification.\r\n User::on_insert(on_user_inserted);\r\n\r\n // When a user's status changes, print a notification.\r\n User::on_update(on_user_updated);\r\n\r\n // When a new message is received, print it.\r\n Message::on_insert(on_message_inserted);\r\n\r\n // When we receive the message backlog, print it in timestamp order.\r\n on_subscription_applied(on_sub_applied);\r\n\r\n // When we fail to set our name, print a warning.\r\n on_set_name(on_name_set);\r\n\r\n // When we fail to send a message, print a warning.\r\n on_send_message(on_message_sent);\r\n\r\n // When our connection closes, inform the user and exit.\r\n on_disconnect(on_disconnected);\r\n}\r\n```\r\n\r\n### Save credentials\r\n\r\nEach client has a `Credentials`, which consists of two parts:\r\n\r\n- An `Identity`, a unique public identifier. We're using these to identify `User` rows.\r\n- A `Token`, a private key which SpacetimeDB uses to authenticate the client.\r\n\r\n`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.\r\n\r\nTo `client/src/main.rs`, add:\r\n\r\n```rust\r\n/// Our `on_connect` callback: save our credentials to a file.\r\nfn on_connected(creds: &Credentials) {\r\n if let Err(e) = save_credentials(CREDS_DIR, creds) {\r\n eprintln!(\"Failed to save credentials: {:?}\", e);\r\n }\r\n}\r\n\r\nconst CREDS_DIR: &str = \".spacetime_chat\";\r\n```\r\n\r\n### Notify about new users\r\n\r\nFor 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`.\r\n\r\nThese callbacks can fire in two contexts:\r\n\r\n- After a reducer runs, when the client's cache is updated about changes to subscribed rows.\r\n- After calling `subscribe`, when the client's cache is initialized with all existing matching rows.\r\n\r\nThis 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.\r\n\r\n`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.\r\n\r\nWhenever 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.\r\n\r\nTo `client/src/main.rs`, add:\r\n\r\n```rust\r\n/// Our `User::on_insert` callback:\r\n/// if the user is online, print a notification.\r\nfn on_user_inserted(user: &User, _: Option<&ReducerEvent>) {\r\n if user.online {\r\n println!(\"User {} connected.\", user_name_or_identity(user));\r\n }\r\n}\r\n\r\nfn user_name_or_identity(user: &User) -> String {\r\n user.name\r\n .clone()\r\n .unwrap_or_else(|| identity_leading_hex(&user.identity))\r\n}\r\n\r\nfn identity_leading_hex(id: &Identity) -> String {\r\n hex::encode(&id.bytes()[0..8])\r\n}\r\n```\r\n\r\n### Notify about updated users\r\n\r\nBecause 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.\r\n\r\n`on_update` callbacks take three arguments: the old row, the new row, and an `Option<&ReducerEvent>`.\r\n\r\nIn our module, users can be updated for three reasons:\r\n\r\n1. They've set their name using the `set_name` reducer.\r\n2. They're an existing user re-connecting, so their `online` has been set to `true`.\r\n3. They've disconnected, so their `online` has been set to `false`.\r\n\r\nWe'll print an appropriate message in each of these cases.\r\n\r\nTo `client/src/main.rs`, add:\r\n\r\n```rust\r\n/// Our `User::on_update` callback:\r\n/// print a notification about name and status changes.\r\nfn on_user_updated(old: &User, new: &User, _: Option<&ReducerEvent>) {\r\n if old.name != new.name {\r\n println!(\r\n \"User {} renamed to {}.\",\r\n user_name_or_identity(old),\r\n user_name_or_identity(new)\r\n );\r\n }\r\n if old.online && !new.online {\r\n println!(\"User {} disconnected.\", user_name_or_identity(new));\r\n }\r\n if !old.online && new.online {\r\n println!(\"User {} connected.\", user_name_or_identity(new));\r\n }\r\n}\r\n```\r\n\r\n### Print messages\r\n\r\nWhen 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.\r\n\r\nTo 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`.\r\n\r\nWe'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`.\r\n\r\nTo `client/src/main.rs`, add:\r\n\r\n```rust\r\n/// Our `Message::on_insert` callback: print new messages.\r\nfn on_message_inserted(message: &Message, reducer_event: Option<&ReducerEvent>) {\r\n if reducer_event.is_some() {\r\n print_message(message);\r\n }\r\n}\r\n\r\nfn print_message(message: &Message) {\r\n let sender = User::filter_by_identity(message.sender.clone())\r\n .map(|u| user_name_or_identity(&u))\r\n .unwrap_or_else(|| \"unknown\".to_string());\r\n println!(\"{}: {}\", sender, message.text);\r\n}\r\n```\r\n\r\n### Print past messages in order\r\n\r\nMessages 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.\r\n\r\nWe'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.\r\n\r\nTo `client/src/main.rs`, add:\r\n\r\n```rust\r\n/// Our `on_subscription_applied` callback:\r\n/// sort all past messages and print them in timestamp order.\r\nfn on_sub_applied() {\r\n let mut messages = Message::iter().collect::>();\r\n messages.sort_by_key(|m| m.sent);\r\n for message in messages {\r\n print_message(&message);\r\n }\r\n}\r\n```\r\n\r\n### Warn if our name was rejected\r\n\r\nWe 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`.\r\n\r\nEach reducer callback takes at least two arguments:\r\n\r\n1. The `Identity` of the client who requested the reducer invocation.\r\n2. 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`.\r\n\r\nIn addition, it takes a reference to each of the arguments passed to the reducer itself.\r\n\r\nThese callbacks will be invoked in one of two cases:\r\n\r\n1. If the reducer was successful and altered any of our subscribed rows.\r\n2. If we requested an invocation which failed.\r\n\r\nNote that a status of `Failed` or `OutOfEnergy` implies that the caller identity is our own identity.\r\n\r\nWe 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.\r\n\r\nTo `client/src/main.rs`, add:\r\n\r\n```rust\r\n/// Our `on_set_name` callback: print a warning if the reducer failed.\r\nfn on_name_set(_sender: &Identity, status: &Status, name: &String) {\r\n if let Status::Failed(err) = status {\r\n eprintln!(\"Failed to change name to {:?}: {}\", name, err);\r\n }\r\n}\r\n```\r\n\r\n### Warn if our message was rejected\r\n\r\nWe handle warnings on rejected messages the same way as rejected names, though the types and the error message are different.\r\n\r\nTo `client/src/main.rs`, add:\r\n\r\n```rust\r\n/// Our `on_send_message` callback: print a warning if the reducer failed.\r\nfn on_message_sent(_sender: &Identity, status: &Status, text: &String) {\r\n if let Status::Failed(err) = status {\r\n eprintln!(\"Failed to send message {:?}: {}\", text, err);\r\n }\r\n}\r\n```\r\n\r\n### Exit on disconnect\r\n\r\nWe 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.\r\n\r\nTo `client/src/main.rs`, add:\r\n\r\n```rust\r\n/// Our `on_disconnect` callback: print a note, then exit the process.\r\nfn on_disconnected() {\r\n eprintln!(\"Disconnected!\");\r\n std::process::exit(0)\r\n}\r\n```\r\n\r\n## Connect to the database\r\n\r\nNow 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.\r\n\r\n`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`.\r\n\r\nTo `client/src/main.rs`, add:\r\n\r\n```rust\r\n/// The URL of the SpacetimeDB instance hosting our chat module.\r\nconst SPACETIMEDB_URI: &str = \"http://localhost:3000\";\r\n\r\n/// The module name we chose when we published our module.\r\nconst DB_NAME: &str = \"\";\r\n\r\n/// Load credentials from a file and connect to the database.\r\nfn connect_to_db() {\r\n connect(\r\n SPACETIMEDB_URI,\r\n DB_NAME,\r\n load_credentials(CREDS_DIR).expect(\"Error reading stored credentials\"),\r\n )\r\n .expect(\"Failed to connect\");\r\n}\r\n```\r\n\r\n## Subscribe to queries\r\n\r\nSpacetimeDB 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.\r\n\r\nTo `client/src/main.rs`, add:\r\n\r\n```rust\r\n/// Register subscriptions for all rows of both tables.\r\nfn subscribe_to_tables() {\r\n subscribe(&[\"SELECT * FROM User;\", \"SELECT * FROM Message;\"]).unwrap();\r\n}\r\n```\r\n\r\n## Handle user input\r\n\r\nA 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.\r\n\r\n`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`.\r\n\r\nTo `client/src/main.rs`, add:\r\n\r\n```rust\r\n/// Read each line of standard input, and either set our name or send a message as appropriate.\r\nfn user_input_loop() {\r\n for line in std::io::stdin().lines() {\r\n let Ok(line) = line else {\r\n panic!(\"Failed to read from stdin.\");\r\n };\r\n if let Some(name) = line.strip_prefix(\"/name \") {\r\n set_name(name.to_string());\r\n } else {\r\n send_message(line);\r\n }\r\n }\r\n}\r\n```\r\n\r\n## Run it\r\n\r\nChange your directory to the client app, then compile and run it. From the `quickstart-chat` directory, run:\r\n\r\n```bash\r\ncd client\r\ncargo run\r\n```\r\n\r\nYou should see something like:\r\n\r\n```\r\nUser d9e25c51996dea2f connected.\r\n```\r\n\r\nNow try sending a message. Type `Hello, world!` and press enter. You should see something like:\r\n\r\n```\r\nd9e25c51996dea2f: Hello, world!\r\n```\r\n\r\nNext, set your name. Type `/name `, replacing `` with your name. You should see something like:\r\n\r\n```\r\nUser d9e25c51996dea2f renamed to .\r\n```\r\n\r\nThen send another message. Type `Hello after naming myself.` and press enter. You should see:\r\n\r\n```\r\n: Hello after naming myself.\r\n```\r\n\r\nNow, 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:\r\n\r\n```\r\nUser connected.\r\n: Hello, world!\r\n: Hello after naming myself.\r\n```\r\n\r\n## What's next?\r\n\r\nYou 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).\r\n\r\nCheck out the [Rust SDK Reference](/docs/client-languages/rust/rust-sdk-reference) for a more comprehensive view of the SpacetimeDB Rust SDK.\r\n\r\nOur 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).\r\n\r\nOnce 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.\r\n\r\nYou 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).\r\n\r\nOr, you could extend the module and the client together, perhaps:\r\n\r\n- Adding a `moderator: bool` flag to `User` and allowing moderators to time-out or ban naughty chatters.\r\n- Adding a message of the day which gets shown to users whenever they connect, or some rules which get shown only to new users.\r\n- Supporting separate rooms or channels which users can join or leave, and maybe even direct messages.\r\n- Allowing users to set their status, which could be displayed alongside their username.\r\n", - "hasPages": false, - "editUrl": "index.md", - "jumpLinks": [ - { - "title": "Rust Client SDK Quick Start", - "route": "rust-client-sdk-quick-start", - "depth": 1 - }, - { - "title": "Project structure", - "route": "project-structure", - "depth": 2 - }, - { - "title": "Depend on `spacetimedb-sdk` and `hex`", - "route": "depend-on-spacetimedb-sdk-and-hex-", - "depth": 2 - }, - { - "title": "Clear `client/src/main.rs`", - "route": "clear-client-src-main-rs-", - "depth": 2 - }, - { - "title": "Generate your module types", - "route": "generate-your-module-types", - "depth": 2 - }, - { - "title": "Add more imports", - "route": "add-more-imports", - "depth": 2 - }, - { - "title": "Define main function", - "route": "define-main-function", - "depth": 2 - }, - { - "title": "Register callbacks", - "route": "register-callbacks", - "depth": 2 - }, - { - "title": "Save credentials", - "route": "save-credentials", - "depth": 3 - }, - { - "title": "Notify about new users", - "route": "notify-about-new-users", - "depth": 3 - }, - { - "title": "Notify about updated users", - "route": "notify-about-updated-users", - "depth": 3 - }, - { - "title": "Print messages", - "route": "print-messages", - "depth": 3 - }, - { - "title": "Print past messages in order", - "route": "print-past-messages-in-order", - "depth": 3 - }, - { - "title": "Warn if our name was rejected", - "route": "warn-if-our-name-was-rejected", - "depth": 3 - }, - { - "title": "Warn if our message was rejected", - "route": "warn-if-our-message-was-rejected", - "depth": 3 - }, - { - "title": "Exit on disconnect", - "route": "exit-on-disconnect", - "depth": 3 - }, - { - "title": "Connect to the database", - "route": "connect-to-the-database", - "depth": 2 - }, - { - "title": "Subscribe to queries", - "route": "subscribe-to-queries", - "depth": 2 - }, - { - "title": "Handle user input", - "route": "handle-user-input", - "depth": 2 - }, - { - "title": "Run it", - "route": "run-it", - "depth": 2 - }, - { - "title": "What's next?", - "route": "what-s-next-", - "depth": 2 - } - ], - "pages": [] - }, - { - "title": "The SpacetimeDB Rust client SDK", - "identifier": "SDK Reference", - "indexIdentifier": "SDK Reference", - "hasPages": false, - "content": "# The SpacetimeDB Rust client SDK\r\n\r\nThe SpacetimeDB client SDK for Rust contains all the tools you need to build native clients for SpacetimeDB modules using Rust.\r\n\r\n## Install the SDK\r\n\r\nFirst, create a new project using `cargo new` and add the SpacetimeDB SDK to your dependencies:\r\n\r\n```bash\r\ncargo add spacetimedb\r\n```\r\n\r\n## Generate module bindings\r\n\r\nEach SpacetimeDB client depends on some bindings specific to your module. Create a `module_bindings` directory in your project's `src` directory and generate the Rust interface files using the Spacetime CLI. From your project directory, run:\r\n\r\n```bash\r\nmkdir -p src/module_bindings\r\nspacetime generate --lang rust \\\r\n --out-dir src/module_bindings \\\r\n --project-path PATH-TO-MODULE-DIRECTORY\r\n```\r\n\r\nReplace `PATH-TO-MODULE-DIRECTORY` with the path to your SpacetimeDB module.\r\n\r\nDeclare a `mod` for the bindings in your client's `src/main.rs`:\r\n\r\n```rust\r\nmod module_bindings;\r\n```\r\n\r\n## API at a glance\r\n\r\n| Definition | Description |\r\n| ------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |\r\n| Function [`module_bindings::connect`](#function-connect) | Autogenerated function to connect to a database. |\r\n| Function [`spacetimedb_sdk::disconnect`](#function-disconnect) | Close the active connection. |\r\n| Function [`spacetimedb_sdk::on_disconnect`](#function-on_disconnect) | Register a `FnMut` callback to run when a connection ends. |\r\n| Function [`spacetimedb_sdk::once_on_disconnect`](#function-once_on_disconnect) | Register a `FnOnce` callback to run the next time a connection ends. |\r\n| Function [`spacetimedb_sdk::remove_on_disconnect`](#function-remove_on_disconnect) | Cancel an `on_disconnect` or `once_on_disconnect` callback. |\r\n| Function [`spacetimedb_sdk::subscribe`](#function-subscribe) | Subscribe to queries with a `&[&str]`. |\r\n| Function [`spacetimedb_sdk::subscribe_owned`](#function-subscribe_owned) | Subscribe to queries with a `Vec`. |\r\n| Function [`spacetimedb_sdk::on_subscription_applied`](#function-on_subscription_applied) | Register a `FnMut` callback to run when a subscription's initial rows become available. |\r\n| 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. |\r\n| Function [`spacetimedb_sdk::remove_on_subscription_applied`](#function-remove_on_subscription_applied) | Cancel an `on_subscription_applied` or `once_on_subscription_applied` callback. |\r\n| Type [`spacetimedb_sdk::identity::Identity`](#type-identity) | A unique public identifier for a client. |\r\n| Type [`spacetimedb_sdk::identity::Token`](#type-token) | A private authentication token corresponding to an `Identity`. |\r\n| Type [`spacetimedb_sdk::identity::Credentials`](#type-credentials) | An `Identity` paired with its `Token`. |\r\n| Function [`spacetimedb_sdk::identity::identity`](#function-identity) | Return the current connection's `Identity`. |\r\n| Function [`spacetimedb_sdk::identity::token`](#function-token) | Return the current connection's `Token`. |\r\n| Function [`spacetimedb_sdk::identity::credentials`](#function-credentials) | Return the current connection's [`Credentials`](#type-credentials). |\r\n| 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. |\r\n| 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. |\r\n| Function [`spacetimedb_sdk::identity::remove_on_connect`](#function-remove_on_connect) | Cancel an `on_connect` or `once_on_connect` callback. |\r\n| Function [`spacetimedb_sdk::identity::load_credentials`](#function-load_credentials) | Load a saved [`Credentials`](#type-credentials) from a file. |\r\n| Function [`spacetimedb_sdk::identity::save_credentials`](#function-save_credentials) | Save a [`Credentials`](#type-credentials) to a file. |\r\n| Type [`module_bindings::{TABLE}`](#type-table) | Autogenerated `struct` type for a table, holding one row. |\r\n| 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. |\r\n| Trait [`spacetimedb_sdk::table::TableType`](#trait-tabletype) | Automatically implemented for all tables defined by a module. |\r\n| Method [`spacetimedb_sdk::table::TableType::count`](#method-count) | Count the number of subscribed rows in a table. |\r\n| Method [`spacetimedb_sdk::table::TableType::iter`](#method-iter) | Iterate over all subscribed rows. |\r\n| Method [`spacetimedb_sdk::table::TableType::filter`](#method-filter) | Iterate over a subset of subscribed rows matching a predicate. |\r\n| Method [`spacetimedb_sdk::table::TableType::find`](#method-find) | Return one subscribed row matching a predicate. |\r\n| Method [`spacetimedb_sdk::table::TableType::on_insert`](#method-on_insert) | Register a `FnMut` callback to run whenever a new subscribed row is inserted. |\r\n| Method [`spacetimedb_sdk::table::TableType::remove_on_insert`](#method-remove_on_insert) | Cancel an `on_insert` callback. |\r\n| Method [`spacetimedb_sdk::table::TableType::on_delete`](#method-on_delete) | Register a `FnMut` callback to run whenever a subscribed row is deleted. |\r\n| Method [`spacetimedb_sdk::table::TableType::remove_on_delete`](#method-remove_on_delete) | Cancel an `on_delete` callback. |\r\n| Trait [`spacetimedb_sdk::table::TableWithPrimaryKey`](#trait-tablewithprimarykey) | Automatically implemented for tables with a column designated `#[primarykey]`. |\r\n| Method [`spacetimedb_sdk::table::TableWithPrimaryKey::on_update`](#method-on_update) | Register a `FnMut` callback to run whenever an existing subscribed row is updated. |\r\n| Method [`spacetimedb_sdk::table::TableWithPrimaryKey::remove_on_update`](#method-remove_on_update) | Cancel an `on_update` callback. |\r\n| Type [`module_bindings::ReducerEvent`](#type-reducerevent) | Autogenerated enum with a variant for each reducer defined by the module. |\r\n| Type [`module_bindings::{REDUCER}Args`](#type-reducerargs) | Autogenerated `struct` type for a reducer, holding its arguments. |\r\n| Function [`module_bindings::{REDUCER}`](#function-reducer) | Autogenerated function to invoke a reducer. |\r\n| Function [`module_bindings::on_{REDUCER}`](#function-on_reducer) | Autogenerated function to register a `FnMut` callback to run whenever the reducer is invoked. |\r\n| 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. |\r\n| Function [`module_bindings::remove_on_{REDUCER}`](#function-remove_on_reducer) | Autogenerated function to cancel an `on_{REDUCER}` or `once_on_{REDUCER}` callback. |\r\n| Type [`spacetimedb_sdk::reducer::Status`](#type-status) | Enum representing reducer completion statuses. |\r\n\r\n## Connect to a database\r\n\r\n### Function `connect`\r\n\r\n```rust\r\nmodule_bindings::connect(\r\n spacetimedb_uri: impl TryInto,\r\n db_name: &str,\r\n credentials: Option,\r\n) -> anyhow::Result<()>\r\n```\r\n\r\nConnect to a database named `db_name` accessible over the internet at the URI `spacetimedb_uri`.\r\n\r\n| Argument | Type | Meaning |\r\n| ----------------- | --------------------- | ------------------------------------------------------------ |\r\n| `spacetimedb_uri` | `impl TryInto` | URI of the SpacetimeDB instance running the module. |\r\n| `db_name` | `&str` | Name of the module. |\r\n| `credentials` | `Option` | [`Credentials`](#type-credentials) to authenticate the user. |\r\n\r\nIf `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.\r\n\r\n```rust\r\nconst MODULE_NAME: &str = \"my-module-name\";\r\n\r\n// Connect to a local DB with a fresh identity\r\nconnect(\"http://localhost:3000\", MODULE_NAME, None)\r\n .expect(\"Connection failed\");\r\n\r\n// Connect to cloud with a fresh identity.\r\nconnect(\"https://testnet.spacetimedb.com\", MODULE_NAME, None)\r\n .expect(\"Connection failed\");\r\n\r\n// Connect with a saved identity\r\nconst CREDENTIALS_DIR: &str = \".my-module\";\r\nconnect(\r\n \"https://testnet.spacetimedb.com\",\r\n MODULE_NAME,\r\n load_credentials(CREDENTIALS_DIR)\r\n .expect(\"Error while loading credentials\"),\r\n).expect(\"Connection failed\");\r\n```\r\n\r\n### Function `disconnect`\r\n\r\n```rust\r\nspacetimedb_sdk::disconnect()\r\n```\r\n\r\nGracefully close the current WebSocket connection.\r\n\r\nIf there is no active connection, this operation does nothing.\r\n\r\n```rust\r\nconnect(SPACETIMEDB_URI, MODULE_NAME, credentials)\r\n .expect(\"Connection failed\");\r\n\r\nrun_app();\r\n\r\ndisconnect();\r\n```\r\n\r\n### Function `on_disconnect`\r\n\r\n```rust\r\nspacetimedb_sdk::on_disconnect(\r\n callback: impl FnMut() + Send + 'static,\r\n) -> DisconnectCallbackId\r\n```\r\n\r\nRegister a callback to be invoked when a connection ends.\r\n\r\n| Argument | Type | Meaning |\r\n| ---------- | ------------------------------- | ------------------------------------------------------ |\r\n| `callback` | `impl FnMut() + Send + 'static` | Callback to be invoked when subscriptions are applied. |\r\n\r\nThe callback will be invoked after calling [`disconnect`](#function-disconnect), or when a connection is closed by the server.\r\n\r\nThe returned `DisconnectCallbackId` can be passed to [`remove_on_disconnect`](#function-remove_on_disconnect) to unregister the callback.\r\n\r\n```rust\r\non_disconnect(|| println!(\"Disconnected!\"));\r\n\r\nconnect(SPACETIMEDB_URI, MODULE_NAME, credentials)\r\n .expect(\"Connection failed\");\r\n\r\ndisconnect();\r\n\r\n// Will print \"Disconnected!\"\r\n```\r\n\r\n### Function `once_on_disconnect`\r\n\r\n```rust\r\nspacetimedb_sdk::once_on_disconnect(\r\n callback: impl FnOnce() + Send + 'static,\r\n) -> DisconnectCallbackId\r\n```\r\n\r\nRegister a callback to be invoked the next time a connection ends.\r\n\r\n| Argument | Type | Meaning |\r\n| ---------- | ------------------------------- | ------------------------------------------------------ |\r\n| `callback` | `impl FnMut() + Send + 'static` | Callback to be invoked when subscriptions are applied. |\r\n\r\nThe callback will be invoked after calling [`disconnect`](#function-disconnect), or when a connection is closed by the server.\r\n\r\nThe callback will be unregistered after running.\r\n\r\nThe returned `DisconnectCallbackId` can be passed to [`remove_on_disconnect`](#function-remove_on_disconnect) to unregister the callback.\r\n\r\n```rust\r\nonce_on_disconnect(|| println!(\"Disconnected!\"));\r\n\r\nconnect(SPACETIMEDB_URI, MODULE_NAME, credentials)\r\n .expect(\"Connection failed\");\r\n\r\ndisconnect();\r\n\r\n// Will print \"Disconnected!\"\r\n\r\nconnect(SPACETIMEDB_URI, MODULE_NAME, credentials)\r\n .expect(\"Connection failed\");\r\n\r\ndisconnect();\r\n\r\n// Nothing printed this time.\r\n```\r\n\r\n### Function `remove_on_disconnect`\r\n\r\n```rust\r\nspacetimedb_sdk::remove_on_disconnect(\r\n id: DisconnectCallbackId,\r\n)\r\n```\r\n\r\nUnregister a previously-registered [`on_disconnect`](#function-on_disconnect) callback.\r\n\r\n| Argument | Type | Meaning |\r\n| -------- | ---------------------- | ------------------------------------------ |\r\n| `id` | `DisconnectCallbackId` | Identifier for the callback to be removed. |\r\n\r\nIf `id` does not refer to a currently-registered callback, this operation does nothing.\r\n\r\n```rust\r\nlet id = on_disconnect(|| unreachable!());\r\n\r\nremove_on_disconnect(id);\r\n\r\ndisconnect();\r\n\r\n// No `unreachable` panic.\r\n```\r\n\r\n## Subscribe to queries\r\n\r\n### Function `subscribe`\r\n\r\n```rust\r\nspacetimedb_sdk::subscribe(queries: &[&str]) -> anyhow::Result<()>\r\n```\r\n\r\nSubscribe to a set of queries, to be notified when rows which match those queries are altered.\r\n\r\n| Argument | Type | Meaning |\r\n| --------- | --------- | ---------------------------- |\r\n| `queries` | `&[&str]` | SQL queries to subscribe to. |\r\n\r\nThe `queries` should be a slice of strings representing SQL queries.\r\n\r\n`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.\r\n\r\n`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.\r\n\r\nA 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.\r\n\r\n```rust\r\nsubscribe(&[\"SELECT * FROM User;\", \"SELECT * FROM Message;\"])\r\n .expect(\"Called `subscribe` before `connect`\");\r\n```\r\n\r\n### Function `subscribe_owned`\r\n\r\n```rust\r\nspacetimedb_sdk::subscribe_owned(queries: Vec) -> anyhow::Result<()>\r\n```\r\n\r\nSubscribe to a set of queries, to be notified when rows which match those queries are altered.\r\n\r\n| Argument | Type | Meaning |\r\n| --------- | ------------- | ---------------------------- |\r\n| `queries` | `Vec` | SQL queries to subscribe to. |\r\n\r\nThe `queries` should be a `Vec` of `String`s representing SQL queries.\r\n\r\nA new call to `subscribe_owned` (or [`subscribe`](#function-subscribe)) will remove all previous subscriptions and replace them with the new `queries`.\r\nIf 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.\r\n\r\n`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.\r\n\r\n```rust\r\nlet query = format!(\"SELECT * FROM User WHERE name = '{}';\", compute_my_name());\r\n\r\nsubscribe_owned(vec![query])\r\n .expect(\"Called `subscribe_owned` before `connect`\");\r\n```\r\n\r\n### Function `on_subscription_applied`\r\n\r\n```rust\r\nspacetimedb_sdk::on_subscription_applied(\r\n callback: impl FnMut() + Send + 'static,\r\n) -> SubscriptionCallbackId\r\n```\r\n\r\nRegister a callback to be invoked the first time a subscription's matching rows becoming available.\r\n\r\n| Argument | Type | Meaning |\r\n| ---------- | ------------------------------- | ------------------------------------------------------ |\r\n| `callback` | `impl FnMut() + Send + 'static` | Callback to be invoked when subscriptions are applied. |\r\n\r\nThe 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.\r\n\r\nThe returned `SubscriptionCallbackId` can be passed to [`remove_on_subscription_applied`](#function-remove_on_subscription_applied) to unregister the callback.\r\n\r\n```rust\r\non_subscription_applied(|| println!(\"Subscription applied!\"));\r\n\r\nsubscribe(&[\"SELECT * FROM User;\"])\r\n .expect(\"Called `subscribe` before `connect`\");\r\n\r\nsleep(Duration::from_secs(1));\r\n\r\n// Will print \"Subscription applied!\"\r\n\r\nsubscribe(&[\"SELECT * FROM User;\", \"SELECT * FROM Message;\"])\r\n .expect(\"Called `subscribe` before `connect`\");\r\n\r\n// Will print again.\r\n```\r\n\r\n### Function `once_on_subscription_applied`\r\n\r\n```rust\r\nspacetimedb_sdk::once_on_subscription_applied(\r\n callback: impl FnOnce() + Send + 'static,\r\n) -> SubscriptionCallbackId\r\n```\r\n\r\nRegister a callback to be invoked the next time a subscription's matching rows become available.\r\n\r\n| Argument | Type | Meaning |\r\n| ---------- | ------------------------------- | ------------------------------------------------------ |\r\n| `callback` | `impl FnMut() + Send + 'static` | Callback to be invoked when subscriptions are applied. |\r\n\r\nThe 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.\r\n\r\nThe callback will be unregistered after running.\r\n\r\nThe returned `SubscriptionCallbackId` can be passed to [`remove_on_subscription_applied`](#function-remove_on_subscription_applied) to unregister the callback.\r\n\r\n```rust\r\nonce_on_subscription_applied(|| println!(\"Subscription applied!\"));\r\n\r\nsubscribe(&[\"SELECT * FROM User;\"])\r\n .expect(\"Called `subscribe` before `connect`\");\r\n\r\nsleep(Duration::from_secs(1));\r\n\r\n// Will print \"Subscription applied!\"\r\n\r\nsubscribe(&[\"SELECT * FROM User;\", \"SELECT * FROM Message;\"])\r\n .expect(\"Called `subscribe` before `connect`\");\r\n\r\n// Nothing printed this time.\r\n```\r\n\r\n### Function `remove_on_subscription_applied`\r\n\r\n```rust\r\nspacetimedb_sdk::remove_on_subscription_applied(\r\n id: SubscriptionCallbackId,\r\n)\r\n```\r\n\r\nUnregister a previously-registered [`on_subscription_applied`](#function-on_subscription_applied) callback.\r\n\r\n| Argument | Type | Meaning |\r\n| -------- | ------------------------ | ------------------------------------------ |\r\n| `id` | `SubscriptionCallbackId` | Identifier for the callback to be removed. |\r\n\r\nIf `id` does not refer to a currently-registered callback, this operation does nothing.\r\n\r\n```rust\r\nlet id = on_subscription_applied(|| println!(\"Subscription applied!\"));\r\n\r\nsubscribe(&[\"SELECT * FROM User;\"])\r\n .expect(\"Called `subscribe` before `connect`\");\r\n\r\nsleep(Duration::from_secs(1));\r\n\r\n// Will print \"Subscription applied!\"\r\n\r\nremove_on_subscription_applied(id);\r\n\r\nsubscribe(&[\"SELECT * FROM User;\", \"SELECT * FROM Message;\"])\r\n .expect(\"Called `subscribe` before `connect`\");\r\n\r\n// Nothing printed this time.\r\n```\r\n\r\n## Identify a client\r\n\r\n### Type `Identity`\r\n\r\n```rust\r\nspacetimedb_sdk::identity::Identity\r\n```\r\n\r\nA unique public identifier for a client connected to a database.\r\n\r\n### Type `Token`\r\n\r\n```rust\r\nspacetimedb_sdk::identity::Token\r\n```\r\n\r\nA private access token for a client connected to a database.\r\n\r\n### Type `Credentials`\r\n\r\n```rust\r\nspacetimedb_sdk::identity::Credentials\r\n```\r\n\r\nCredentials, including a private access token, sufficient to authenticate a client connected to a database.\r\n\r\n| Field | Type |\r\n| ---------- | ---------------------------- |\r\n| `identity` | [`Identity`](#type-identity) |\r\n| `token` | [`Token`](#type-token) |\r\n\r\n### Function `identity`\r\n\r\n```rust\r\nspacetimedb_sdk::identity::identity() -> Result\r\n```\r\n\r\nRead the current connection's public [`Identity`](#type-identity).\r\n\r\nReturns an error if:\r\n\r\n- [`connect`](#function-connect) has not yet been called.\r\n- We connected anonymously, and we have not yet received our credentials.\r\n\r\n```rust\r\nconnect(SPACETIMEDB_URI, DB_NAME, None)\r\n .expect(\"Failed to connect\");\r\n\r\nsleep(Duration::from_secs(1));\r\n\r\nprintln!(\"My identity is {:?}\", identity());\r\n\r\n// Prints \"My identity is Ok(Identity { bytes: [...several u8s...] })\"\r\n```\r\n\r\n### Function `token`\r\n\r\n```rust\r\nspacetimedb_sdk::identity::token() -> Result\r\n```\r\n\r\nRead the current connection's private [`Token`](#type-token).\r\n\r\nReturns an error if:\r\n\r\n- [`connect`](#function-connect) has not yet been called.\r\n- We connected anonymously, and we have not yet received our credentials.\r\n\r\n```rust\r\nconnect(SPACETIMEDB_URI, DB_NAME, None)\r\n .expect(\"Failed to connect\");\r\n\r\nsleep(Duration::from_secs(1));\r\n\r\nprintln!(\"My token is {:?}\", token());\r\n\r\n// Prints \"My token is Ok(Token {string: \"...several Base64 digits...\" })\"\r\n```\r\n\r\n### Function `credentials`\r\n\r\n```rust\r\nspacetimedb_sdk::identity::credentials() -> Result\r\n```\r\n\r\nRead the current connection's [`Credentials`](#type-credentials), including a public [`Identity`](#type-identity) and a private [`Token`](#type-token).\r\n\r\nReturns an error if:\r\n\r\n- [`connect`](#function-connect) has not yet been called.\r\n- We connected anonymously, and we have not yet received our credentials.\r\n\r\n```rust\r\nconnect(SPACETIMEDB_URI, DB_NAME, None)\r\n .expect(\"Failed to connect\");\r\n\r\nsleep(Duration::from_secs(1));\r\n\r\nprintln!(\"My credentials are {:?}\", credentials());\r\n\r\n// Prints \"My credentials are Ok(Credentials {\r\n// identity: Identity { bytes: [...several u8s...] },\r\n// token: Token { string: \"...several Base64 digits...\"},\r\n// })\"\r\n```\r\n\r\n### Function `on_connect`\r\n\r\n```rust\r\nspacetimedb_sdk::identity::on_connect(\r\n callback: impl FnMut(&Credentials) + Send + 'static,\r\n) -> ConnectCallbackId\r\n```\r\n\r\nRegister a callback to be invoked upon authentication with the database.\r\n\r\n| Argument | Type | Meaning |\r\n| ---------- | ----------------------------------------- | ------------------------------------------------------ |\r\n| `callback` | `impl FnMut(&Credentials) + Send + 'sync` | Callback to be invoked upon successful authentication. |\r\n\r\nThe 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.\r\n\r\nThe [`Credentials`](#type-credentials) passed to the callback can be saved and used to authenticate the same user in future connections.\r\n\r\nThe returned `ConnectCallbackId` can be passed to [`remove_on_connect`](#function-remove_on_connect) to unregister the callback.\r\n\r\n```rust\r\non_connect(\r\n |creds| println!(\"Successfully connected! My credentials are: {:?}\", creds)\r\n);\r\n\r\nconnect(SPACETIMEDB_URI, DB_NAME, None)\r\n .expect(\"Failed to connect\");\r\n\r\nsleep(Duration::from_secs(1));\r\n\r\n// Will print \"Successfully connected! My credentials are: \"\r\n// followed by a printed representation of the client's `Credentials`.\r\n```\r\n\r\n### Function `once_on_connect`\r\n\r\n```rust\r\nspacetimedb_sdk::identity::once_on_connect(\r\n callback: impl FnOnce(&Credentials) + Send + 'static,\r\n) -> ConnectCallbackId\r\n```\r\n\r\nRegister a callback to be invoked once upon authentication with the database.\r\n\r\n| Argument | Type | Meaning |\r\n| ---------- | ------------------------------------------ | ---------------------------------------------------------------- |\r\n| `callback` | `impl FnOnce(&Credentials) + Send + 'sync` | Callback to be invoked once upon next successful authentication. |\r\n\r\nThe 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.\r\n\r\nThe [`Credentials`](#type-credentials) passed to the callback can be saved and used to authenticate the same user in future connections.\r\n\r\nThe callback will be unregistered after running.\r\n\r\nThe returned `ConnectCallbackId` can be passed to [`remove_on_connect`](#function-remove_on_connect) to unregister the callback.\r\n\r\n### Function `remove_on_connect`\r\n\r\n```rust\r\nspacetimedb_sdk::identity::remove_on_connect(id: ConnectCallbackId)\r\n```\r\n\r\nUnregister a previously-registered [`on_connect`](#function-on_connect) or [`once_on_connect`](#function-once_on_connect) callback.\r\n\r\n| Argument | Type | Meaning |\r\n| -------- | ------------------- | ------------------------------------------ |\r\n| `id` | `ConnectCallbackId` | Identifier for the callback to be removed. |\r\n\r\nIf `id` does not refer to a currently-registered callback, this operation does nothing.\r\n\r\n```rust\r\nlet id = on_connect(|_creds| unreachable!());\r\n\r\nremove_on_connect(id);\r\n\r\nconnect(SPACETIMEDB_URI, DB_NAME, None)\r\n .expect(\"Failed to connect\");\r\n\r\nsleep(Duration::from_secs(1));\r\n\r\n// No `unreachable` panic.\r\n```\r\n\r\n### Function `load_credentials`\r\n\r\n```rust\r\nspacetimedb_sdk::identity::load_credentials(\r\n dirname: &str,\r\n) -> Result>\r\n```\r\n\r\nLoad a saved [`Credentials`](#type-credentials) from a file within `~/dirname`, if one exists.\r\n\r\n| Argument | Type | Meaning |\r\n| --------- | ------ | ----------------------------------------------------- |\r\n| `dirname` | `&str` | Name of a sub-directory in the user's home directory. |\r\n\r\n`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.\r\n\r\nReturns `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).\r\n\r\n```rust\r\nconst CREDENTIALS_DIR = \".my-module\";\r\n\r\nlet creds = load_credentials(CREDENTIALS_DIR)\r\n .expect(\"Error while loading credentials\");\r\n\r\nconnect(SPACETIMEDB_URI, DB_NAME, creds)\r\n .expect(\"Failed to connect\");\r\n```\r\n\r\n### Function `save_credentials`\r\n\r\n```rust\r\nspacetimedb_sdk::identity::save_credentials(\r\n dirname: &str,\r\n credentials: &Credentials,\r\n) -> Result<()>\r\n```\r\n\r\nStore a [`Credentials`](#type-credentials) to a file within `~/dirname`, to be later loaded with [`load_credentials`](#function-load_credentials).\r\n\r\n| Argument | Type | Meaning |\r\n| ------------- | -------------- | ----------------------------------------------------- |\r\n| `dirname` | `&str` | Name of a sub-directory in the user's home directory. |\r\n| `credentials` | `&Credentials` | [`Credentials`](#type-credentials) to store. |\r\n\r\n`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.\r\n\r\nReturns `Err` when IO or serialization fails.\r\n\r\n```rust\r\nconst CREDENTIALS_DIR = \".my-module\";\r\n\r\nlet creds = load_credentials(CREDENTIALS_DIRectory)\r\n .expect(\"Error while loading credentials\");\r\n\r\non_connect(|creds| {\r\n if let Err(e) = save_credentials(CREDENTIALS_DIR, creds) {\r\n eprintln!(\"Error while saving credentials: {:?}\", e);\r\n }\r\n});\r\n\r\nconnect(SPACETIMEDB_URI, DB_NAME, creds)\r\n .expect(\"Failed to connect\");\r\n```\r\n\r\n## View subscribed rows of tables\r\n\r\n### Type `{TABLE}`\r\n\r\n```rust\r\nmodule_bindings::{TABLE}\r\n```\r\n\r\nFor 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`.\r\n\r\n### Method `filter_by_{COLUMN}`\r\n\r\n```rust\r\nmodule_bindings::{TABLE}::filter_by_{COLUMN}(\r\n value: {COLUMN_TYPE},\r\n) -> {FILTER_RESULT}<{TABLE}>\r\n```\r\n\r\nFor 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`.\r\n\r\nThe method's return type depends on the column's attributes:\r\n\r\n- 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).\r\n- For non-unique columns, the `filter_by` method returns an `impl Iterator`.\r\n\r\n### Trait `TableType`\r\n\r\n```rust\r\nspacetimedb_sdk::table::TableType\r\n```\r\n\r\nEvery [generated table struct](#type-table) implements the trait `TableType`.\r\n\r\n#### Method `count`\r\n\r\n```rust\r\nTableType::count() -> usize\r\n```\r\n\r\nReturn the number of subscribed rows in the table, or 0 if there is no active connection.\r\n\r\nThis method acquires a global lock.\r\n\r\n```rust\r\nconnect(SPACETIMEDB_URI, DB_NAME, None)\r\n .expect(\"Failed to connect\");\r\n\r\non_subscription_applied(|| println!(\"There are {} users\", User::count()));\r\n\r\nsubscribe(&[\"SELECT * FROM User;\"])\r\n .unwrap();\r\n\r\nsleep(Duration::from_secs(1));\r\n\r\n// Will the number of `User` rows in the database.\r\n```\r\n\r\n#### Method `iter`\r\n\r\n```rust\r\nTableType::iter() -> impl Iterator\r\n```\r\n\r\nIterate over all the subscribed rows in the table.\r\n\r\nThis method acquires a global lock, but the iterator does not hold it.\r\n\r\nThis 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.\r\n\r\n```rust\r\nconnect(SPACETIMEDB_URI, DB_NAME, None)\r\n .expect(\"Failed to connect\");\r\n\r\non_subscription_applied(|| for user in User::iter() {\r\n println!(\"{:?}\", user);\r\n});\r\n\r\nsubscribe(&[\"SELECT * FROM User;\"])\r\n .unwrap();\r\n\r\nsleep(Duration::from_secs(1));\r\n\r\n// Will print a line for each `User` row in the database.\r\n```\r\n\r\n#### Method `filter`\r\n\r\n```rust\r\nTableType::filter(\r\n predicate: impl FnMut(&Self) -> bool,\r\n) -> impl Iterator\r\n```\r\n\r\nIterate over the subscribed rows in the table for which `predicate` returns `true`.\r\n\r\n| Argument | Type | Meaning |\r\n| ----------- | --------------------------- | ------------------------------------------------------------------------------- |\r\n| `predicate` | `impl FnMut(&Self) -> bool` | Test which returns `true` if a row should be included in the filtered iterator. |\r\n\r\nThis method acquires a global lock, and the `predicate` runs while the lock is held. The returned iterator does not hold the lock.\r\n\r\nThe `predicate` is called eagerly for each subscribed row in the table, even if the returned iterator is never consumed.\r\n\r\nThis 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`.\r\n\r\nClient authors should prefer calling [tables' generated `filter_by_{COLUMN}` methods](#method-filter_by_column) when possible rather than calling `TableType::filter`.\r\n\r\n```rust\r\nconnect(SPACETIMEDB_URI, DB_NAME, None)\r\n .expect(\"Failed to connect\");\r\n\r\non_subscription_applied(|| {\r\n for user in User::filter(|user| user.age >= 30\r\n && user.country == Country::USA) {\r\n println!(\"{:?}\", user);\r\n }\r\n});\r\n\r\nsubscribe(&[\"SELECT * FROM User;\"])\r\n .unwrap();\r\n\r\nsleep(Duration::from_secs(1));\r\n\r\n// Will print a line for each `User` row in the database\r\n// who is at least 30 years old and who lives in the United States.\r\n```\r\n\r\n#### Method `find`\r\n\r\n```rust\r\nTableType::find(\r\n predicate: impl FnMut(&Self) -> bool,\r\n) -> Option\r\n```\r\n\r\nLocate a subscribed row for which `predicate` returns `true`, if one exists.\r\n\r\n| Argument | Type | Meaning |\r\n| ----------- | --------------------------- | ------------------------------------------------------ |\r\n| `predicate` | `impl FnMut(&Self) -> bool` | Test which returns `true` if a row should be returned. |\r\n\r\nThis method acquires a global lock.\r\n\r\nIf multiple subscribed rows match `predicate`, one is chosen arbitrarily. The choice may not be stable across different calls to `find` with the same `predicate`.\r\n\r\nClient authors should prefer calling [tables' generated `filter_by_{COLUMN}` methods](#method-filter_by_column) when possible rather than calling `TableType::find`.\r\n\r\n```rust\r\nconnect(SPACETIMEDB_URI, DB_NAME, None)\r\n .expect(\"Failed to connect\");\r\n\r\non_subscription_applied(|| {\r\n if let Some(tyler) = User::find(|user| user.first_name == \"Tyler\"\r\n && user.surname == \"Cloutier\") {\r\n println!(\"Found Tyler: {:?}\", tyler);\r\n } else {\r\n println!(\"Tyler isn't registered :(\");\r\n }\r\n});\r\n\r\nsubscribe(&[\"SELECT * FROM User;\"])\r\n .unwrap();\r\n\r\nsleep(Duration::from_secs(1));\r\n\r\n// Will tell us whether Tyler Cloutier is registered in the database.\r\n```\r\n\r\n#### Method `on_insert`\r\n\r\n```rust\r\nTableType::on_insert(\r\n callback: impl FnMut(&Self, Option<&ReducerEvent>) + Send + 'static,\r\n) -> InsertCallbackId\r\n```\r\n\r\nRegister an `on_insert` callback for when a subscribed row is newly inserted into the database.\r\n\r\n| Argument | Type | Meaning |\r\n| ---------- | ----------------------------------------------------------- | ------------------------------------------------------ |\r\n| `callback` | `impl FnMut(&Self, Option<&ReducerEvent>) + Send + 'static` | Callback to run whenever a subscribed row is inserted. |\r\n\r\nThe callback takes two arguments:\r\n\r\n- `row: &Self`, the newly-inserted row value.\r\n- `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.\r\n\r\nThe returned `InsertCallbackId` can be passed to [`remove_on_insert`](#method-remove_on_insert) to remove the callback.\r\n\r\n```rust\r\nconnect(SPACETIMEDB_URI, DB_NAME, None)\r\n .expect(\"Failed to connect\");\r\n\r\nUser::on_insert(|user, reducer_event| {\r\n if let Some(reducer_event) = reducer_event {\r\n println!(\"New user inserted by reducer {:?}: {:?}\", reducer_event, user);\r\n } else {\r\n println!(\"New user received during subscription update: {:?}\", user);\r\n }\r\n});\r\n\r\nsubscribe(&[\"SELECT * FROM User;\"])\r\n .unwrap();\r\n\r\nsleep(Duration::from_secs(1));\r\n\r\n// Will print a note whenever a new `User` row is inserted.\r\n```\r\n\r\n#### Method `remove_on_insert`\r\n\r\n```rust\r\nTableType::remove_on_insert(id: InsertCallbackId)\r\n```\r\n\r\nUnregister a previously-registered [`on_insert`](#method-on_insert) callback.\r\n\r\n| Argument | Type | Meaning |\r\n| -------- | ------------------------ | ----------------------------------------------------------------------- |\r\n| `id` | `InsertCallbackId` | Identifier for the [`on_insert`](#method-on_insert) callback to remove. |\r\n\r\nIf `id` does not refer to a currently-registered callback, this operation does nothing.\r\n\r\n```rust\r\nconnect(SPACETIMEDB_URI, DB_NAME, None)\r\n .expect(\"Failed to connect\");\r\n\r\nlet id = User::on_insert(|_, _| unreachable!());\r\n\r\nUser::remove_on_insert(id);\r\n\r\nsubscribe(&[\"SELECT * FROM User;\"])\r\n .unwrap();\r\n\r\nsleep(Duration::from_secs(1));\r\n\r\n// No `unreachable` panic.\r\n```\r\n\r\n#### Method `on_delete`\r\n\r\n```rust\r\nTableType::on_delete(\r\n callback: impl FnMut(&Self, Option<&ReducerEvent>) + Send + 'static,\r\n) -> DeleteCallbackId\r\n```\r\n\r\nRegister an `on_delete` callback for when a subscribed row is removed from the database.\r\n\r\n| Argument | Type | Meaning |\r\n| ---------- | ----------------------------------------------------------- | ----------------------------------------------------- |\r\n| `callback` | `impl FnMut(&Self, Option<&ReducerEvent>) + Send + 'static` | Callback to run whenever a subscribed row is deleted. |\r\n\r\nThe callback takes two arguments:\r\n\r\n- `row: &Self`, the previously-present row which is no longer resident in the database.\r\n- `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.\r\n\r\nThe returned `DeleteCallbackId` can be passed to [`remove_on_delete`](#method-remove_on_delete) to remove the callback.\r\n\r\n```rust\r\nconnect(SPACETIMEDB_URI, DB_NAME, None)\r\n .expect(\"Failed to connect\");\r\n\r\nUser::on_delete(|user, reducer_event| {\r\n if let Some(reducer_event) = reducer_event {\r\n println!(\"User deleted by reducer {:?}: {:?}\", reducer_event, user);\r\n } else {\r\n println!(\"User no longer subscribed during subscription update: {:?}\", user);\r\n }\r\n});\r\n\r\nsubscribe(&[\"SELECT * FROM User;\"])\r\n .unwrap();\r\n\r\n// Invoke a reducer which will delete a `User` row.\r\ndelete_user_by_name(\"Tyler Cloutier\".to_string());\r\n\r\nsleep(Duration::from_secs(1));\r\n\r\n// Will print a note whenever a `User` row is inserted,\r\n// including \"User deleted by reducer ReducerEvent::DeleteUserByName(\r\n// DeleteUserByNameArgs { name: \"Tyler Cloutier\" }\r\n// ): User { first_name: \"Tyler\", surname: \"Cloutier\" }\"\r\n```\r\n\r\n#### Method `remove_on_delete`\r\n\r\n```rust\r\nTableType::remove_on_delete(id: DeleteCallbackId)\r\n```\r\n\r\nUnregister a previously-registered [`on_delete`](#method-on_delete) callback.\r\n\r\n| Argument | Type | Meaning |\r\n| -------- | ------------------------ | ----------------------------------------------------------------------- |\r\n| `id` | `DeleteCallbackId` | Identifier for the [`on_delete`](#method-on_delete) callback to remove. |\r\n\r\nIf `id` does not refer to a currently-registered callback, this operation does nothing.\r\n\r\n```rust\r\nconnect(SPACETIMEDB_URI, DB_NAME, None)\r\n .expect(\"Failed to connect\");\r\n\r\nlet id = User::on_delete(|_, _| unreachable!());\r\n\r\nUser::remove_on_delete(id);\r\n\r\nsubscribe(&[\"SELECT * FROM User;\"])\r\n .unwrap();\r\n\r\n// Invoke a reducer which will delete a `User` row.\r\ndelete_user_by_name(\"Tyler Cloutier\".to_string());\r\n\r\nsleep(Duration::from_secs(1));\r\n\r\n// No `unreachable` panic.\r\n```\r\n\r\n### Trait `TableWithPrimaryKey`\r\n\r\n```rust\r\nspacetimedb_sdk::table::TableWithPrimaryKey\r\n```\r\n\r\n[Generated table structs](#type-table) with a column designated `#[primarykey]` implement the trait `TableWithPrimaryKey`.\r\n\r\n#### Method `on_update`\r\n\r\n```rust\r\nTableWithPrimaryKey::on_update(\r\n callback: impl FnMut(&Self, &Self, Option<&Self::ReducerEvent>) + Send + 'static,\r\n) -> UpdateCallbackId\r\n```\r\n\r\nRegister an `on_update` callback for when an existing row is modified.\r\n\r\n| Argument | Type | Meaning |\r\n| ---------- | ------------------------------------------------------------------ | ----------------------------------------------------- |\r\n| `callback` | `impl FnMut(&Self, &Self, Option<&ReducerEvent>) + Send + 'static` | Callback to run whenever a subscribed row is updated. |\r\n\r\nThe callback takes three arguments:\r\n\r\n- `old: &Self`, the previous row value which has been replaced in the database.\r\n- `new: &Self`, the updated row value which is now resident in the database.\r\n- `reducer_event: Option<&ReducerEvent>`, the [`ReducerEvent`](#type-reducerevent) which caused this row to be inserted.\r\n\r\nThe returned `UpdateCallbackId` can be passed to [`remove_on_update`](#method-remove_on_update) to remove the callback.\r\n\r\n```rust\r\nconnect(SPACETIMEDB_URI, DB_NAME, None)\r\n .expect(\"Failed to connect\");\r\n\r\nUser::on_update(|old, new, reducer_event| {\r\n println!(\"User updated by reducer {:?}: from {:?} to {:?}\", reducer_event, old, new);\r\n});\r\n\r\nsubscribe(&[\"SELECT * FROM User;\"])\r\n .unwrap();\r\n\r\n// Prints a line whenever a `User` row is updated by primary key.\r\n```\r\n\r\n#### Method `remove_on_update`\r\n\r\n```rust\r\nTableWithPrimaryKey::remove_on_update(id: UpdateCallbackId)\r\n```\r\n\r\n| Argument | Type | Meaning |\r\n| -------- | ------------------------ | ----------------------------------------------------------------------- |\r\n| `id` | `UpdateCallbackId` | Identifier for the [`on_update`](#method-on_update) callback to remove. |\r\n\r\nUnregister a previously-registered [`on_update`](#method-on_update) callback.\r\n\r\nIf `id` does not refer to a currently-registered callback, this operation does nothing.\r\n\r\n```rust\r\nconnect(SPACETIMEDB_URI, DB_NAME, None)\r\n .expect(\"Failed to connect\");\r\n\r\nlet id = User::on_update(|_, _, _| unreachable!);\r\n\r\nUser::remove_on_update(id);\r\n\r\nsubscribe(&[\"SELECT * FROM User;\"])\r\n .unwrap();\r\n\r\n// No `unreachable` panic.\r\n```\r\n\r\n## Observe and request reducer invocations\r\n\r\n### Type `ReducerEvent`\r\n\r\n```rust\r\nmodule_bindings::ReducerEvent\r\n```\r\n\r\n`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).\r\n\r\n[`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.\r\n\r\n### Type `{REDUCER}Args`\r\n\r\n```rust\r\nmodule_bindings::{REDUCER}Args\r\n```\r\n\r\nFor 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`.\r\n\r\nFor reducers which accept a `ReducerContext` as their first argument, the `ReducerContext` is not included in the arguments struct.\r\n\r\n### Function `{REDUCER}`\r\n\r\n```rust\r\nmodule_bindings::{REDUCER}({ARGS...})\r\n```\r\n\r\nFor 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`.\r\n\r\nFor reducers which accept a `ReducerContext` as their first argument, the `ReducerContext` is not included in the generated function's argument list.\r\n\r\n### Function `on_{REDUCER}`\r\n\r\n```rust\r\nmodule_bindings::on_{REDUCER}(\r\n callback: impl FnMut(&Identity, Status, {&ARGS...}) + Send + 'static,\r\n) -> ReducerCallbackId<{REDUCER}Args>\r\n```\r\n\r\nFor 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`.\r\n\r\n| Argument | Type | Meaning |\r\n| ---------- | ------------------------------------------------------------- | ------------------------------------------------ |\r\n| `callback` | `impl FnMut(&Identity, &Status, {&ARGS...}) + Send + 'static` | Callback to run whenever the reducer is invoked. |\r\n\r\nThe callback always accepts two arguments:\r\n\r\n- `caller: &Identity`, the [`Identity`](#type-identity) of the client which invoked the reducer.\r\n- `status: &Status`, the termination [`Status`](#type-status) of the reducer run.\r\n\r\nIn addition, the callback accepts a reference to each of the reducer's arguments.\r\n\r\nClients will only be notified of reducer runs if either of two criteria is met:\r\n\r\n- The reducer inserted, deleted or updated at least one row to which the client is subscribed.\r\n- The reducer invocation was requested by this client, and the run failed.\r\n\r\nThe `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.\r\n\r\n### Function `once_on_{REDUCER}`\r\n\r\n```rust\r\nmodule_bindings::once_on_{REDUCER}(\r\n callback: impl FnOnce(&Identity, &Status, {&ARGS...}) + Send + 'static,\r\n) -> ReducerCallbackId<{REDUCER}Args>\r\n```\r\n\r\nFor 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`.\r\n\r\n| Argument | Type | Meaning |\r\n| ---------- | -------------------------------------------------------------- | ----------------------------------------------------- |\r\n| `callback` | `impl FnOnce(&Identity, &Status, {&ARGS...}) + Send + 'static` | Callback to run the next time the reducer is invoked. |\r\n\r\nThe callback accepts the same arguments as an [on-reducer callback](#function-on_reducer), but may be a `FnOnce` rather than a `FnMut`.\r\n\r\nThe callback will be invoked in the same circumstances as an on-reducer callback.\r\n\r\nThe `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.\r\n\r\n### Function `remove_on_{REDUCER}`\r\n\r\n```rust\r\nmodule_bindings::remove_on_{REDUCER}(id: ReducerCallbackId<{REDUCER}Args>)\r\n```\r\n\r\nFor 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.\r\n\r\n| Argument | Type | Meaning |\r\n| -------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------- |\r\n| `id` | `UpdateCallbackId` | Identifier for the [`on_{REDUCER}`](#function-on_reducer) or [`once_on_{REDUCER}`](#function-once_on_reducer) callback to remove. |\r\n\r\nIf `id` does not refer to a currently-registered callback, this operation does nothing.\r\n\r\n### Type `Status`\r\n\r\n```rust\r\nspacetimedb_sdk::reducer::Status\r\n```\r\n\r\nAn enum whose variants represent possible reducer completion statuses.\r\n\r\nA `Status` is passed as the second argument to [`on_{REDUCER}`](#function-on_reducer) and [`once_on_{REDUCER}`](#function-once_on_reducer) callbacks.\r\n\r\n#### Variant `Status::Committed`\r\n\r\nThe reducer finished successfully, and its row changes were committed to the database.\r\n\r\n#### Variant `Status::Failed(String)`\r\n\r\nThe reducer failed, either by panicking or returning an `Err`.\r\n\r\n| Field | Type | Meaning |\r\n| ----- | -------- | --------------------------------------------------- |\r\n| 0 | `String` | The error message which caused the reducer to fail. |\r\n\r\n#### Variant `Status::OutOfEnergy`\r\n\r\nThe reducer was canceled because the module owner had insufficient energy to allow it to run to completion.\r\n", - "editUrl": "SDK%20Reference.md", - "jumpLinks": [ - { - "title": "The SpacetimeDB Rust client SDK", - "route": "the-spacetimedb-rust-client-sdk", - "depth": 1 - }, - { - "title": "Install the SDK", - "route": "install-the-sdk", - "depth": 2 - }, - { - "title": "Generate module bindings", - "route": "generate-module-bindings", - "depth": 2 - }, - { - "title": "API at a glance", - "route": "api-at-a-glance", - "depth": 2 - }, - { - "title": "Connect to a database", - "route": "connect-to-a-database", - "depth": 2 - }, - { - "title": "Function `connect`", - "route": "function-connect-", - "depth": 3 - }, - { - "title": "Function `disconnect`", - "route": "function-disconnect-", - "depth": 3 - }, - { - "title": "Function `on_disconnect`", - "route": "function-on_disconnect-", - "depth": 3 - }, - { - "title": "Function `once_on_disconnect`", - "route": "function-once_on_disconnect-", - "depth": 3 - }, - { - "title": "Function `remove_on_disconnect`", - "route": "function-remove_on_disconnect-", - "depth": 3 - }, - { - "title": "Subscribe to queries", - "route": "subscribe-to-queries", - "depth": 2 - }, - { - "title": "Function `subscribe`", - "route": "function-subscribe-", - "depth": 3 - }, - { - "title": "Function `subscribe_owned`", - "route": "function-subscribe_owned-", - "depth": 3 - }, - { - "title": "Function `on_subscription_applied`", - "route": "function-on_subscription_applied-", - "depth": 3 - }, - { - "title": "Function `once_on_subscription_applied`", - "route": "function-once_on_subscription_applied-", - "depth": 3 - }, - { - "title": "Function `remove_on_subscription_applied`", - "route": "function-remove_on_subscription_applied-", - "depth": 3 - }, - { - "title": "Identify a client", - "route": "identify-a-client", - "depth": 2 - }, - { - "title": "Type `Identity`", - "route": "type-identity-", - "depth": 3 - }, - { - "title": "Type `Token`", - "route": "type-token-", - "depth": 3 - }, - { - "title": "Type `Credentials`", - "route": "type-credentials-", - "depth": 3 - }, - { - "title": "Function `identity`", - "route": "function-identity-", - "depth": 3 - }, - { - "title": "Function `token`", - "route": "function-token-", - "depth": 3 - }, - { - "title": "Function `credentials`", - "route": "function-credentials-", - "depth": 3 - }, - { - "title": "Function `on_connect`", - "route": "function-on_connect-", - "depth": 3 - }, - { - "title": "Function `once_on_connect`", - "route": "function-once_on_connect-", - "depth": 3 - }, - { - "title": "Function `remove_on_connect`", - "route": "function-remove_on_connect-", - "depth": 3 - }, - { - "title": "Function `load_credentials`", - "route": "function-load_credentials-", - "depth": 3 - }, - { - "title": "Function `save_credentials`", - "route": "function-save_credentials-", - "depth": 3 - }, - { - "title": "View subscribed rows of tables", - "route": "view-subscribed-rows-of-tables", - "depth": 2 - }, - { - "title": "Type `{TABLE}`", - "route": "type-table-", - "depth": 3 - }, - { - "title": "Method `filter_by_{COLUMN}`", - "route": "method-filter_by_-column-", - "depth": 3 - }, - { - "title": "Trait `TableType`", - "route": "trait-tabletype-", - "depth": 3 - }, - { - "title": "Method `count`", - "route": "method-count-", - "depth": 4 - }, - { - "title": "Method `iter`", - "route": "method-iter-", - "depth": 4 - }, - { - "title": "Method `filter`", - "route": "method-filter-", - "depth": 4 - }, - { - "title": "Method `find`", - "route": "method-find-", - "depth": 4 - }, - { - "title": "Method `on_insert`", - "route": "method-on_insert-", - "depth": 4 - }, - { - "title": "Method `remove_on_insert`", - "route": "method-remove_on_insert-", - "depth": 4 - }, - { - "title": "Method `on_delete`", - "route": "method-on_delete-", - "depth": 4 - }, - { - "title": "Method `remove_on_delete`", - "route": "method-remove_on_delete-", - "depth": 4 - }, - { - "title": "Trait `TableWithPrimaryKey`", - "route": "trait-tablewithprimarykey-", - "depth": 3 - }, - { - "title": "Method `on_update`", - "route": "method-on_update-", - "depth": 4 - }, - { - "title": "Method `remove_on_update`", - "route": "method-remove_on_update-", - "depth": 4 - }, - { - "title": "Observe and request reducer invocations", - "route": "observe-and-request-reducer-invocations", - "depth": 2 - }, - { - "title": "Type `ReducerEvent`", - "route": "type-reducerevent-", - "depth": 3 - }, - { - "title": "Type `{REDUCER}Args`", - "route": "type-reducer-args-", - "depth": 3 - }, - { - "title": "Function `{REDUCER}`", - "route": "function-reducer-", - "depth": 3 - }, - { - "title": "Function `on_{REDUCER}`", - "route": "function-on_-reducer-", - "depth": 3 - }, - { - "title": "Function `once_on_{REDUCER}`", - "route": "function-once_on_-reducer-", - "depth": 3 - }, - { - "title": "Function `remove_on_{REDUCER}`", - "route": "function-remove_on_-reducer-", - "depth": 3 - }, - { - "title": "Type `Status`", - "route": "type-status-", - "depth": 3 - }, - { - "title": "Variant `Status::Committed`", - "route": "variant-status-committed-", - "depth": 4 - }, - { - "title": "Variant `Status::Failed(String)`", - "route": "variant-status-failed-string-", - "depth": 4 - }, - { - "title": "Variant `Status::OutOfEnergy`", - "route": "variant-status-outofenergy-", - "depth": 4 - } - ], - "pages": [] - } - ] - }, - { - "title": "Typescript", - "identifier": "Typescript", - "indexIdentifier": "index", - "comingSoon": false, - "hasPages": true, - "editUrl": "Typescript/index.md", - "jumpLinks": [], - "pages": [ - { - "title": "Typescript Client SDK Quick Start", - "identifier": "index", - "indexIdentifier": "index", - "content": "# Typescript Client SDK Quick Start\r\n\r\nIn this guide we'll show you how to get up and running with a simple SpacetimDB app with a client written in Typescript.\r\n\r\nWe'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.**\r\n\r\n## Project structure\r\n\r\nEnter 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:\r\n\r\n```bash\r\ncd quickstart-chat\r\n```\r\n\r\nWithin it, create a `client` react app:\r\n\r\n```bash\r\nnpx create-react-app client --template typescript\r\n```\r\n\r\nWe also need to install the `spacetime-client-sdk` package:\r\n\r\n```bash\r\ncd client\r\nnpm install @clockworklabs/spacetimedb-sdk\r\n```\r\n\r\n## Basic layout\r\n\r\nWe are going to start by creating a basic layout for our app. The page contains four sections:\r\n\r\n1. A profile section, where we can set our name.\r\n2. A message section, where we can see all the messages.\r\n3. A system section, where we can see system messages.\r\n4. A new message section, where we can send a new message.\r\n\r\nThe `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.\r\n\r\nReplace the entire contents of `client/src/App.tsx` with the following:\r\n\r\n```typescript\r\nimport React, { useEffect, useState } from \"react\";\r\nimport logo from \"./logo.svg\";\r\nimport \"./App.css\";\r\n\r\nexport type MessageType = {\r\n name: string;\r\n message: string;\r\n};\r\n\r\nfunction App() {\r\n const [newName, setNewName] = useState(\"\");\r\n const [settingName, setSettingName] = useState(false);\r\n const [name, setName] = useState(\"\");\r\n const [systemMessage, setSystemMessage] = useState(\"\");\r\n const [messages, setMessages] = useState([]);\r\n\r\n const [newMessage, setNewMessage] = useState(\"\");\r\n\r\n const onSubmitNewName = (e: React.FormEvent) => {\r\n e.preventDefault();\r\n setSettingName(false);\r\n // Fill in app logic here\r\n };\r\n\r\n const onMessageSubmit = (e: React.FormEvent) => {\r\n e.preventDefault();\r\n // Fill in app logic here\r\n setNewMessage(\"\");\r\n };\r\n\r\n return (\r\n
\r\n
\r\n

Profile

\r\n {!settingName ? (\r\n <>\r\n

{name}

\r\n {\r\n setSettingName(true);\r\n setNewName(name);\r\n }}\r\n >\r\n Edit Name\r\n \r\n \r\n ) : (\r\n
\r\n setNewName(e.target.value)}\r\n />\r\n \r\n \r\n )}\r\n
\r\n
\r\n

Messages

\r\n {messages.length < 1 &&

No messages

}\r\n
\r\n {messages.map((message, key) => (\r\n
\r\n

\r\n {message.name}\r\n

\r\n

{message.message}

\r\n
\r\n ))}\r\n
\r\n
\r\n
\r\n

System

\r\n
\r\n

{systemMessage}

\r\n
\r\n
\r\n
\r\n \r\n

New Message

\r\n setNewMessage(e.target.value)}\r\n >\r\n \r\n \r\n
\r\n
\r\n );\r\n}\r\n\r\nexport default App;\r\n```\r\n\r\nNow when you run `npm start`, you should see a basic chat app that does not yet send or receive messages.\r\n\r\n## Generate your module types\r\n\r\nThe `spacetime` CLI's `generate` command will generate client-side interfaces for the tables, reducers and types defined in your server module.\r\n\r\nIn your `quickstart-chat` directory, run:\r\n\r\n```bash\r\nmkdir -p client/src/module_bindings\r\nspacetime generate --lang typescript --out-dir client/src/module_bindings --project_path server\r\n```\r\n\r\nTake a look inside `client/src/module_bindings`. The CLI should have generated four files:\r\n\r\n```\r\nmodule_bindings\r\n├── message.ts\r\n├── send_message_reducer.ts\r\n├── set_name_reducer.ts\r\n└── user.ts\r\n```\r\n\r\nWe 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.\r\n\r\n> 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.\r\n\r\n```typescript\r\nimport { SpacetimeDBClient, Identity } from \"@clockworklabs/spacetimedb-sdk\";\r\n\r\nimport Message from \"./module_bindings/message\";\r\nimport User from \"./module_bindings/user\";\r\nimport SendMessageReducer from \"./module_bindings/send_message_reducer\";\r\nimport SetNameReducer from \"./module_bindings/set_name_reducer\";\r\nconsole.log(Message, User, SendMessageReducer, SetNameReducer);\r\n```\r\n\r\n## Create your SpacetimeDB client\r\n\r\nFirst, we need to create a SpacetimeDB client and connect to the module. Create your client at the top of the `App` function.\r\n\r\nWe 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.\r\n\r\nReplace `` 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`.\r\n\r\nAdd this before the `App` function declaration:\r\n\r\n```typescript\r\nlet token = localStorage.getItem(\"auth_token\") || undefined;\r\nvar spacetimeDBClient = new SpacetimeDBClient(\r\n \"ws://localhost:3000\",\r\n \"chat\",\r\n token\r\n);\r\n```\r\n\r\nInside the `App` function, add a few refs:\r\n\r\n```typescript\r\nlet local_identity = useRef(undefined);\r\nlet initialized = useRef(false);\r\nconst client = useRef(spacetimeDBClient);\r\n```\r\n\r\n## Register callbacks and connect\r\n\r\nWe need to handle several sorts of events:\r\n\r\n1. `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.\r\n2. `initialStateSync`: When we're informed of the backlog of past messages, we'll sort them and update the `message` section of the page.\r\n3. `Message.onInsert`: When we receive a new message, we'll update the `message` section of the page.\r\n4. `User.onInsert`: When a new user joins, we'll update the `system` section of the page with an appropiate message.\r\n5. `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.\r\n6. `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.\r\n7. `SendMessageReducer.on`: If the server rejects a message we send, we'll update the `system` section of the page with an appropriate error message.\r\n\r\nWe 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.\r\n\r\n### onConnect Callback\r\n\r\nOn connect SpacetimeDB will provide us with our client credentials.\r\n\r\nEach client has a credentials which consists of two parts:\r\n\r\n- An `Identity`, a unique public identifier. We're using these to identify `User` rows.\r\n- A `Token`, a private key which SpacetimeDB uses to authenticate the client.\r\n\r\nThese 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.\r\n\r\nWe want to store our local client identity in a stateful variable and also save our `token` to local storage for future connections.\r\n\r\nOnce 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.\r\n\r\nTo the body of `App`, add:\r\n\r\n```typescript\r\nclient.current.onConnect((token, identity) => {\r\n console.log(\"Connected to SpacetimeDB\");\r\n\r\n local_identity.current = identity;\r\n\r\n localStorage.setItem(\"auth_token\", token);\r\n\r\n client.current.subscribe([\"SELECT * FROM User\", \"SELECT * FROM Message\"]);\r\n});\r\n```\r\n\r\n### initialStateSync callback\r\n\r\nThis callback fires when our local client cache of the database is populated. This is a good time to set the initial messages list.\r\n\r\nWe'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`.\r\n\r\nTo 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.\r\n\r\nWhenever 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.\r\n\r\nWe 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`.\r\n\r\nTo the body of `App`, add:\r\n\r\n```typescript\r\nfunction userNameOrIdentity(user: User): string {\r\n console.log(`Name: ${user.name} `);\r\n if (user.name !== null) {\r\n return user.name || \"\";\r\n } else {\r\n var identityStr = new Identity(user.identity).toHexString();\r\n console.log(`Name: ${identityStr} `);\r\n return new Identity(user.identity).toHexString().substring(0, 8);\r\n }\r\n}\r\n\r\nfunction setAllMessagesInOrder() {\r\n let messages = Array.from(Message.all());\r\n messages.sort((a, b) => (a.sent > b.sent ? 1 : a.sent < b.sent ? -1 : 0));\r\n\r\n let messagesType: MessageType[] = messages.map((message) => {\r\n let sender_identity = User.filterByIdentity(message.sender);\r\n let display_name = sender_identity\r\n ? userNameOrIdentity(sender_identity)\r\n : \"unknown\";\r\n\r\n return {\r\n name: display_name,\r\n message: message.text,\r\n };\r\n });\r\n\r\n setMessages(messagesType);\r\n}\r\n\r\nclient.current.on(\"initialStateSync\", () => {\r\n setAllMessagesInOrder();\r\n var user = User.filterByIdentity(local_identity?.current?.toUint8Array()!);\r\n setName(userNameOrIdentity(user!));\r\n});\r\n```\r\n\r\n### Message.onInsert callback - Update messages\r\n\r\nWhen 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.\r\n\r\nTo the body of `App`, add:\r\n\r\n```typescript\r\nMessage.onInsert((message, reducerEvent) => {\r\n if (reducerEvent !== undefined) {\r\n setAllMessagesInOrder();\r\n }\r\n});\r\n```\r\n\r\n### User.onInsert callback - Notify about new users\r\n\r\nFor 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`.\r\n\r\nThese callbacks can fire in two contexts:\r\n\r\n- After a reducer runs, when the client's cache is updated about changes to subscribed rows.\r\n- After calling `subscribe`, when the client's cache is initialized with all existing matching rows.\r\n\r\nThis 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.\r\n\r\n`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.\r\n\r\nWe 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.\r\n\r\nTo the body of `App`, add:\r\n\r\n```typescript\r\n// Helper function to append a line to the systemMessage state\r\nfunction appendToSystemMessage(line: String) {\r\n setSystemMessage((prevMessage) => prevMessage + \"\\n\" + line);\r\n}\r\n\r\nUser.onInsert((user, reducerEvent) => {\r\n if (user.online) {\r\n appendToSystemMessage(`${userNameOrIdentity(user)} has connected.`);\r\n }\r\n});\r\n```\r\n\r\n### User.onUpdate callback - Notify about updated users\r\n\r\nBecause 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.\r\n\r\n`onUpdate` callbacks take three arguments: the old row, the new row, and a `ReducerEvent`.\r\n\r\nIn our module, users can be updated for three reasons:\r\n\r\n1. They've set their name using the `set_name` reducer.\r\n2. They're an existing user re-connecting, so their `online` has been set to `true`.\r\n3. They've disconnected, so their `online` has been set to `false`.\r\n\r\nWe'll update the `system` message in each of these cases.\r\n\r\nTo the body of `App`, add:\r\n\r\n```typescript\r\nUser.onUpdate((oldUser, user, reducerEvent) => {\r\n if (oldUser.online === false && user.online === true) {\r\n appendToSystemMessage(`${userNameOrIdentity(user)} has connected.`);\r\n } else if (oldUser.online === true && user.online === false) {\r\n appendToSystemMessage(`${userNameOrIdentity(user)} has disconnected.`);\r\n }\r\n\r\n if (user.name !== oldUser.name) {\r\n appendToSystemMessage(\r\n `User ${userNameOrIdentity(oldUser)} renamed to ${userNameOrIdentity(\r\n user\r\n )}.`\r\n );\r\n }\r\n});\r\n```\r\n\r\n### SetNameReducer.on callback - Handle errors and update profile name\r\n\r\nWe 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`.\r\n\r\nEach reducer callback takes two arguments:\r\n\r\n1. `ReducerEvent` that contains information about the reducer that triggered this event. It contains several fields. The ones we care about are:\r\n\r\n - `callerIdentity`: The `Identity` of the client that called the reducer.\r\n - `status`: The `Status` of the reducer run, one of `\"Committed\"`, `\"Failed\"` or `\"OutOfEnergy\"`.\r\n - `message`: The error message, if any, that the reducer returned.\r\n\r\n2. `ReducerArgs` which is an array containing the arguments with which the reducer was invoked.\r\n\r\nThese callbacks will be invoked in one of two cases:\r\n\r\n1. If the reducer was successful and altered any of our subscribed rows.\r\n2. If we requested an invocation which failed.\r\n\r\nNote that a status of `Failed` or `OutOfEnergy` implies that the caller identity is our own identity.\r\n\r\nWe 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.\r\n\r\nWe'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.\r\n\r\nIf the reducer status comes back as `committed`, we'll update the name in our app.\r\n\r\nTo the body of `App`, add:\r\n\r\n```typescript\r\nSetNameReducer.on((reducerEvent, reducerArgs) => {\r\n if (\r\n local_identity.current &&\r\n reducerEvent.callerIdentity.isEqual(local_identity.current)\r\n ) {\r\n if (reducerEvent.status === \"failed\") {\r\n appendToSystemMessage(`Error setting name: ${reducerEvent.message} `);\r\n } else if (reducerEvent.status === \"committed\") {\r\n setName(reducerArgs[0]);\r\n }\r\n }\r\n});\r\n```\r\n\r\n### SendMessageReducer.on callback - Handle errors\r\n\r\nWe 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.\r\n\r\nTo the body of `App`, add:\r\n\r\n```typescript\r\nSendMessageReducer.on((reducerEvent, reducerArgs) => {\r\n if (\r\n local_identity.current &&\r\n reducerEvent.callerIdentity.isEqual(local_identity.current)\r\n ) {\r\n if (reducerEvent.status === \"failed\") {\r\n appendToSystemMessage(`Error sending message: ${reducerEvent.message} `);\r\n }\r\n }\r\n});\r\n```\r\n\r\n## Update the UI button callbacks\r\n\r\nWe need to update the `onSubmitNewName` and `onMessageSubmit` callbacks to send the appropriate reducer to the module.\r\n\r\n`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`.\r\n\r\nAdd the following to the `onSubmitNewName` callback:\r\n\r\n```typescript\r\nSetNameReducer.call(newName);\r\n```\r\n\r\nAdd the following to the `onMessageSubmit` callback:\r\n\r\n```typescript\r\nSendMessageReducer.call(newMessage);\r\n```\r\n\r\n## Connecting to the module\r\n\r\nWe 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.\r\n\r\n```typescript\r\nuseEffect(() => {\r\n if (!initialized.current) {\r\n client.current.connect();\r\n initialized.current = true;\r\n }\r\n}, []);\r\n```\r\n\r\n## What's next?\r\n\r\nWhen 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.\r\n\r\nCongratulations! 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)\r\n\r\nFor 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).\r\n\r\n## Troubleshooting\r\n\r\nIf you encounter the following error:\r\n\r\n```\r\nTS2802: Type 'IterableIterator' can only be iterated through when using the '--downlevelIteration' flag or with a '--target' of 'es2015' or higher.\r\n```\r\n\r\nYou can fix it by changing your compiler target. Add the following to your `tsconfig.json` file:\r\n\r\n```json\r\n{\r\n \"compilerOptions\": {\r\n \"target\": \"es2015\"\r\n }\r\n}\r\n```\r\n", - "hasPages": false, - "editUrl": "index.md", - "jumpLinks": [ - { - "title": "Typescript Client SDK Quick Start", - "route": "typescript-client-sdk-quick-start", - "depth": 1 - }, - { - "title": "Project structure", - "route": "project-structure", - "depth": 2 - }, - { - "title": "Basic layout", - "route": "basic-layout", - "depth": 2 - }, - { - "title": "Generate your module types", - "route": "generate-your-module-types", - "depth": 2 - }, - { - "title": "Create your SpacetimeDB client", - "route": "create-your-spacetimedb-client", - "depth": 2 - }, - { - "title": "Register callbacks and connect", - "route": "register-callbacks-and-connect", - "depth": 2 - }, - { - "title": "onConnect Callback", - "route": "onconnect-callback", - "depth": 3 - }, - { - "title": "initialStateSync callback", - "route": "initialstatesync-callback", - "depth": 3 - }, - { - "title": "Message.onInsert callback - Update messages", - "route": "message-oninsert-callback-update-messages", - "depth": 3 - }, - { - "title": "User.onInsert callback - Notify about new users", - "route": "user-oninsert-callback-notify-about-new-users", - "depth": 3 - }, - { - "title": "User.onUpdate callback - Notify about updated users", - "route": "user-onupdate-callback-notify-about-updated-users", - "depth": 3 - }, - { - "title": "SetNameReducer.on callback - Handle errors and update profile name", - "route": "setnamereducer-on-callback-handle-errors-and-update-profile-name", - "depth": 3 - }, - { - "title": "SendMessageReducer.on callback - Handle errors", - "route": "sendmessagereducer-on-callback-handle-errors", - "depth": 3 - }, - { - "title": "Update the UI button callbacks", - "route": "update-the-ui-button-callbacks", - "depth": 2 - }, - { - "title": "Connecting to the module", - "route": "connecting-to-the-module", - "depth": 2 - }, - { - "title": "What's next?", - "route": "what-s-next-", - "depth": 2 - }, - { - "title": "Troubleshooting", - "route": "troubleshooting", - "depth": 2 - } - ], - "pages": [] - }, - { - "title": "The SpacetimeDB Typescript client SDK", - "identifier": "SDK Reference", - "indexIdentifier": "SDK Reference", - "hasPages": false, - "content": "# The SpacetimeDB Typescript client SDK\r\n\r\nThe 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.\r\n\r\n> You need a database created before use the client, so make sure to follow the Rust or C# Module Quickstart guides if need one.\r\n\r\n## Install the SDK\r\n\r\nFirst, create a new client project, and add the following to your `tsconfig.json` file:\r\n\r\n```json\r\n{\r\n \"compilerOptions\": {\r\n //You can use any target higher than this one\r\n //https://www.typescriptlang.org/tsconfig#target\r\n \"target\": \"es2015\"\r\n }\r\n}\r\n```\r\n\r\nThen add the SpacetimeDB SDK to your dependencies:\r\n\r\n```bash\r\ncd client\r\nnpm install @clockworklabs/spacetimedb-sdk\r\n```\r\n\r\nYou should have this folder layout starting from the root of your project:\r\n\r\n```bash\r\nquickstart-chat\r\n├── client\r\n│ ├── node_modules\r\n│ ├── public\r\n│ └── src\r\n└── server\r\n └── src\r\n```\r\n\r\n### Tip for utilities/scripts\r\n\r\nIf want to create a quick script to test your module bindings from the command line, you can use https://www.npmjs.com/package/tsx to execute TypeScript files.\r\n\r\nThen you create a `script.ts` file and add the imports, code and execute with:\r\n\r\n```bash\r\nnpx tsx src/script.ts\r\n```\r\n\r\n## Generate module bindings\r\n\r\nEach SpacetimeDB client depends on some bindings specific to your module. Create a `module_bindings` directory in your project's `src` directory and generate the Typescript interface files using the Spacetime CLI. From your project directory, run:\r\n\r\n```bash\r\nmkdir -p client/src/module_bindings\r\nspacetime generate --lang typescript \\\r\n --out-dir client/src/module_bindings \\\r\n --project-path server\r\n```\r\n\r\nAnd now you will get the files for the `reducers` & `tables`:\r\n\r\n```bash\r\nquickstart-chat\r\n├── client\r\n│ ├── node_modules\r\n│ ├── public\r\n│ └── src\r\n| └── module_bindings\r\n| ├── add_reducer.ts\r\n| ├── person.ts\r\n| └── say_hello_reducer.ts\r\n└── server\r\n └── src\r\n```\r\n\r\nImport the `module_bindings` in your client's _main_ file:\r\n\r\n```typescript\r\nimport { SpacetimeDBClient, Identity } from \"@clockworklabs/spacetimedb-sdk\";\r\n\r\nimport Person from \"./module_bindings/person\";\r\nimport AddReducer from \"./module_bindings/add_reducer\";\r\nimport SayHelloReducer from \"./module_bindings/say_hello_reducer\";\r\nconsole.log(Person, AddReducer, SayHelloReducer);\r\n```\r\n\r\n> 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.\r\n\r\n## API at a glance\r\n\r\n### Classes\r\n\r\n| Class | Description |\r\n| ----------------------------------------------- | ---------------------------------------------------------------- |\r\n| [`SpacetimeDBClient`](#class-spacetimedbclient) | The database client connection to a SpacetimeDB server. |\r\n| [`Identity`](#class-identity) | The user's public identity. |\r\n| [`{Table}`](#class-table) | `{Table}` is a placeholder for each of the generated tables. |\r\n| [`{Reducer}`](#class-reducer) | `{Reducer}` is a placeholder for each of the generated reducers. |\r\n\r\n### Class `SpacetimeDBClient`\r\n\r\nThe database client connection to a SpacetimeDB server.\r\n\r\nDefined in [spacetimedb-sdk.spacetimedb](https://github.com/clockworklabs/spacetimedb-typescript-sdk/blob/main/src/spacetimedb.ts):\r\n\r\n| Constructors | Description |\r\n| ----------------------------------------------------------------- | ------------------------------------------------------------------------ |\r\n| [`SpacetimeDBClient.constructor`](#spacetimedbclient-constructor) | Creates a new `SpacetimeDBClient` database client. |\r\n| Properties |\r\n| [`SpacetimeDBClient.identity`](#spacetimedbclient-identity) | The user's public identity. |\r\n| [`SpacetimeDBClient.live`](#spacetimedbclient-live) | Whether the client is connected. |\r\n| [`SpacetimeDBClient.token`](#spacetimedbclient-token) | The user's private authentication token. |\r\n| Methods | |\r\n| [`SpacetimeDBClient.connect`](#spacetimedbclient-connect) | Connect to a SpacetimeDB module. |\r\n| [`SpacetimeDBClient.disconnect`](#spacetimedbclient-disconnect) | Close the current connection. |\r\n| [`SpacetimeDBClient.subscribe`](#spacetimedbclient-subscribe) | Subscribe to a set of queries. |\r\n| Events | |\r\n| [`SpacetimeDBClient.onConnect`](#spacetimedbclient-onconnect) | Register a callback to be invoked upon authentication with the database. |\r\n| [`SpacetimeDBClient.onError`](#spacetimedbclient-onerror) | Register a callback to be invoked upon a error. |\r\n\r\n## Constructors\r\n\r\n### `SpacetimeDBClient` constructor\r\n\r\nCreates a new `SpacetimeDBClient` database client and set the initial parameters.\r\n\r\n```ts\r\nnew SpacetimeDBClient(host: string, name_or_address: string, auth_token?: string, protocol?: \"binary\" | \"json\")\r\n```\r\n\r\n#### Parameters\r\n\r\n| Name | Type | Description |\r\n| :---------------- | :--------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------ |\r\n| `host` | `string` | The host of the SpacetimeDB server. |\r\n| `name_or_address` | `string` | The name or address of the SpacetimeDB module. |\r\n| `auth_token?` | `string` | The credentials to use to connect to authenticate with SpacetimeDB. |\r\n| `protocol?` | `\"binary\"` \\| `\"json\"` | Define how encode the messages: `\"binary\"` \\| `\"json\"`. Binary is more efficient and compact, but JSON provides human-readable debug information. |\r\n\r\n#### Example\r\n\r\n```ts\r\nconst host = \"ws://localhost:3000\";\r\nconst name_or_address = \"database_name\";\r\nconst auth_token = undefined;\r\nconst protocol = \"binary\";\r\n\r\nvar spacetimeDBClient = new SpacetimeDBClient(\r\n host,\r\n name_or_address,\r\n auth_token,\r\n protocol\r\n);\r\n```\r\n\r\n## Properties\r\n\r\n### `SpacetimeDBClient` identity\r\n\r\nThe user's public [Identity](#class-identity).\r\n\r\n```\r\nidentity: Identity | undefined\r\n```\r\n\r\n---\r\n\r\n### `SpacetimeDBClient` live\r\n\r\nWhether the client is connected.\r\n\r\n```ts\r\nlive: boolean;\r\n```\r\n\r\n---\r\n\r\n### `SpacetimeDBClient` token\r\n\r\nThe user's private authentication token.\r\n\r\n```\r\ntoken: string | undefined\r\n```\r\n\r\n#### Parameters\r\n\r\n| Name | Type | Description |\r\n| :------------ | :----------------------------------------------------- | :------------------------------ |\r\n| `reducerName` | `string` | The name of the reducer to call |\r\n| `serializer` | [`Serializer`](../interfaces/serializer.Serializer.md) | - |\r\n\r\n---\r\n\r\n### `SpacetimeDBClient` connect\r\n\r\nConnect 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.\r\n\r\n```ts\r\nconnect(host: string?, name_or_address: string?, auth_token: string?): Promise\r\n```\r\n\r\n#### Parameters\r\n\r\n| Name | Type | Description |\r\n| :----------------- | :------- | :------------------------------------------------------------------------------------------------------------------------------------------ |\r\n| `host?` | `string` | The hostname of the SpacetimeDB server. Defaults to the value passed to the [constructor](#spacetimedbclient-constructor). |\r\n| `name_or_address?` | `string` | The name or address of the SpacetimeDB module. Defaults to the value passed to the [constructor](#spacetimedbclient-constructor). |\r\n| `auth_token?` | `string` | The credentials to use to authenticate with SpacetimeDB. Defaults to the value passed to the [constructor](#spacetimedbclient-constructor). |\r\n\r\n#### Returns\r\n\r\n`Promise`<`void`\\>\r\n\r\n#### Example\r\n\r\n```ts\r\nconst host = \"ws://localhost:3000\";\r\nconst name_or_address = \"database_name\";\r\nconst auth_token = undefined;\r\n\r\nvar spacetimeDBClient = new SpacetimeDBClient(\r\n host,\r\n name_or_address,\r\n auth_token\r\n);\r\n// Connect with the initial parameters\r\nspacetimeDBClient.connect();\r\n//Set the `auth_token`\r\nspacetimeDBClient.connect(undefined, undefined, NEW_TOKEN);\r\n```\r\n\r\n---\r\n\r\n### `SpacetimeDBClient` disconnect\r\n\r\nClose the current connection.\r\n\r\n```ts\r\ndisconnect(): void\r\n```\r\n\r\n#### Example\r\n\r\n```ts\r\nvar spacetimeDBClient = new SpacetimeDBClient(\r\n \"ws://localhost:3000\",\r\n \"database_name\"\r\n);\r\n\r\nspacetimeDBClient.disconnect();\r\n```\r\n\r\n---\r\n\r\n### `SpacetimeDBClient` subscribe\r\n\r\nSubscribe to a set of queries, to be notified when rows which match those queries are altered.\r\n\r\n> A new call to `subscribe` will remove all previous subscriptions and replace them with the new `queries`.\r\n> If any rows matched the previous subscribed queries but do not match the new queries,\r\n> those rows will be removed from the client cache, and [`{Table}.on_delete`](#table-ondelete) callbacks will be invoked for them.\r\n\r\n```ts\r\nsubscribe(queryOrQueries: string | string[]): void\r\n```\r\n\r\n#### Parameters\r\n\r\n| Name | Type | Description |\r\n| :--------------- | :--------------------- | :------------------------------- |\r\n| `queryOrQueries` | `string` \\| `string`[] | A `SQL` query or list of queries |\r\n\r\n#### Example\r\n\r\n```ts\r\nspacetimeDBClient.subscribe([\"SELECT * FROM User\", \"SELECT * FROM Message\"]);\r\n```\r\n\r\n## Events\r\n\r\n### `SpacetimeDBClient` onConnect\r\n\r\nRegister a callback to be invoked upon authentication with the database.\r\n\r\n```ts\r\nonConnect(callback: (token: string, identity: Identity) => void): void\r\n```\r\n\r\nThe 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.\r\n\r\nThe credentials passed to the callback can be saved and used to authenticate the same user in future connections.\r\n\r\n#### Parameters\r\n\r\n| Name | Type |\r\n| :--------- | :----------------------------------------------------------------------- |\r\n| `callback` | (`token`: `string`, `identity`: [`Identity`](#class-identity)) => `void` |\r\n\r\n#### Example\r\n\r\n```ts\r\nspacetimeDBClient.onConnect((token, identity) => {\r\n console.log(\"Connected to SpacetimeDB\");\r\n console.log(\"Token\", token);\r\n console.log(\"Identity\", identity);\r\n});\r\n```\r\n\r\n---\r\n\r\n### `SpacetimeDBClient` onError\r\n\r\nRegister a callback to be invoked upon an error.\r\n\r\n```ts\r\nonError(callback: (...args: any[]) => void): void\r\n```\r\n\r\n#### Parameters\r\n\r\n| Name | Type |\r\n| :--------- | :----------------------------- |\r\n| `callback` | (...`args`: `any`[]) => `void` |\r\n\r\n#### Example\r\n\r\n```ts\r\nspacetimeDBClient.onError((...args: any[]) => {\r\n console.error(\"ERROR\", args);\r\n});\r\n```\r\n\r\n### Class `Identity`\r\n\r\nA unique public identifier for a client connected to a database.\r\n\r\nDefined in [spacetimedb-sdk.identity](https://github.com/clockworklabs/spacetimedb-typescript-sdk/blob/main/src/identity.ts):\r\n\r\n| Constructors | Description |\r\n| ----------------------------------------------- | -------------------------------------------- |\r\n| [`Identity.constructor`](#identity-constructor) | Creates a new `Identity`. |\r\n| Methods | |\r\n| [`Identity.isEqual`](#identity-isequal) | Compare two identities for equality. |\r\n| [`Identity.toHexString`](#identity-tohexstring) | Print the identity as a hexadecimal string. |\r\n| Static methods | |\r\n| [`Identity.fromString`](#identity-fromstring) | Parse an Identity from a hexadecimal string. |\r\n\r\n## Constructors\r\n\r\n### `Identity` constructor\r\n\r\n```ts\r\nnew Identity(data: Uint8Array)\r\n```\r\n\r\n#### Parameters\r\n\r\n| Name | Type |\r\n| :----- | :----------- |\r\n| `data` | `Uint8Array` |\r\n\r\n## Methods\r\n\r\n### `Identity` isEqual\r\n\r\nCompare two identities for equality.\r\n\r\n```ts\r\nisEqual(other: Identity): boolean\r\n```\r\n\r\n#### Parameters\r\n\r\n| Name | Type |\r\n| :------ | :---------------------------- |\r\n| `other` | [`Identity`](#class-identity) |\r\n\r\n#### Returns\r\n\r\n`boolean`\r\n\r\n---\r\n\r\n### `Identity` toHexString\r\n\r\nPrint an `Identity` as a hexadecimal string.\r\n\r\n```ts\r\ntoHexString(): string\r\n```\r\n\r\n#### Returns\r\n\r\n`string`\r\n\r\n---\r\n\r\n### `Identity` fromString\r\n\r\nStatic method; parse an Identity from a hexadecimal string.\r\n\r\n```ts\r\nIdentity.fromString(str: string): Identity\r\n```\r\n\r\n#### Parameters\r\n\r\n| Name | Type |\r\n| :---- | :------- |\r\n| `str` | `string` |\r\n\r\n#### Returns\r\n\r\n[`Identity`](#class-identity)\r\n\r\n### Class `{Table}`\r\n\r\nFor 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`.\r\n\r\nThe generated class has a field for each of the table's columns, whose names are the column names converted to `snake_case`.\r\n\r\n| Properties | Description |\r\n| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |\r\n| [`Table.name`](#table-name) | The name of the class. |\r\n| [`Table.tableName`](#table-tableName) | The name of the table in the database. |\r\n| Methods | |\r\n| [`Table.isEqual`](#table-isequal) | Method to compare two identities. |\r\n| [`Table.all`](#table-all) | Return all the subscribed rows in the table. |\r\n| [`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. |\r\n| Events | |\r\n| [`Table.onInsert`](#table-oninsert) | Register an `onInsert` callback for when a subscribed row is newly inserted into the database. |\r\n| [`Table.removeOnInsert`](#table-removeoninsert) | Unregister a previously-registered [`onInsert`](#table-oninsert) callback. |\r\n| [`Table.onUpdate`](#table-onupdate) | Register an `onUpdate` callback for when an existing row is modified. |\r\n| [`Table.removeOnUpdate`](#table-removeonupdate) | Unregister a previously-registered [`onUpdate`](#table-onupdate) callback. |\r\n| [`Table.onDelete`](#table-ondelete) | Register an `onDelete` callback for when a subscribed row is removed from the database. |\r\n| [`Table.removeOnDelete`](#table-removeondelete) | Unregister a previously-registered [`onDelete`](#table-removeondelete) callback. |\r\n\r\n## Properties\r\n\r\n### {Table} name\r\n\r\n• **name**: `string`\r\n\r\nThe name of the `Class`.\r\n\r\n---\r\n\r\n### {Table} tableName\r\n\r\nThe name of the table in the database.\r\n\r\n▪ `Static` **tableName**: `string` = `\"Person\"`\r\n\r\n## Methods\r\n\r\n### {Table} all\r\n\r\nReturn all the subscribed rows in the table.\r\n\r\n```ts\r\n{Table}.all(): {Table}[]\r\n```\r\n\r\n#### Returns\r\n\r\n`{Table}[]`\r\n\r\n#### Example\r\n\r\n```ts\r\nvar spacetimeDBClient = new SpacetimeDBClient(\r\n \"ws://localhost:3000\",\r\n \"database_name\"\r\n);\r\n\r\nspacetimeDBClient.onConnect((token, identity) => {\r\n spacetimeDBClient.subscribe([\"SELECT * FROM Person\"]);\r\n\r\n setTimeout(() => {\r\n console.log(Person.all()); // Prints all the `Person` rows in the database.\r\n }, 5000);\r\n});\r\n```\r\n\r\n---\r\n\r\n### {Table} count\r\n\r\nReturn the number of subscribed rows in the table, or 0 if there is no active connection.\r\n\r\n```ts\r\n{Table}.count(): number\r\n```\r\n\r\n#### Returns\r\n\r\n`number`\r\n\r\n#### Example\r\n\r\n```ts\r\nvar spacetimeDBClient = new SpacetimeDBClient(\r\n \"ws://localhost:3000\",\r\n \"database_name\"\r\n);\r\n\r\nspacetimeDBClient.onConnect((token, identity) => {\r\n spacetimeDBClient.subscribe([\"SELECT * FROM Person\"]);\r\n\r\n setTimeout(() => {\r\n console.log(Person.count());\r\n }, 5000);\r\n});\r\n```\r\n\r\n---\r\n\r\n### {Table} filterBy{COLUMN}\r\n\r\nFor 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.\r\n\r\nThese methods are named `filterBy{COLUMN}`, where `{COLUMN}` is the column name converted to `camelCase`.\r\n\r\n```ts\r\n{Table}.filterBy{COLUMN}(value): {Table}[]\r\n```\r\n\r\n#### Parameters\r\n\r\n| Name | Type |\r\n| :------ | :-------------------------- |\r\n| `value` | The type of the `{COLUMN}`. |\r\n\r\n#### Returns\r\n\r\n`{Table}[]`\r\n\r\n#### Example\r\n\r\n```ts\r\nvar spacetimeDBClient = new SpacetimeDBClient(\r\n \"ws://localhost:3000\",\r\n \"database_name\"\r\n);\r\n\r\nspacetimeDBClient.onConnect((token, identity) => {\r\n spacetimeDBClient.subscribe([\"SELECT * FROM Person\"]);\r\n\r\n setTimeout(() => {\r\n console.log(Person.filterByName(\"John\")); // prints all the `Person` rows named John.\r\n }, 5000);\r\n});\r\n```\r\n\r\n---\r\n\r\n### {Table} fromValue\r\n\r\nDeserialize an `AlgebraicType` into this `{Table}`.\r\n\r\n```ts\r\n {Table}.fromValue(value: AlgebraicValue): {Table}\r\n```\r\n\r\n#### Parameters\r\n\r\n| Name | Type |\r\n| :------ | :--------------- |\r\n| `value` | `AlgebraicValue` |\r\n\r\n#### Returns\r\n\r\n`{Table}`\r\n\r\n---\r\n\r\n### {Table} getAlgebraicType\r\n\r\nSerialize `this` into an `AlgebraicType`.\r\n\r\n#### Example\r\n\r\n```ts\r\n{Table}.getAlgebraicType(): AlgebraicType\r\n```\r\n\r\n#### Returns\r\n\r\n`AlgebraicType`\r\n\r\n---\r\n\r\n### {Table} onInsert\r\n\r\nRegister an `onInsert` callback for when a subscribed row is newly inserted into the database.\r\n\r\n```ts\r\n{Table}.onInsert(callback: (value: {Table}, reducerEvent: ReducerEvent | undefined) => void): void\r\n```\r\n\r\n#### Parameters\r\n\r\n| Name | Type | Description |\r\n| :--------- | :---------------------------------------------------------------------------- | :----------------------------------------------------- |\r\n| `callback` | (`value`: `{Table}`, `reducerEvent`: `undefined` \\| `ReducerEvent`) => `void` | Callback to run whenever a subscribed row is inserted. |\r\n\r\n#### Example\r\n\r\n```ts\r\nvar spacetimeDBClient = new SpacetimeDBClient(\r\n \"ws://localhost:3000\",\r\n \"database_name\"\r\n);\r\nspacetimeDBClient.onConnect((token, identity) => {\r\n spacetimeDBClient.subscribe([\"SELECT * FROM Person\"]);\r\n});\r\n\r\nPerson.onInsert((person, reducerEvent) => {\r\n if (reducerEvent) {\r\n console.log(\"New person inserted by reducer\", reducerEvent, person);\r\n } else {\r\n console.log(\"New person received during subscription update\", person);\r\n }\r\n});\r\n```\r\n\r\n---\r\n\r\n### {Table} removeOnInsert\r\n\r\nUnregister a previously-registered [`onInsert`](#table-oninsert) callback.\r\n\r\n```ts\r\n{Table}.removeOnInsert(callback: (value: Person, reducerEvent: ReducerEvent | undefined) => void): void\r\n```\r\n\r\n#### Parameters\r\n\r\n| Name | Type |\r\n| :--------- | :---------------------------------------------------------------------------- |\r\n| `callback` | (`value`: `{Table}`, `reducerEvent`: `undefined` \\| `ReducerEvent`) => `void` |\r\n\r\n---\r\n\r\n### {Table} onUpdate\r\n\r\nRegister an `onUpdate` callback to run when an existing row is modified by primary key.\r\n\r\n```ts\r\n{Table}.onUpdate(callback: (oldValue: {Table}, newValue: {Table}, reducerEvent: ReducerEvent | undefined) => void): void\r\n```\r\n\r\n`onUpdate` callbacks are only meaningful for tables with a column declared as a primary key. Tables without primary keys will never fire `onUpdate` callbacks.\r\n\r\n#### Parameters\r\n\r\n| Name | Type | Description |\r\n| :--------- | :------------------------------------------------------------------------------------------------------ | :---------------------------------------------------- |\r\n| `callback` | (`oldValue`: `{Table}`, `newValue`: `{Table}`, `reducerEvent`: `undefined` \\| `ReducerEvent`) => `void` | Callback to run whenever a subscribed row is updated. |\r\n\r\n#### Example\r\n\r\n```ts\r\nvar spacetimeDBClient = new SpacetimeDBClient(\r\n \"ws://localhost:3000\",\r\n \"database_name\"\r\n);\r\nspacetimeDBClient.onConnect((token, identity) => {\r\n spacetimeDBClient.subscribe([\"SELECT * FROM Person\"]);\r\n});\r\n\r\nPerson.onUpdate((oldPerson, newPerson, reducerEvent) => {\r\n console.log(\"Person updated by reducer\", reducerEvent, oldPerson, newPerson);\r\n});\r\n```\r\n\r\n---\r\n\r\n### {Table} removeOnUpdate\r\n\r\nUnregister a previously-registered [`onUpdate`](#table-onUpdate) callback.\r\n\r\n```ts\r\n{Table}.removeOnUpdate(callback: (oldValue: {Table}, newValue: {Table}, reducerEvent: ReducerEvent | undefined) => void): void\r\n```\r\n\r\n#### Parameters\r\n\r\n| Name | Type |\r\n| :--------- | :------------------------------------------------------------------------------------------------------ |\r\n| `callback` | (`oldValue`: `{Table}`, `newValue`: `{Table}`, `reducerEvent`: `undefined` \\| `ReducerEvent`) => `void` |\r\n\r\n---\r\n\r\n### {Table} onDelete\r\n\r\nRegister an `onDelete` callback for when a subscribed row is removed from the database.\r\n\r\n```ts\r\n{Table}.onDelete(callback: (value: {Table}, reducerEvent: ReducerEvent | undefined) => void): void\r\n```\r\n\r\n#### Parameters\r\n\r\n| Name | Type | Description |\r\n| :--------- | :---------------------------------------------------------------------------- | :---------------------------------------------------- |\r\n| `callback` | (`value`: `{Table}`, `reducerEvent`: `undefined` \\| `ReducerEvent`) => `void` | Callback to run whenever a subscribed row is removed. |\r\n\r\n#### Example\r\n\r\n```ts\r\nvar spacetimeDBClient = new SpacetimeDBClient(\r\n \"ws://localhost:3000\",\r\n \"database_name\"\r\n);\r\nspacetimeDBClient.onConnect((token, identity) => {\r\n spacetimeDBClient.subscribe([\"SELECT * FROM Person\"]);\r\n});\r\n\r\nPerson.onDelete((person, reducerEvent) => {\r\n if (reducerEvent) {\r\n console.log(\"Person deleted by reducer\", reducerEvent, person);\r\n } else {\r\n console.log(\r\n \"Person no longer subscribed during subscription update\",\r\n person\r\n );\r\n }\r\n});\r\n```\r\n\r\n---\r\n\r\n### {Table} removeOnDelete\r\n\r\nUnregister a previously-registered [`onDelete`](#table-onDelete) callback.\r\n\r\n```ts\r\n{Table}.removeOnDelete(callback: (value: {Table}, reducerEvent: ReducerEvent | undefined) => void): void\r\n```\r\n\r\n#### Parameters\r\n\r\n| Name | Type |\r\n| :--------- | :---------------------------------------------------------------------------- |\r\n| `callback` | (`value`: `{Table}`, `reducerEvent`: `undefined` \\| `ReducerEvent`) => `void` |\r\n\r\n### Class `{Reducer}`\r\n\r\n`spacetime generate` defines an `{Reducer}` class in the `module_bindings` folder for each reducer defined by a module.\r\n\r\nThe class's name will be the reducer's name converted to `PascalCase`.\r\n\r\n| Static methods | Description |\r\n| ------------------------------- | ------------------------------------------------------------ |\r\n| [`Reducer.call`](#reducer-call) | Executes the reducer. |\r\n| Events | |\r\n| [`Reducer.on`](#reducer-on) | Register a callback to run each time the reducer is invoked. |\r\n\r\n## Static methods\r\n\r\n### {Reducer} call\r\n\r\nExecutes the reducer.\r\n\r\n```ts\r\n{Reducer}.call(): void\r\n```\r\n\r\n#### Example\r\n\r\n```ts\r\nSayHelloReducer.call();\r\n```\r\n\r\n## Events\r\n\r\n### {Reducer} on\r\n\r\nRegister a callback to run each time the reducer is invoked.\r\n\r\n```ts\r\n{Reducer}.on(callback: (reducerEvent: ReducerEvent, reducerArgs: any[]) => void): void\r\n```\r\n\r\nClients will only be notified of reducer runs if either of two criteria is met:\r\n\r\n- The reducer inserted, deleted or updated at least one row to which the client is subscribed.\r\n- The reducer invocation was requested by this client, and the run failed.\r\n\r\n#### Parameters\r\n\r\n| Name | Type |\r\n| :--------- | :---------------------------------------------------------- |\r\n| `callback` | `(reducerEvent: ReducerEvent, reducerArgs: any[]) => void)` |\r\n\r\n#### Example\r\n\r\n```ts\r\nSayHelloReducer.on((reducerEvent, reducerArgs) => {\r\n console.log(\"SayHelloReducer called\", reducerEvent, reducerArgs);\r\n});\r\n```\r\n", - "editUrl": "SDK%20Reference.md", - "jumpLinks": [ - { - "title": "The SpacetimeDB Typescript client SDK", - "route": "the-spacetimedb-typescript-client-sdk", - "depth": 1 - }, - { - "title": "Install the SDK", - "route": "install-the-sdk", - "depth": 2 - }, - { - "title": "Tip for utilities/scripts", - "route": "tip-for-utilities-scripts", - "depth": 3 - }, - { - "title": "Generate module bindings", - "route": "generate-module-bindings", - "depth": 2 - }, - { - "title": "API at a glance", - "route": "api-at-a-glance", - "depth": 2 - }, - { - "title": "Classes", - "route": "classes", - "depth": 3 - }, - { - "title": "Class `SpacetimeDBClient`", - "route": "class-spacetimedbclient-", - "depth": 3 - }, - { - "title": "Constructors", - "route": "constructors", - "depth": 2 - }, - { - "title": "`SpacetimeDBClient` constructor", - "route": "-spacetimedbclient-constructor", - "depth": 3 - }, - { - "title": "Parameters", - "route": "parameters", - "depth": 4 - }, - { - "title": "Example", - "route": "example", - "depth": 4 - }, - { - "title": "Properties", - "route": "properties", - "depth": 2 - }, - { - "title": "`SpacetimeDBClient` identity", - "route": "-spacetimedbclient-identity", - "depth": 3 - }, - { - "title": "`SpacetimeDBClient` live", - "route": "-spacetimedbclient-live", - "depth": 3 - }, - { - "title": "`SpacetimeDBClient` token", - "route": "-spacetimedbclient-token", - "depth": 3 - }, - { - "title": "Parameters", - "route": "parameters", - "depth": 4 - }, - { - "title": "`SpacetimeDBClient` connect", - "route": "-spacetimedbclient-connect", - "depth": 3 - }, - { - "title": "Parameters", - "route": "parameters", - "depth": 4 - }, - { - "title": "Returns", - "route": "returns", - "depth": 4 - }, - { - "title": "Example", - "route": "example", - "depth": 4 - }, - { - "title": "`SpacetimeDBClient` disconnect", - "route": "-spacetimedbclient-disconnect", - "depth": 3 - }, - { - "title": "Example", - "route": "example", - "depth": 4 - }, - { - "title": "`SpacetimeDBClient` subscribe", - "route": "-spacetimedbclient-subscribe", - "depth": 3 - }, - { - "title": "Parameters", - "route": "parameters", - "depth": 4 - }, - { - "title": "Example", - "route": "example", - "depth": 4 - }, - { - "title": "Events", - "route": "events", - "depth": 2 - }, - { - "title": "`SpacetimeDBClient` onConnect", - "route": "-spacetimedbclient-onconnect", - "depth": 3 - }, - { - "title": "Parameters", - "route": "parameters", - "depth": 4 - }, - { - "title": "Example", - "route": "example", - "depth": 4 - }, - { - "title": "`SpacetimeDBClient` onError", - "route": "-spacetimedbclient-onerror", - "depth": 3 - }, - { - "title": "Parameters", - "route": "parameters", - "depth": 4 - }, - { - "title": "Example", - "route": "example", - "depth": 4 - }, - { - "title": "Class `Identity`", - "route": "class-identity-", - "depth": 3 - }, - { - "title": "Constructors", - "route": "constructors", - "depth": 2 - }, - { - "title": "`Identity` constructor", - "route": "-identity-constructor", - "depth": 3 - }, - { - "title": "Parameters", - "route": "parameters", - "depth": 4 - }, - { - "title": "Methods", - "route": "methods", - "depth": 2 - }, - { - "title": "`Identity` isEqual", - "route": "-identity-isequal", - "depth": 3 - }, - { - "title": "Parameters", - "route": "parameters", - "depth": 4 - }, - { - "title": "Returns", - "route": "returns", - "depth": 4 - }, - { - "title": "`Identity` toHexString", - "route": "-identity-tohexstring", - "depth": 3 - }, - { - "title": "Returns", - "route": "returns", - "depth": 4 - }, - { - "title": "`Identity` fromString", - "route": "-identity-fromstring", - "depth": 3 - }, - { - "title": "Parameters", - "route": "parameters", - "depth": 4 - }, - { - "title": "Returns", - "route": "returns", - "depth": 4 - }, - { - "title": "Class `{Table}`", - "route": "class-table-", - "depth": 3 - }, - { - "title": "Properties", - "route": "properties", - "depth": 2 - }, - { - "title": "{Table} name", - "route": "-table-name", - "depth": 3 - }, - { - "title": "{Table} tableName", - "route": "-table-tablename", - "depth": 3 - }, - { - "title": "Methods", - "route": "methods", - "depth": 2 - }, - { - "title": "{Table} all", - "route": "-table-all", - "depth": 3 - }, - { - "title": "Returns", - "route": "returns", - "depth": 4 - }, - { - "title": "Example", - "route": "example", - "depth": 4 - }, - { - "title": "{Table} count", - "route": "-table-count", - "depth": 3 - }, - { - "title": "Returns", - "route": "returns", - "depth": 4 - }, - { - "title": "Example", - "route": "example", - "depth": 4 - }, - { - "title": "{Table} filterBy{COLUMN}", - "route": "-table-filterby-column-", - "depth": 3 - }, - { - "title": "Parameters", - "route": "parameters", - "depth": 4 - }, - { - "title": "Returns", - "route": "returns", - "depth": 4 - }, - { - "title": "Example", - "route": "example", - "depth": 4 - }, - { - "title": "{Table} fromValue", - "route": "-table-fromvalue", - "depth": 3 - }, - { - "title": "Parameters", - "route": "parameters", - "depth": 4 - }, - { - "title": "Returns", - "route": "returns", - "depth": 4 - }, - { - "title": "{Table} getAlgebraicType", - "route": "-table-getalgebraictype", - "depth": 3 - }, - { - "title": "Example", - "route": "example", - "depth": 4 - }, - { - "title": "Returns", - "route": "returns", - "depth": 4 - }, - { - "title": "{Table} onInsert", - "route": "-table-oninsert", - "depth": 3 - }, - { - "title": "Parameters", - "route": "parameters", - "depth": 4 - }, - { - "title": "Example", - "route": "example", - "depth": 4 - }, - { - "title": "{Table} removeOnInsert", - "route": "-table-removeoninsert", - "depth": 3 - }, - { - "title": "Parameters", - "route": "parameters", - "depth": 4 - }, - { - "title": "{Table} onUpdate", - "route": "-table-onupdate", - "depth": 3 - }, - { - "title": "Parameters", - "route": "parameters", - "depth": 4 - }, - { - "title": "Example", - "route": "example", - "depth": 4 - }, - { - "title": "{Table} removeOnUpdate", - "route": "-table-removeonupdate", - "depth": 3 - }, - { - "title": "Parameters", - "route": "parameters", - "depth": 4 - }, - { - "title": "{Table} onDelete", - "route": "-table-ondelete", - "depth": 3 - }, - { - "title": "Parameters", - "route": "parameters", - "depth": 4 - }, - { - "title": "Example", - "route": "example", - "depth": 4 - }, - { - "title": "{Table} removeOnDelete", - "route": "-table-removeondelete", - "depth": 3 - }, - { - "title": "Parameters", - "route": "parameters", - "depth": 4 - }, - { - "title": "Class `{Reducer}`", - "route": "class-reducer-", - "depth": 3 - }, - { - "title": "Static methods", - "route": "static-methods", - "depth": 2 - }, - { - "title": "{Reducer} call", - "route": "-reducer-call", - "depth": 3 - }, - { - "title": "Example", - "route": "example", - "depth": 4 - }, - { - "title": "Events", - "route": "events", - "depth": 2 - }, - { - "title": "{Reducer} on", - "route": "-reducer-on", - "depth": 3 - }, - { - "title": "Parameters", - "route": "parameters", - "depth": 4 - }, - { - "title": "Example", - "route": "example", - "depth": 4 - } - ], - "pages": [] - } - ] - } - ], - "previousKey": { - "title": "Server Module Languages", - "route": "index", - "depth": 1 - }, - "nextKey": { - "title": "Module ABI Reference", - "route": "index", - "depth": 1 - } - }, - { - "title": "Module ABI Reference", - "identifier": "Module ABI Reference", - "indexIdentifier": "index", - "comingSoon": false, - "hasPages": true, - "editUrl": "Module%20ABI%20Reference/index.md", - "jumpLinks": [], - "pages": [ - { - "title": "Module ABI Reference", - "identifier": "index", - "indexIdentifier": "index", - "content": "# Module ABI Reference\r\n\r\nThis document specifies the _low level details_ of module-host interactions (_\"Module ABI\"_). _**Most users**_ looking to interact with the host will want to use derived and higher level functionality like [`bindings`], `#[spacetimedb(table)]`, and `#[derive(SpacetimeType)]` rather than this low level ABI. For more on those, read the [Rust module quick start][module_quick_start] guide and the [Rust module reference][module_ref].\r\n\r\nThe Module ABI is defined in [`bindings_sys::raw`] and is used by modules to interact with their host and perform various operations like:\r\n\r\n- logging,\r\n- transporting data,\r\n- scheduling reducers,\r\n- altering tables,\r\n- inserting and deleting rows,\r\n- querying tables.\r\n\r\nIn the next few sections, we'll define the functions that make up the ABI and what these functions do.\r\n\r\n## General notes\r\n\r\nThe functions in this ABI all use the [`C` ABI on the `wasm32` platform][wasm_c_abi]. They are specified in a Rust `extern \"C\" { .. }` block. For those more familiar with the `C` notation, an [appendix][c_header] is provided with equivalent definitions as would occur in a `.h` file.\r\n\r\nMany functions in the ABI take in- or out-pointers, e.g. `*const u8` and `*mut u8`. The WASM host itself does not have undefined behavior. However, what WASM does not consider a memory access violation could be one according to some other language's abstract machine. For example, running the following on a WASM host would violate Rust's rules around writing across allocations:\r\n\r\n```rust\r\nfn main() {\r\n let mut bytes = [0u8; 12];\r\n let other_bytes = [0u8; 4];\r\n unsafe { ffi_func_with_out_ptr_and_len(&mut bytes as *mut u8, 16); }\r\n assert_eq!(other_bytes, [0u8; 4]);\r\n}\r\n```\r\n\r\nWhen we note in this reference that traps occur or errors are returned on memory access violations, we only mean those that WASM can directly detected, and not cases like the one above.\r\n\r\nShould memory access violations occur, such as a buffer overrun, undefined behavior will never result, as it does not exist in WASM. However, in many cases, an error code will result.\r\n\r\nSome functions will treat UTF-8 strings _lossily_. That is, if the slice identified by a `(ptr, len)` contains non-UTF-8 bytes, these bytes will be replaced with `�` in the read string.\r\n\r\nMost functions return a `u16` value. This is how these functions indicate an error where a `0` value means that there were no errors. Such functions will instead return any data they need to through out pointers.\r\n\r\n## Logging\r\n\r\n```rust\r\n/// The error log level.\r\nconst LOG_LEVEL_ERROR: u8 = 0;\r\n/// The warn log level.\r\nconst LOG_LEVEL_WARN: u8 = 1;\r\n/// The info log level.\r\nconst LOG_LEVEL_INFO: u8 = 2;\r\n/// The debug log level.\r\nconst LOG_LEVEL_DEBUG: u8 = 3;\r\n/// The trace log level.\r\nconst LOG_LEVEL_TRACE: u8 = 4;\r\n/// The panic log level.\r\n///\r\n/// A panic level is emitted just before\r\n/// a fatal error causes the WASM module to trap.\r\nconst LOG_LEVEL_PANIC: u8 = 101;\r\n\r\n/// Log at `level` a `text` message occuring in `filename:line_number`\r\n/// with `target` being the module path at the `log!` invocation site.\r\n///\r\n/// These various pointers are interpreted lossily as UTF-8 strings.\r\n/// The data pointed to are copied. Ownership does not transfer.\r\n///\r\n/// See https://docs.rs/log/latest/log/struct.Record.html#method.target\r\n/// for more info on `target`.\r\n///\r\n/// Calls to the function cannot fail\r\n/// irrespective of memory access violations.\r\n/// If they occur, no message is logged.\r\nfn _console_log(\r\n // The level we're logging at.\r\n // One of the `LOG_*` constants above.\r\n level: u8,\r\n // The module path, if any, associated with the message\r\n // or to \"blame\" for the reason we're logging.\r\n //\r\n // This is a pointer to a buffer holding an UTF-8 encoded string.\r\n // When the pointer is `NULL`, `target` is ignored.\r\n target: *const u8,\r\n // The length of the buffer pointed to by `text`.\r\n // Unused when `target` is `NULL`.\r\n target_len: usize,\r\n // The file name, if any, associated with the message\r\n // or to \"blame\" for the reason we're logging.\r\n //\r\n // This is a pointer to a buffer holding an UTF-8 encoded string.\r\n // When the pointer is `NULL`, `filename` is ignored.\r\n filename: *const u8,\r\n // The length of the buffer pointed to by `text`.\r\n // Unused when `filename` is `NULL`.\r\n filename_len: usize,\r\n // The line number associated with the message\r\n // or to \"blame\" for the reason we're logging.\r\n line_number: u32,\r\n // A pointer to a buffer holding an UTF-8 encoded message to log.\r\n text: *const u8,\r\n // The length of the buffer pointed to by `text`.\r\n text_len: usize,\r\n);\r\n```\r\n\r\n## Buffer handling\r\n\r\n```rust\r\n/// Returns the length of buffer `bufh` without\r\n/// transferring ownership of the data into the function.\r\n///\r\n/// The `bufh` must have previously been allocating using `_buffer_alloc`.\r\n///\r\n/// Traps if the buffer does not exist.\r\nfn _buffer_len(\r\n // The buffer previously allocated using `_buffer_alloc`.\r\n // Ownership of the buffer is not taken.\r\n bufh: ManuallyDrop\r\n) -> usize;\r\n\r\n/// Consumes the buffer `bufh`,\r\n/// moving its contents to the WASM byte slice `(ptr, len)`.\r\n///\r\n/// Returns an error if the buffer does not exist\r\n/// or on any memory access violations associated with `(ptr, len)`.\r\nfn _buffer_consume(\r\n // The buffer to consume and move into `(ptr, len)`.\r\n // Ownership of the buffer and its contents are taken.\r\n // That is, `bufh` won't be usable after this call.\r\n bufh: Buffer,\r\n // A WASM out pointer to write the contents of `bufh` to.\r\n ptr: *mut u8,\r\n // The size of the buffer pointed to by `ptr`.\r\n // This size must match that of `bufh` or a trap will occur.\r\n len: usize\r\n);\r\n\r\n/// Creates a buffer of size `data_len` in the host environment.\r\n///\r\n/// The contents of the byte slice lasting `data_len` bytes\r\n/// at the `data` WASM pointer are read\r\n/// and written into the newly initialized buffer.\r\n///\r\n/// Traps on any memory access violations.\r\nfn _buffer_alloc(data: *const u8, data_len: usize) -> Buffer;\r\n```\r\n\r\n## Reducer scheduling\r\n\r\n```rust\r\n/// Schedules a reducer to be called asynchronously at `time`.\r\n///\r\n/// The reducer is named as the valid UTF-8 slice `(name, name_len)`,\r\n/// and is passed the slice `(args, args_len)` as its argument.\r\n///\r\n/// A generated schedule id is assigned to the reducer.\r\n/// This id is written to the pointer `out`.\r\n///\r\n/// Errors on any memory access violations,\r\n/// if `(name, name_len)` does not point to valid UTF-8,\r\n/// or if the `time` delay exceeds `64^6 - 1` milliseconds from now.\r\nfn _schedule_reducer(\r\n // A pointer to a buffer\r\n // with a valid UTF-8 string of `name_len` many bytes.\r\n name: *const u8,\r\n // The number of bytes in the `name` buffer.\r\n name_len: usize,\r\n // A pointer to a byte buffer of `args_len` many bytes.\r\n args: *const u8,\r\n // The number of bytes in the `args` buffer.\r\n args_len: usize,\r\n // When to call the reducer.\r\n time: u64,\r\n // The schedule ID is written to this out pointer on a successful call.\r\n out: *mut u64,\r\n);\r\n\r\n/// Unschedules a reducer\r\n/// using the same `id` generated as when it was scheduled.\r\n///\r\n/// This assumes that the reducer hasn't already been executed.\r\nfn _cancel_reducer(id: u64);\r\n```\r\n\r\n## Altering tables\r\n\r\n```rust\r\n/// Creates an index with the name `index_name` and type `index_type`,\r\n/// on a product of the given columns in `col_ids`\r\n/// in the table identified by `table_id`.\r\n///\r\n/// Here `index_name` points to a UTF-8 slice in WASM memory\r\n/// and `col_ids` points to a byte slice in WASM memory\r\n/// with each element being a column.\r\n///\r\n/// Currently only single-column-indices are supported\r\n/// and they may only be of the btree index type.\r\n/// In the former case, the function will panic,\r\n/// and in latter, an error is returned.\r\n///\r\n/// Returns an error on any memory access violations,\r\n/// if `(index_name, index_name_len)` is not valid UTF-8,\r\n/// or when a table with the provided `table_id` doesn't exist.\r\n///\r\n/// Traps if `index_type /= 0` or if `col_len /= 1`.\r\nfn _create_index(\r\n // A pointer to a buffer holding an UTF-8 encoded index name.\r\n index_name: *const u8,\r\n // The length of the buffer pointed to by `index_name`.\r\n index_name_len: usize,\r\n // The ID of the table to create the index for.\r\n table_id: u32,\r\n // The type of the index.\r\n // Must be `0` currently, that is, a btree-index.\r\n index_type: u8,\r\n // A pointer to a buffer holding a byte slice\r\n // where each element is the position\r\n // of a column to include in the index.\r\n col_ids: *const u8,\r\n // The length of the byte slice in `col_ids`. Must be `1`.\r\n col_len: usize,\r\n) -> u16;\r\n```\r\n\r\n## Inserting and deleting rows\r\n\r\n```rust\r\n/// Inserts a row into the table identified by `table_id`,\r\n/// where the row is read from the byte slice `row_ptr` in WASM memory,\r\n/// lasting `row_len` bytes.\r\n///\r\n/// Errors if there were unique constraint violations,\r\n/// if there were any memory access violations in associated with `row`,\r\n/// if the `table_id` doesn't identify a table,\r\n/// or if `(row, row_len)` doesn't decode from BSATN to a `ProductValue`\r\n/// according to the `ProductType` that the table's schema specifies.\r\nfn _insert(\r\n // The table to insert the row into.\r\n // The interpretation of `(row, row_len)` depends on this ID\r\n // as it's table schema determines how to decode the raw bytes.\r\n table_id: u32,\r\n // An in/out pointer to a byte buffer\r\n // holding the BSATN-encoded `ProductValue` row data to insert.\r\n //\r\n // The pointer is written to with the inserted row re-encoded.\r\n // This is due to auto-incrementing columns.\r\n row: *mut u8,\r\n // The length of the buffer pointed to by `row`.\r\n row_len: usize\r\n) -> u16;\r\n\r\n/// Deletes all rows in the table identified by `table_id`\r\n/// where the column identified by `col_id` matches the byte string,\r\n/// in WASM memory, pointed to by `value`.\r\n///\r\n/// Matching is defined by decoding of `value` to an `AlgebraicValue`\r\n/// according to the column's schema and then `Ord for AlgebraicValue`.\r\n///\r\n/// The number of rows deleted is written to the WASM pointer `out`.\r\n///\r\n/// Errors if there were memory access violations\r\n/// associated with `value` or `out`,\r\n/// if no columns were deleted,\r\n/// or if the column wasn't found.\r\nfn _delete_by_col_eq(\r\n // The table to delete rows from.\r\n table_id: u32,\r\n // The position of the column to match `(value, value_len)` against.\r\n col_id: u32,\r\n // A pointer to a byte buffer holding a BSATN-encoded `AlgebraicValue`\r\n // of the `AlgebraicType` that the table's schema specifies\r\n // for the column identified by `col_id`.\r\n value: *const u8,\r\n // The length of the buffer pointed to by `value`.\r\n value_len: usize,\r\n // An out pointer that the number of rows deleted is written to.\r\n out: *mut u32\r\n) -> u16;\r\n```\r\n\r\n## Querying tables\r\n\r\n```rust\r\n/// Queries the `table_id` associated with the given (table) `name`\r\n/// where `name` points to a UTF-8 slice\r\n/// in WASM memory of `name_len` bytes.\r\n///\r\n/// The table id is written into the `out` pointer.\r\n///\r\n/// Errors on memory access violations associated with `name`\r\n/// or if the table does not exist.\r\nfn _get_table_id(\r\n // A pointer to a buffer holding the name of the table\r\n // as a valid UTF-8 encoded string.\r\n name: *const u8,\r\n // The length of the buffer pointed to by `name`.\r\n name_len: usize,\r\n // An out pointer to write the table ID to.\r\n out: *mut u32\r\n) -> u16;\r\n\r\n/// Finds all rows in the table identified by `table_id`,\r\n/// where the row has a column, identified by `col_id`,\r\n/// with data matching the byte string,\r\n/// in WASM memory, pointed to at by `val`.\r\n///\r\n/// Matching is defined by decoding of `value`\r\n/// to an `AlgebraicValue` according to the column's schema\r\n/// and then `Ord for AlgebraicValue`.\r\n///\r\n/// The rows found are BSATN encoded and then concatenated.\r\n/// The resulting byte string from the concatenation\r\n/// is written to a fresh buffer\r\n/// with the buffer's identifier written to the WASM pointer `out`.\r\n///\r\n/// Errors if no table with `table_id` exists,\r\n/// if `col_id` does not identify a column of the table,\r\n/// if `(value, value_len)` cannot be decoded to an `AlgebraicValue`\r\n/// typed at the `AlgebraicType` of the column,\r\n/// or if memory access violations occurred associated with `value` or `out`.\r\nfn _iter_by_col_eq(\r\n // Identifies the table to find rows in.\r\n table_id: u32,\r\n // The position of the column in the table\r\n // to match `(value, value_len)` against.\r\n col_id: u32,\r\n // A pointer to a byte buffer holding a BSATN encoded\r\n // value typed at the `AlgebraicType` of the column.\r\n value: *const u8,\r\n // The length of the buffer pointed to by `value`.\r\n value_len: usize,\r\n // An out pointer to which the new buffer's id is written to.\r\n out: *mut Buffer\r\n) -> u16;\r\n\r\n/// Starts iteration on each row, as bytes,\r\n/// of a table identified by `table_id`.\r\n///\r\n/// The iterator is registered in the host environment\r\n/// under an assigned index which is written to the `out` pointer provided.\r\n///\r\n/// Errors if the table doesn't exist\r\n/// or if memory access violations occurred in association with `out`.\r\nfn _iter_start(\r\n // The ID of the table to start row iteration on.\r\n table_id: u32,\r\n // An out pointer to which an identifier\r\n // to the newly created buffer is written.\r\n out: *mut BufferIter\r\n) -> u16;\r\n\r\n/// Like [`_iter_start`], starts iteration on each row,\r\n/// as bytes, of a table identified by `table_id`.\r\n///\r\n/// The rows are filtered through `filter`, which is read from WASM memory\r\n/// and is encoded in the embedded language defined by `spacetimedb_lib::filter::Expr`.\r\n///\r\n/// The iterator is registered in the host environment\r\n/// under an assigned index which is written to the `out` pointer provided.\r\n///\r\n/// Errors if `table_id` doesn't identify a table,\r\n/// if `(filter, filter_len)` doesn't decode to a filter expression,\r\n/// or if there were memory access violations\r\n/// in association with `filter` or `out`.\r\nfn _iter_start_filtered(\r\n // The ID of the table to start row iteration on.\r\n table_id: u32,\r\n // A pointer to a buffer holding an encoded filter expression.\r\n filter: *const u8,\r\n // The length of the buffer pointed to by `filter`.\r\n filter_len: usize,\r\n // An out pointer to which an identifier\r\n // to the newly created buffer is written.\r\n out: *mut BufferIter\r\n) -> u16;\r\n\r\n/// Advances the registered iterator with the index given by `iter_key`.\r\n///\r\n/// On success, the next element (the row as bytes) is written to a buffer.\r\n/// The buffer's index is returned and written to the `out` pointer.\r\n/// If there are no elements left, an invalid buffer index is written to `out`.\r\n/// On failure however, the error is returned.\r\n///\r\n/// Errors if `iter` does not identify a registered `BufferIter`,\r\n/// or if there were memory access violations in association with `out`.\r\nfn _iter_next(\r\n // An identifier for the iterator buffer to advance.\r\n // Ownership of the buffer nor the identifier is moved into the function.\r\n iter: ManuallyDrop,\r\n // An out pointer to write the newly created buffer's identifier to.\r\n out: *mut Buffer\r\n) -> u16;\r\n\r\n/// Drops the entire registered iterator with the index given by `iter_key`.\r\n/// The iterator is effectively de-registered.\r\n///\r\n/// Returns an error if the iterator does not exist.\r\nfn _iter_drop(\r\n // An identifier for the iterator buffer to unregister / drop.\r\n iter: ManuallyDrop\r\n) -> u16;\r\n```\r\n\r\n## Appendix, `bindings.h`\r\n\r\n```c\r\n#include \r\n#include \r\n#include \r\n#include \r\n#include \r\n\r\ntypedef uint32_t Buffer;\r\ntypedef uint32_t BufferIter;\r\n\r\nvoid _console_log(\r\n uint8_t level,\r\n const uint8_t *target,\r\n size_t target_len,\r\n const uint8_t *filename,\r\n size_t filename_len,\r\n uint32_t line_number,\r\n const uint8_t *text,\r\n size_t text_len\r\n);\r\n\r\n\r\nBuffer _buffer_alloc(\r\n const uint8_t *data,\r\n size_t data_len\r\n);\r\nvoid _buffer_consume(\r\n Buffer bufh,\r\n uint8_t *into,\r\n size_t len\r\n);\r\nsize_t _buffer_len(Buffer bufh);\r\n\r\n\r\nvoid _schedule_reducer(\r\n const uint8_t *name,\r\n size_t name_len,\r\n const uint8_t *args,\r\n size_t args_len,\r\n uint64_t time,\r\n uint64_t *out\r\n);\r\nvoid _cancel_reducer(uint64_t id);\r\n\r\n\r\nuint16_t _create_index(\r\n const uint8_t *index_name,\r\n size_t index_name_len,\r\n uint32_t table_id,\r\n uint8_t index_type,\r\n const uint8_t *col_ids,\r\n size_t col_len\r\n);\r\n\r\n\r\nuint16_t _insert(\r\n uint32_t table_id,\r\n uint8_t *row,\r\n size_t row_len\r\n);\r\nuint16_t _delete_by_col_eq(\r\n uint32_t table_id,\r\n uint32_t col_id,\r\n const uint8_t *value,\r\n size_t value_len,\r\n uint32_t *out\r\n);\r\n\r\n\r\nuint16_t _get_table_id(\r\n const uint8_t *name,\r\n size_t name_len,\r\n uint32_t *out\r\n);\r\nuint16_t _iter_by_col_eq(\r\n uint32_t table_id,\r\n uint32_t col_id,\r\n const uint8_t *value,\r\n size_t value_len,\r\n Buffer *out\r\n);\r\nuint16_t _iter_drop(BufferIter iter);\r\nuint16_t _iter_next(BufferIter iter, Buffer *out);\r\nuint16_t _iter_start(uint32_t table_id, BufferIter *out);\r\nuint16_t _iter_start_filtered(\r\n uint32_t table_id,\r\n const uint8_t *filter,\r\n size_t filter_len,\r\n BufferIter *out\r\n);\r\n```\r\n\r\n[`bindings_sys::raw`]: https://github.com/clockworklabs/SpacetimeDB/blob/master/crates/bindings-sys/src/lib.rs#L44-L215\r\n[`bindings`]: https://github.com/clockworklabs/SpacetimeDB/blob/master/crates/bindings/src/lib.rs\r\n[module_ref]: /docs/languages/rust/rust-module-reference\r\n[module_quick_start]: /docs/languages/rust/rust-module-quick-start\r\n[wasm_c_abi]: https://github.com/WebAssembly/tool-conventions/blob/main/BasicCABI.md\r\n[c_header]: #appendix-bindingsh\r\n", - "hasPages": false, - "editUrl": "index.md", - "jumpLinks": [ - { - "title": "Module ABI Reference", - "route": "module-abi-reference", - "depth": 1 - }, - { - "title": "General notes", - "route": "general-notes", - "depth": 2 - }, - { - "title": "Logging", - "route": "logging", - "depth": 2 - }, - { - "title": "Buffer handling", - "route": "buffer-handling", - "depth": 2 - }, - { - "title": "Reducer scheduling", - "route": "reducer-scheduling", - "depth": 2 - }, - { - "title": "Altering tables", - "route": "altering-tables", - "depth": 2 - }, - { - "title": "Inserting and deleting rows", - "route": "inserting-and-deleting-rows", - "depth": 2 - }, - { - "title": "Querying tables", - "route": "querying-tables", - "depth": 2 - }, - { - "title": "Appendix, `bindings.h`", - "route": "appendix-bindings-h-", - "depth": 2 - } - ], - "pages": [] - } - ], - "previousKey": { - "title": "Client SDK Languages", - "route": "index", - "depth": 1 - }, - "nextKey": { - "title": "HTTP API Reference", - "route": "index", - "depth": 1 - } - }, - { - "title": "HTTP API Reference", - "identifier": "HTTP API Reference", - "indexIdentifier": "index", - "comingSoon": false, - "hasPages": true, - "editUrl": "HTTP%20API%20Reference/index.md", - "jumpLinks": [], - "pages": [ - { - "title": "`/database` HTTP API", - "identifier": "Databases", - "indexIdentifier": "Databases", - "hasPages": false, - "content": "# `/database` HTTP API\r\n\r\nThe 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.\r\n\r\n## At a glance\r\n\r\n| Route | Description |\r\n| ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |\r\n| [`/database/dns/:name GET`](#databasednsname-get) | Look up a database's address by its name. |\r\n| [`/database/reverse_dns/:address GET`](#databasereverse_dnsaddress-get) | Look up a database's name by its address. |\r\n| [`/database/set_name GET`](#databaseset_name-get) | Set a database's name, given its address. |\r\n| [`/database/ping GET`](#databaseping-get) | No-op. Used to determine whether a client can connect. |\r\n| [`/database/register_tld GET`](#databaseregister_tld-get) | Register a top-level domain. |\r\n| [`/database/request_recovery_code GET`](#databaserequest_recovery_code-get) | Request a recovery code to the email associated with an identity. |\r\n| [`/database/confirm_recovery_code GET`](#databaseconfirm_recovery_code-get) | Recover a login token from a recovery code. |\r\n| [`/database/publish POST`](#databasepublish-post) | Publish a database given its module code. |\r\n| [`/database/delete/:address POST`](#databasedeleteaddress-post) | Delete a database. |\r\n| [`/database/subscribe/:name_or_address GET`](#databasesubscribename_or_address-get) | Begin a [WebSocket connection](/docs/websocket-api-reference). |\r\n| [`/database/call/:name_or_address/:reducer POST`](#databasecallname_or_addressreducer-post) | Invoke a reducer in a database. |\r\n| [`/database/schema/:name_or_address GET`](#databaseschemaname_or_address-get) | Get the schema for a database. |\r\n| [`/database/schema/:name_or_address/:entity_type/:entity GET`](#databaseschemaname_or_addressentity_typeentity-get) | Get a schema for a particular table or reducer. |\r\n| [`/database/info/:name_or_address GET`](#databaseinfoname_or_address-get) | Get a JSON description of a database. |\r\n| [`/database/logs/:name_or_address GET`](#databaselogsname_or_address-get) | Retrieve logs from a database. |\r\n| [`/database/sql/:name_or_address POST`](#databasesqlname_or_address-post) | Run a SQL query against a database. |\r\n\r\n## `/database/dns/:name GET`\r\n\r\nLook up a database's address by its name.\r\n\r\nAccessible through the CLI as `spacetime dns lookup `.\r\n\r\n#### Parameters\r\n\r\n| Name | Value |\r\n| ------- | ------------------------- |\r\n| `:name` | The name of the database. |\r\n\r\n#### Returns\r\n\r\nIf a database with that name exists, returns JSON in the form:\r\n\r\n```typescript\r\n{ \"Success\": {\r\n \"domain\": string,\r\n \"address\": string\r\n} }\r\n```\r\n\r\nIf no database with that name exists, returns JSON in the form:\r\n\r\n```typescript\r\n{ \"Failure\": {\r\n \"domain\": string\r\n} }\r\n```\r\n\r\n## `/database/reverse_dns/:address GET`\r\n\r\nLook up a database's name by its address.\r\n\r\nAccessible through the CLI as `spacetime dns reverse-lookup
`.\r\n\r\n#### Parameters\r\n\r\n| Name | Value |\r\n| ---------- | ---------------------------- |\r\n| `:address` | The address of the database. |\r\n\r\n#### Returns\r\n\r\nReturns JSON in the form:\r\n\r\n```typescript\r\n{ \"names\": array }\r\n```\r\n\r\nwhere `` is a JSON array of strings, each of which is a name which refers to the database.\r\n\r\n## `/database/set_name GET`\r\n\r\nSet the name associated with a database.\r\n\r\nAccessible through the CLI as `spacetime dns set-name
`.\r\n\r\n#### Query Parameters\r\n\r\n| Name | Value |\r\n| -------------- | ------------------------------------------------------------------------- |\r\n| `address` | The address of the database to be named. |\r\n| `domain` | The name to register. |\r\n| `register_tld` | A boolean; whether to register the name as a TLD. Should usually be true. |\r\n\r\n#### Required Headers\r\n\r\n| Name | Value |\r\n| --------------- | ------------------------------------------------------------------------------------------- |\r\n| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). |\r\n\r\n#### Returns\r\n\r\nIf the name was successfully set, returns JSON in the form:\r\n\r\n```typescript\r\n{ \"Success\": {\r\n \"domain\": string,\r\n \"address\": string\r\n} }\r\n```\r\n\r\nIf the top-level domain is not registered, and `register_tld` was not specified, returns JSON in the form:\r\n\r\n```typescript\r\n{ \"TldNotRegistered\": {\r\n \"domain\": string\r\n} }\r\n```\r\n\r\nIf 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:\r\n\r\n```typescript\r\n{ \"PermissionDenied\": {\r\n \"domain\": string\r\n} }\r\n```\r\n\r\n> 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.\r\n\r\n## `/database/ping GET`\r\n\r\nDoes nothing and returns no data. Clients can send requests to this endpoint to determine whether they are able to connect to SpacetimeDB.\r\n\r\n## `/database/register_tld GET`\r\n\r\nRegister 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.\r\n\r\n> 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.\r\n\r\nAccessible through the CLI as `spacetime dns register-tld `.\r\n\r\n#### Query Parameters\r\n\r\n| Name | Value |\r\n| ----- | -------------------------------------- |\r\n| `tld` | New top-level domain name to register. |\r\n\r\n#### Required Headers\r\n\r\n| Name | Value |\r\n| --------------- | ------------------------------------------------------------------------------------------- |\r\n| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). |\r\n\r\n#### Returns\r\n\r\nIf the domain is successfully registered, returns JSON in the form:\r\n\r\n```typescript\r\n{ \"Success\": {\r\n \"domain\": string\r\n} }\r\n```\r\n\r\nIf the domain is already registered to the caller, returns JSON in the form:\r\n\r\n```typescript\r\n{ \"AlreadyRegistered\": {\r\n \"domain\": string\r\n} }\r\n```\r\n\r\nIf the domain is already registered to another identity, returns JSON in the form:\r\n\r\n```typescript\r\n{ \"Unauthorized\": {\r\n \"domain\": string\r\n} }\r\n```\r\n\r\n## `/database/request_recovery_code GET`\r\n\r\nRequest a recovery code or link via email, in order to recover the token associated with an identity.\r\n\r\nAccessible through the CLI as `spacetime identity recover `.\r\n\r\n#### Query Parameters\r\n\r\n| Name | Value |\r\n| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\r\n| `identity` | The identity whose token should be recovered. |\r\n| `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). |\r\n| `link` | A boolean; whether to send a clickable link rather than a recovery code. |\r\n\r\n## `/database/confirm_recovery_code GET`\r\n\r\nConfirm 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.\r\n\r\nAccessible through the CLI as `spacetime identity recover `.\r\n\r\n#### Query Parameters\r\n\r\n| Name | Value |\r\n| ---------- | --------------------------------------------- |\r\n| `identity` | The identity whose token should be recovered. |\r\n| `email` | The email which received the recovery code. |\r\n| `code` | The recovery code received via email. |\r\n\r\nOn success, returns JSON in the form:\r\n\r\n```typescript\r\n{\r\n \"identity\": string,\r\n \"token\": string\r\n}\r\n```\r\n\r\n## `/database/publish POST`\r\n\r\nPublish a database.\r\n\r\nAccessible through the CLI as `spacetime publish`.\r\n\r\n#### Query Parameters\r\n\r\n| Name | Value |\r\n| ----------------- | ------------------------------------------------------------------------------------------------ |\r\n| `host_type` | Optional; a SpacetimeDB module host type. Currently, only `\"wasmer\"` is supported. |\r\n| `clear` | A boolean; whether to clear any existing data when updating an existing database. |\r\n| `name_or_address` | The name of the database to publish or update, or the address of an existing database to update. |\r\n| `register_tld` | A boolean; whether to register the database's top-level domain. |\r\n\r\n#### Required Headers\r\n\r\n| Name | Value |\r\n| --------------- | ------------------------------------------------------------------------------------------- |\r\n| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). |\r\n\r\n#### Data\r\n\r\nA WebAssembly module in the [binary format](https://webassembly.github.io/spec/core/binary/index.html).\r\n\r\n#### Returns\r\n\r\nIf the database was successfully published, returns JSON in the form:\r\n\r\n```typescript\r\n{ \"Success\": {\r\n \"domain\": null | string,\r\n \"address\": string,\r\n \"op\": \"created\" | \"updated\"\r\n} }\r\n```\r\n\r\nIf the top-level domain for the requested name is not registered, returns JSON in the form:\r\n\r\n```typescript\r\n{ \"TldNotRegistered\": {\r\n \"domain\": string\r\n} }\r\n```\r\n\r\nIf 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:\r\n\r\n```typescript\r\n{ \"PermissionDenied\": {\r\n \"domain\": string\r\n} }\r\n```\r\n\r\n> 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.\r\n\r\n## `/database/delete/:address POST`\r\n\r\nDelete a database.\r\n\r\nAccessible through the CLI as `spacetime delete
`.\r\n\r\n#### Parameters\r\n\r\n| Name | Address |\r\n| ---------- | ---------------------------- |\r\n| `:address` | The address of the database. |\r\n\r\n#### Required Headers\r\n\r\n| Name | Value |\r\n| --------------- | ------------------------------------------------------------------------------------------- |\r\n| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). |\r\n\r\n## `/database/subscribe/:name_or_address GET`\r\n\r\nBegin a [WebSocket connection](/docs/websocket-api-reference) with a database.\r\n\r\n#### Parameters\r\n\r\n| Name | Value |\r\n| ------------------ | ---------------------------- |\r\n| `:name_or_address` | The address of the database. |\r\n\r\n#### Required Headers\r\n\r\nFor more information about WebSocket headers, see [RFC 6455](https://datatracker.ietf.org/doc/html/rfc6455).\r\n\r\n| Name | Value |\r\n| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |\r\n| `Sec-WebSocket-Protocol` | [`v1.bin.spacetimedb`](/docs/websocket-api-reference#binary-protocol) or [`v1.text.spacetimedb`](/docs/websocket-api-reference#text-protocol). |\r\n| `Connection` | `Updgrade` |\r\n| `Upgrade` | `websocket` |\r\n| `Sec-WebSocket-Version` | `13` |\r\n| `Sec-WebSocket-Key` | A 16-byte value, generated randomly by the client, encoded as Base64. |\r\n\r\n#### Optional Headers\r\n\r\n| Name | Value |\r\n| --------------- | ------------------------------------------------------------------------------------------- |\r\n| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). |\r\n\r\n## `/database/call/:name_or_address/:reducer POST`\r\n\r\nInvoke a reducer in a database.\r\n\r\n#### Parameters\r\n\r\n| Name | Value |\r\n| ------------------ | ------------------------------------ |\r\n| `:name_or_address` | The name or address of the database. |\r\n| `:reducer` | The name of the reducer. |\r\n\r\n#### Required Headers\r\n\r\n| Name | Value |\r\n| --------------- | ------------------------------------------------------------------------------------------- |\r\n| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). |\r\n\r\n#### Data\r\n\r\nA JSON array of arguments to the reducer.\r\n\r\n## `/database/schema/:name_or_address GET`\r\n\r\nGet a schema for a database.\r\n\r\nAccessible through the CLI as `spacetime describe `.\r\n\r\n#### Parameters\r\n\r\n| Name | Value |\r\n| ------------------ | ------------------------------------ |\r\n| `:name_or_address` | The name or address of the database. |\r\n\r\n#### Query Parameters\r\n\r\n| Name | Value |\r\n| -------- | ----------------------------------------------------------- |\r\n| `expand` | A boolean; whether to include full schemas for each entity. |\r\n\r\n#### Returns\r\n\r\nReturns a JSON object with two properties, `\"entities\"` and `\"typespace\"`. For example, on the default module generated by `spacetime init` with `expand=true`, returns:\r\n\r\n```typescript\r\n{\r\n \"entities\": {\r\n \"Person\": {\r\n \"arity\": 1,\r\n \"schema\": {\r\n \"elements\": [\r\n {\r\n \"algebraic_type\": {\r\n \"Builtin\": {\r\n \"String\": []\r\n }\r\n },\r\n \"name\": {\r\n \"some\": \"name\"\r\n }\r\n }\r\n ]\r\n },\r\n \"type\": \"table\"\r\n },\r\n \"__init__\": {\r\n \"arity\": 0,\r\n \"schema\": {\r\n \"elements\": [],\r\n \"name\": \"__init__\"\r\n },\r\n \"type\": \"reducer\"\r\n },\r\n \"add\": {\r\n \"arity\": 1,\r\n \"schema\": {\r\n \"elements\": [\r\n {\r\n \"algebraic_type\": {\r\n \"Builtin\": {\r\n \"String\": []\r\n }\r\n },\r\n \"name\": {\r\n \"some\": \"name\"\r\n }\r\n }\r\n ],\r\n \"name\": \"add\"\r\n },\r\n \"type\": \"reducer\"\r\n },\r\n \"say_hello\": {\r\n \"arity\": 0,\r\n \"schema\": {\r\n \"elements\": [],\r\n \"name\": \"say_hello\"\r\n },\r\n \"type\": \"reducer\"\r\n }\r\n },\r\n \"typespace\": [\r\n {\r\n \"Product\": {\r\n \"elements\": [\r\n {\r\n \"algebraic_type\": {\r\n \"Builtin\": {\r\n \"String\": []\r\n }\r\n },\r\n \"name\": {\r\n \"some\": \"name\"\r\n }\r\n }\r\n ]\r\n }\r\n }\r\n ]\r\n}\r\n```\r\n\r\nThe `\"entities\"` will be an object whose keys are table and reducer names, and whose values are objects of the form:\r\n\r\n```typescript\r\n{\r\n \"arity\": number,\r\n \"type\": \"table\" | \"reducer\",\r\n \"schema\"?: ProductType\r\n}\r\n```\r\n\r\n| Entity field | Value |\r\n| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\r\n| `arity` | For tables, the number of colums; for reducers, the number of arguments. |\r\n| `type` | For tables, `\"table\"`; for reducers, `\"reducer\"`. |\r\n| `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. |\r\n\r\nThe `\"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]`.\r\n\r\n## `/database/schema/:name_or_address/:entity_type/:entity GET`\r\n\r\nGet a schema for a particular table or reducer in a database.\r\n\r\nAccessible through the CLI as `spacetime describe `.\r\n\r\n#### Parameters\r\n\r\n| Name | Value |\r\n| ------------------ | ---------------------------------------------------------------- |\r\n| `:name_or_address` | The name or address of the database. |\r\n| `:entity_type` | `reducer` to describe a reducer, or `table` to describe a table. |\r\n| `:entity` | The name of the reducer or table. |\r\n\r\n#### Query Parameters\r\n\r\n| Name | Value |\r\n| -------- | ------------------------------------------------------------- |\r\n| `expand` | A boolean; whether to include the full schema for the entity. |\r\n\r\n#### Returns\r\n\r\nReturns 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):\r\n\r\n```typescript\r\n{\r\n \"arity\": number,\r\n \"type\": \"table\" | \"reducer\",\r\n \"schema\"?: ProductType,\r\n}\r\n```\r\n\r\n| Field | Value |\r\n| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\r\n| `arity` | For tables, the number of colums; for reducers, the number of arguments. |\r\n| `type` | For tables, `\"table\"`; for reducers, `\"reducer\"`. |\r\n| `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. |\r\n\r\n## `/database/info/:name_or_address GET`\r\n\r\nGet a database's address, owner identity, host type, number of replicas and a hash of its WASM module.\r\n\r\n#### Parameters\r\n\r\n| Name | Value |\r\n| ------------------ | ------------------------------------ |\r\n| `:name_or_address` | The name or address of the database. |\r\n\r\n#### Returns\r\n\r\nReturns JSON in the form:\r\n\r\n```typescript\r\n{\r\n \"address\": string,\r\n \"identity\": string,\r\n \"host_type\": \"wasmer\",\r\n \"num_replicas\": number,\r\n \"program_bytes_address\": string\r\n}\r\n```\r\n\r\n| Field | Type | Meaning |\r\n| ------------------------- | ------ | ----------------------------------------------------------- |\r\n| `\"address\"` | String | The address of the database. |\r\n| `\"identity\"` | String | The Spacetime identity of the database's owner. |\r\n| `\"host_type\"` | String | The module host type; currently always `\"wasmer\"`. |\r\n| `\"num_replicas\"` | Number | The number of replicas of the database. Currently always 1. |\r\n| `\"program_bytes_address\"` | String | Hash of the WASM module for the database. |\r\n\r\n## `/database/logs/:name_or_address GET`\r\n\r\nRetrieve logs from a database.\r\n\r\nAccessible through the CLI as `spacetime logs `.\r\n\r\n#### Parameters\r\n\r\n| Name | Value |\r\n| ------------------ | ------------------------------------ |\r\n| `:name_or_address` | The name or address of the database. |\r\n\r\n#### Query Parameters\r\n\r\n| Name | Value |\r\n| ----------- | --------------------------------------------------------------- |\r\n| `num_lines` | Number of most-recent log lines to retrieve. |\r\n| `follow` | A boolean; whether to continue receiving new logs via a stream. |\r\n\r\n#### Required Headers\r\n\r\n| Name | Value |\r\n| --------------- | ------------------------------------------------------------------------------------------- |\r\n| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). |\r\n\r\n#### Returns\r\n\r\nText, or streaming text if `follow` is supplied, containing log lines.\r\n\r\n## `/database/sql/:name_or_address POST`\r\n\r\nRun a SQL query against a database.\r\n\r\nAccessible through the CLI as `spacetime sql `.\r\n\r\n#### Parameters\r\n\r\n| Name | Value |\r\n| ------------------ | --------------------------------------------- |\r\n| `:name_or_address` | The name or address of the database to query. |\r\n\r\n#### Required Headers\r\n\r\n| Name | Value |\r\n| --------------- | ------------------------------------------------------------------------------------------- |\r\n| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). |\r\n\r\n#### Data\r\n\r\nSQL queries, separated by `;`.\r\n\r\n#### Returns\r\n\r\nReturns a JSON array of statement results, each of which takes the form:\r\n\r\n```typescript\r\n{\r\n \"schema\": ProductType,\r\n \"rows\": array\r\n}\r\n```\r\n\r\nThe `schema` will be a [JSON-encoded `ProductType`](/docs/satn-reference/satn-reference-json-format) describing the type of the returned rows.\r\n\r\nThe `rows` will be an array of [JSON-encoded `ProductValue`s](/docs/satn-reference/satn-reference-json-format), each of which conforms to the `schema`.\r\n", - "editUrl": "Databases.md", - "jumpLinks": [ - { - "title": "`/database` HTTP API", - "route": "-database-http-api", - "depth": 1 - }, - { - "title": "At a glance", - "route": "at-a-glance", - "depth": 2 - }, - { - "title": "`/database/dns/:name GET`", - "route": "-database-dns-name-get-", - "depth": 2 - }, - { - "title": "Parameters", - "route": "parameters", - "depth": 4 - }, - { - "title": "Returns", - "route": "returns", - "depth": 4 - }, - { - "title": "`/database/reverse_dns/:address GET`", - "route": "-database-reverse_dns-address-get-", - "depth": 2 - }, - { - "title": "Parameters", - "route": "parameters", - "depth": 4 - }, - { - "title": "Returns", - "route": "returns", - "depth": 4 - }, - { - "title": "`/database/set_name GET`", - "route": "-database-set_name-get-", - "depth": 2 - }, - { - "title": "Query Parameters", - "route": "query-parameters", - "depth": 4 - }, - { - "title": "Required Headers", - "route": "required-headers", - "depth": 4 - }, - { - "title": "Returns", - "route": "returns", - "depth": 4 - }, - { - "title": "`/database/ping GET`", - "route": "-database-ping-get-", - "depth": 2 - }, - { - "title": "`/database/register_tld GET`", - "route": "-database-register_tld-get-", - "depth": 2 - }, - { - "title": "Query Parameters", - "route": "query-parameters", - "depth": 4 - }, - { - "title": "Required Headers", - "route": "required-headers", - "depth": 4 - }, - { - "title": "Returns", - "route": "returns", - "depth": 4 - }, - { - "title": "`/database/request_recovery_code GET`", - "route": "-database-request_recovery_code-get-", - "depth": 2 - }, - { - "title": "Query Parameters", - "route": "query-parameters", - "depth": 4 - }, - { - "title": "`/database/confirm_recovery_code GET`", - "route": "-database-confirm_recovery_code-get-", - "depth": 2 - }, - { - "title": "Query Parameters", - "route": "query-parameters", - "depth": 4 - }, - { - "title": "`/database/publish POST`", - "route": "-database-publish-post-", - "depth": 2 - }, - { - "title": "Query Parameters", - "route": "query-parameters", - "depth": 4 - }, - { - "title": "Required Headers", - "route": "required-headers", - "depth": 4 - }, - { - "title": "Data", - "route": "data", - "depth": 4 - }, - { - "title": "Returns", - "route": "returns", - "depth": 4 - }, - { - "title": "`/database/delete/:address POST`", - "route": "-database-delete-address-post-", - "depth": 2 - }, - { - "title": "Parameters", - "route": "parameters", - "depth": 4 - }, - { - "title": "Required Headers", - "route": "required-headers", - "depth": 4 - }, - { - "title": "`/database/subscribe/:name_or_address GET`", - "route": "-database-subscribe-name_or_address-get-", - "depth": 2 - }, - { - "title": "Parameters", - "route": "parameters", - "depth": 4 - }, - { - "title": "Required Headers", - "route": "required-headers", - "depth": 4 - }, - { - "title": "Optional Headers", - "route": "optional-headers", - "depth": 4 - }, - { - "title": "`/database/call/:name_or_address/:reducer POST`", - "route": "-database-call-name_or_address-reducer-post-", - "depth": 2 - }, - { - "title": "Parameters", - "route": "parameters", - "depth": 4 - }, - { - "title": "Required Headers", - "route": "required-headers", - "depth": 4 - }, - { - "title": "Data", - "route": "data", - "depth": 4 - }, - { - "title": "`/database/schema/:name_or_address GET`", - "route": "-database-schema-name_or_address-get-", - "depth": 2 - }, - { - "title": "Parameters", - "route": "parameters", - "depth": 4 - }, - { - "title": "Query Parameters", - "route": "query-parameters", - "depth": 4 - }, - { - "title": "Returns", - "route": "returns", - "depth": 4 - }, - { - "title": "`/database/schema/:name_or_address/:entity_type/:entity GET`", - "route": "-database-schema-name_or_address-entity_type-entity-get-", - "depth": 2 - }, - { - "title": "Parameters", - "route": "parameters", - "depth": 4 - }, - { - "title": "Query Parameters", - "route": "query-parameters", - "depth": 4 - }, - { - "title": "Returns", - "route": "returns", - "depth": 4 - }, - { - "title": "`/database/info/:name_or_address GET`", - "route": "-database-info-name_or_address-get-", - "depth": 2 - }, - { - "title": "Parameters", - "route": "parameters", - "depth": 4 - }, - { - "title": "Returns", - "route": "returns", - "depth": 4 - }, - { - "title": "`/database/logs/:name_or_address GET`", - "route": "-database-logs-name_or_address-get-", - "depth": 2 - }, - { - "title": "Parameters", - "route": "parameters", - "depth": 4 - }, - { - "title": "Query Parameters", - "route": "query-parameters", - "depth": 4 - }, - { - "title": "Required Headers", - "route": "required-headers", - "depth": 4 - }, - { - "title": "Returns", - "route": "returns", - "depth": 4 - }, - { - "title": "`/database/sql/:name_or_address POST`", - "route": "-database-sql-name_or_address-post-", - "depth": 2 - }, - { - "title": "Parameters", - "route": "parameters", - "depth": 4 - }, - { - "title": "Required Headers", - "route": "required-headers", - "depth": 4 - }, - { - "title": "Data", - "route": "data", - "depth": 4 - }, - { - "title": "Returns", - "route": "returns", - "depth": 4 - } - ], - "pages": [] - }, - { - "title": "`/energy` HTTP API", - "identifier": "Energy", - "indexIdentifier": "Energy", - "hasPages": false, - "content": "# `/energy` HTTP API\r\n\r\nThe HTTP endpoints in `/energy` allow clients to query identities' energy balances. Spacetime databases expend energy from their owners' balances while executing reducers.\r\n\r\n## At a glance\r\n\r\n| Route | Description |\r\n| ------------------------------------------------ | --------------------------------------------------------- |\r\n| [`/energy/:identity GET`](#energyidentity-get) | Get the remaining energy balance for the user `identity`. |\r\n| [`/energy/:identity POST`](#energyidentity-post) | Set the energy balance for the user `identity`. |\r\n\r\n## `/energy/:identity GET`\r\n\r\nGet the energy balance of an identity.\r\n\r\nAccessible through the CLI as `spacetime energy status `.\r\n\r\n#### Parameters\r\n\r\n| Name | Value |\r\n| ----------- | ----------------------- |\r\n| `:identity` | The Spacetime identity. |\r\n\r\n#### Returns\r\n\r\nReturns JSON in the form:\r\n\r\n```typescript\r\n{\r\n \"balance\": string\r\n}\r\n```\r\n\r\n| Field | Value |\r\n| --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |\r\n| `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. |\r\n\r\n## `/energy/:identity POST`\r\n\r\nSet the energy balance for an identity.\r\n\r\nNote that in the SpacetimeDB 0.6 Testnet, this endpoint always returns code 401, `UNAUTHORIZED`. Testnet energy balances cannot be refilled.\r\n\r\nAccessible through the CLI as `spacetime energy set-balance `.\r\n\r\n#### Parameters\r\n\r\n| Name | Value |\r\n| ----------- | ----------------------- |\r\n| `:identity` | The Spacetime identity. |\r\n\r\n#### Query Parameters\r\n\r\n| Name | Value |\r\n| --------- | ------------------------------------------ |\r\n| `balance` | A decimal integer; the new balance to set. |\r\n\r\n#### Required Headers\r\n\r\n| Name | Value |\r\n| --------------- | ------------------------------------------------------------------------------------------- |\r\n| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). |\r\n\r\n#### Returns\r\n\r\nReturns JSON in the form:\r\n\r\n```typescript\r\n{\r\n \"balance\": number\r\n}\r\n```\r\n\r\n| Field | Value |\r\n| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\r\n| `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. |\r\n", - "editUrl": "Energy.md", - "jumpLinks": [ - { - "title": "`/energy` HTTP API", - "route": "-energy-http-api", - "depth": 1 - }, - { - "title": "At a glance", - "route": "at-a-glance", - "depth": 2 - }, - { - "title": "`/energy/:identity GET`", - "route": "-energy-identity-get-", - "depth": 2 - }, - { - "title": "Parameters", - "route": "parameters", - "depth": 4 - }, - { - "title": "Returns", - "route": "returns", - "depth": 4 - }, - { - "title": "`/energy/:identity POST`", - "route": "-energy-identity-post-", - "depth": 2 - }, - { - "title": "Parameters", - "route": "parameters", - "depth": 4 - }, - { - "title": "Query Parameters", - "route": "query-parameters", - "depth": 4 - }, - { - "title": "Required Headers", - "route": "required-headers", - "depth": 4 - }, - { - "title": "Returns", - "route": "returns", - "depth": 4 - } - ], - "pages": [] - }, - { - "title": "`/identity` HTTP API", - "identifier": "Identities", - "indexIdentifier": "Identities", - "hasPages": false, - "content": "# `/identity` HTTP API\r\n\r\nThe HTTP endpoints in `/identity` allow clients to generate and manage Spacetime public identities and private tokens.\r\n\r\n## At a glance\r\n\r\n| Route | Description |\r\n| ----------------------------------------------------------------------- | ------------------------------------------------------------------ |\r\n| [`/identity GET`](#identity-get) | Look up an identity by email. |\r\n| [`/identity POST`](#identity-post) | Generate a new identity and token. |\r\n| [`/identity/websocket_token POST`](#identitywebsocket_token-post) | Generate a short-lived access token for use in untrusted contexts. |\r\n| [`/identity/:identity/set-email POST`](#identityidentityset-email-post) | Set the email for an identity. |\r\n| [`/identity/:identity/databases GET`](#identityidentitydatabases-get) | List databases owned by an identity. |\r\n| [`/identity/:identity/verify GET`](#identityidentityverify-get) | Verify an identity and token. |\r\n\r\n## `/identity GET`\r\n\r\nLook up Spacetime identities associated with an email.\r\n\r\nAccessible through the CLI as `spacetime identity find `.\r\n\r\n#### Query Parameters\r\n\r\n| Name | Value |\r\n| ------- | ------------------------------- |\r\n| `email` | An email address to search for. |\r\n\r\n#### Returns\r\n\r\nReturns JSON in the form:\r\n\r\n```typescript\r\n{\r\n \"identities\": [\r\n {\r\n \"identity\": string,\r\n \"email\": string\r\n }\r\n ]\r\n}\r\n```\r\n\r\nThe `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.\r\n\r\n## `/identity POST`\r\n\r\nCreate a new identity.\r\n\r\nAccessible through the CLI as `spacetime identity new`.\r\n\r\n#### Query Parameters\r\n\r\n| Name | Value |\r\n| ------- | ----------------------------------------------------------------------------------------------------------------------- |\r\n| `email` | An email address to associate with the new identity. If unsupplied, the new identity will not have an associated email. |\r\n\r\n#### Returns\r\n\r\nReturns JSON in the form:\r\n\r\n```typescript\r\n{\r\n \"identity\": string,\r\n \"token\": string\r\n}\r\n```\r\n\r\n## `/identity/websocket_token POST`\r\n\r\nGenerate a short-lived access token which can be used in untrusted contexts, e.g. embedded in URLs.\r\n\r\n#### Required Headers\r\n\r\n| Name | Value |\r\n| --------------- | ------------------------------------------------------------------------------------------- |\r\n| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). |\r\n\r\n#### Returns\r\n\r\nReturns JSON in the form:\r\n\r\n```typescript\r\n{\r\n \"token\": string\r\n}\r\n```\r\n\r\nThe `token` value is a short-lived [JSON Web Token](https://datatracker.ietf.org/doc/html/rfc7519).\r\n\r\n## `/identity/:identity/set-email POST`\r\n\r\nAssociate an email with a Spacetime identity.\r\n\r\nAccessible through the CLI as `spacetime identity set-email `.\r\n\r\n#### Parameters\r\n\r\n| Name | Value |\r\n| ----------- | ----------------------------------------- |\r\n| `:identity` | The identity to associate with the email. |\r\n\r\n#### Query Parameters\r\n\r\n| Name | Value |\r\n| ------- | ----------------- |\r\n| `email` | An email address. |\r\n\r\n#### Required Headers\r\n\r\n| Name | Value |\r\n| --------------- | ------------------------------------------------------------------------------------------- |\r\n| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). |\r\n\r\n## `/identity/:identity/databases GET`\r\n\r\nList all databases owned by an identity.\r\n\r\n#### Parameters\r\n\r\n| Name | Value |\r\n| ----------- | --------------------- |\r\n| `:identity` | A Spacetime identity. |\r\n\r\n#### Returns\r\n\r\nReturns JSON in the form:\r\n\r\n```typescript\r\n{\r\n \"addresses\": array\r\n}\r\n```\r\n\r\nThe `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.\r\n\r\n## `/identity/:identity/verify GET`\r\n\r\nVerify the validity of an identity/token pair.\r\n\r\n#### Parameters\r\n\r\n| Name | Value |\r\n| ----------- | ----------------------- |\r\n| `:identity` | The identity to verify. |\r\n\r\n#### Required Headers\r\n\r\n| Name | Value |\r\n| --------------- | ------------------------------------------------------------------------------------------- |\r\n| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http-api-reference/authorization). |\r\n\r\n#### Returns\r\n\r\nReturns no data.\r\n\r\nIf the token is valid and matches the identity, returns `204 No Content`.\r\n\r\nIf the token is valid but does not match the identity, returns `400 Bad Request`.\r\n\r\nIf the token is invalid, or no `Authorization` header is included in the request, returns `401 Unauthorized`.\r\n", - "editUrl": "Identities.md", - "jumpLinks": [ - { - "title": "`/identity` HTTP API", - "route": "-identity-http-api", - "depth": 1 - }, - { - "title": "At a glance", - "route": "at-a-glance", - "depth": 2 - }, - { - "title": "`/identity GET`", - "route": "-identity-get-", - "depth": 2 - }, - { - "title": "Query Parameters", - "route": "query-parameters", - "depth": 4 - }, - { - "title": "Returns", - "route": "returns", - "depth": 4 - }, - { - "title": "`/identity POST`", - "route": "-identity-post-", - "depth": 2 - }, - { - "title": "Query Parameters", - "route": "query-parameters", - "depth": 4 - }, - { - "title": "Returns", - "route": "returns", - "depth": 4 - }, - { - "title": "`/identity/websocket_token POST`", - "route": "-identity-websocket_token-post-", - "depth": 2 - }, - { - "title": "Required Headers", - "route": "required-headers", - "depth": 4 - }, - { - "title": "Returns", - "route": "returns", - "depth": 4 - }, - { - "title": "`/identity/:identity/set-email POST`", - "route": "-identity-identity-set-email-post-", - "depth": 2 - }, - { - "title": "Parameters", - "route": "parameters", - "depth": 4 - }, - { - "title": "Query Parameters", - "route": "query-parameters", - "depth": 4 - }, - { - "title": "Required Headers", - "route": "required-headers", - "depth": 4 - }, - { - "title": "`/identity/:identity/databases GET`", - "route": "-identity-identity-databases-get-", - "depth": 2 - }, - { - "title": "Parameters", - "route": "parameters", - "depth": 4 - }, - { - "title": "Returns", - "route": "returns", - "depth": 4 - }, - { - "title": "`/identity/:identity/verify GET`", - "route": "-identity-identity-verify-get-", - "depth": 2 - }, - { - "title": "Parameters", - "route": "parameters", - "depth": 4 - }, - { - "title": "Required Headers", - "route": "required-headers", - "depth": 4 - }, - { - "title": "Returns", - "route": "returns", - "depth": 4 - } - ], - "pages": [] - }, - { - "title": "SpacetimeDB HTTP Authorization", - "identifier": "index", - "indexIdentifier": "index", - "content": "# SpacetimeDB HTTP Authorization\r\n\r\nRather 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.\r\n\r\n> Do not share your SpacetimeDB token with anyone, ever.\r\n\r\n### Generating identities and tokens\r\n\r\nClients can request a new identity and token via [the `/identity POST` HTTP endpoint](/docs/http-api-reference/identities#identity-post).\r\n\r\nAlternately, 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).\r\n\r\n### Encoding `Authorization` headers\r\n\r\nMany 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.\r\n\r\nTo construct an appropriate `Authorization` header value for a `token`:\r\n\r\n1. Prepend the string `token:`.\r\n2. Base64-encode.\r\n3. Prepend the string `Basic `.\r\n\r\n#### Python\r\n\r\n```python\r\ndef auth_header_value(token):\r\n username_and_password = f\"token:{token}\".encode(\"utf-8\")\r\n base64_encoded = base64.b64encode(username_and_password).decode(\"utf-8\")\r\n return f\"Basic {base64_encoded}\"\r\n```\r\n\r\n#### Rust\r\n\r\n```rust\r\nfn auth_header_value(token: &str) -> String {\r\n let username_and_password = format!(\"token:{}\", token);\r\n let base64_encoded = base64::prelude::BASE64_STANDARD.encode(username_and_password);\r\n format!(\"Basic {}\", encoded)\r\n}\r\n```\r\n\r\n#### C#\r\n\r\n```csharp\r\npublic string AuthHeaderValue(string token)\r\n{\r\n var username_and_password = Encoding.UTF8.GetBytes($\"token:{auth}\");\r\n var base64_encoded = Convert.ToBase64String(username_and_password);\r\n return \"Basic \" + base64_encoded;\r\n}\r\n```\r\n", - "hasPages": false, - "editUrl": "index.md", - "jumpLinks": [ - { - "title": "SpacetimeDB HTTP Authorization", - "route": "spacetimedb-http-authorization", - "depth": 1 - }, - { - "title": "Generating identities and tokens", - "route": "generating-identities-and-tokens", - "depth": 3 - }, - { - "title": "Encoding `Authorization` headers", - "route": "encoding-authorization-headers", - "depth": 3 - }, - { - "title": "Python", - "route": "python", - "depth": 4 - }, - { - "title": "Rust", - "route": "rust", - "depth": 4 - }, - { - "title": "C#", - "route": "c-", - "depth": 4 - } - ], - "pages": [] - } - ], - "previousKey": { - "title": "Module ABI Reference", - "route": "index", - "depth": 1 - }, - "nextKey": { - "title": "SATN Reference", - "route": "index", - "depth": 1 - } - }, - { - "title": "SATN Reference", - "identifier": "SATN Reference", - "indexIdentifier": "index", - "comingSoon": false, - "hasPages": true, - "editUrl": "SATN%20Reference/index.md", - "jumpLinks": [], - "pages": [ - { - "title": "SATN Binary Format (BSATN)", - "identifier": "Binary Format", - "indexIdentifier": "Binary Format", - "hasPages": false, - "content": "# SATN Binary Format (BSATN)\r\n\r\nThe Spacetime Algebraic Type Notation binary (BSATN) format defines\r\nhow Spacetime `AlgebraicValue`s and friends are encoded as byte strings.\r\n\r\nAlgebraic values and product values are BSATN-encoded for e.g.,\r\nmodule-host communication and for storing row data in the database.\r\n\r\n## Notes on notation\r\n\r\nIn this reference, we give a formal definition of the format.\r\nTo do this, we use inductive definitions, and define the following notation:\r\n\r\n- `bsatn(x)` denotes a function converting some value `x` to a list of bytes.\r\n- `a: B` means that `a` is of type `B`.\r\n- `Foo(x)` denotes extracting `x` out of some variant or type `Foo`.\r\n- `a ++ b` denotes concatenating two byte lists `a` and `b`.\r\n- `bsatn(A) = bsatn(B) | ... | bsatn(Z)` where `B` to `Z` are variants of `A`\r\n means that `bsatn(A)` is defined as e.g.,\r\n `bsatn(B)`, `bsatn(C)`, .., `bsatn(Z)` depending on what variant of `A` it was.\r\n- `[]` denotes the empty list of bytes.\r\n\r\n## Values\r\n\r\n### At a glance\r\n\r\n| Type | Description |\r\n| ---------------- | ---------------------------------------------------------------- |\r\n| `AlgebraicValue` | A value whose type may be any [`AlgebraicType`](#algebraictype). |\r\n| `SumValue` | A value whose type is a [`SumType`](#sumtype). |\r\n| `ProductValue` | A value whose type is a [`ProductType`](#producttype). |\r\n| `BuiltinValue` | A value whose type is a [`BuiltinType`](#builtintype). |\r\n\r\n### `AlgebraicValue`\r\n\r\nThe BSATN encoding of an `AlgebraicValue` defers to the encoding of each variant:\r\n\r\n```fsharp\r\nbsatn(AlgebraicValue) = bsatn(SumValue) | bsatn(ProductValue) | bsatn(BuiltinValue)\r\n```\r\n\r\n### `SumValue`\r\n\r\nAn instance of a [`SumType`](#sumtype).\r\n`SumValue`s are binary-encoded as `bsatn(tag) ++ bsatn(variant_data)`\r\nwhere `tag: u8` is an index into the [`SumType.variants`](#sumtype)\r\narray of the value's [`SumType`](#sumtype),\r\nand where `variant_data` is the data of the variant.\r\nFor variants holding no data, i.e., of some zero sized type,\r\n`bsatn(variant_data) = []`.\r\n\r\n### `ProductValue`\r\n\r\nAn instance of a [`ProductType`](#producttype).\r\n`ProductValue`s are binary encoded as:\r\n\r\n```fsharp\r\nbsatn(elems) = bsatn(elem_0) ++ .. ++ bsatn(elem_n)\r\n```\r\n\r\nField names are not encoded.\r\n\r\n### `BuiltinValue`\r\n\r\nAn instance of a [`BuiltinType`](#builtintype).\r\nThe BSATN encoding of `BuiltinValue`s defers to the encoding of each variant:\r\n\r\n```fsharp\r\nbsatn(BuiltinValue)\r\n = bsatn(Bool)\r\n | bsatn(U8) | bsatn(U16) | bsatn(U32) | bsatn(U64) | bsatn(U128)\r\n | bsatn(I8) | bsatn(I16) | bsatn(I32) | bsatn(I64) | bsatn(I128)\r\n | bsatn(F32) | bsatn(F64)\r\n | bsatn(String)\r\n | bsatn(Array)\r\n | bsatn(Map)\r\n\r\nbsatn(Bool(b)) = bsatn(b as u8)\r\nbsatn(U8(x)) = [x]\r\nbsatn(U16(x: u16)) = to_little_endian_bytes(x)\r\nbsatn(U32(x: u32)) = to_little_endian_bytes(x)\r\nbsatn(U64(x: u64)) = to_little_endian_bytes(x)\r\nbsatn(U128(x: u128)) = to_little_endian_bytes(x)\r\nbsatn(I8(x: i8)) = to_little_endian_bytes(x)\r\nbsatn(I16(x: i16)) = to_little_endian_bytes(x)\r\nbsatn(I32(x: i32)) = to_little_endian_bytes(x)\r\nbsatn(I64(x: i64)) = to_little_endian_bytes(x)\r\nbsatn(I128(x: i128)) = to_little_endian_bytes(x)\r\nbsatn(F32(x: f32)) = bsatn(f32_to_raw_bits(x)) // lossless conversion\r\nbsatn(F64(x: f64)) = bsatn(f64_to_raw_bits(x)) // lossless conversion\r\nbsatn(String(s)) = bsatn(len(s) as u32) ++ bsatn(bytes(s))\r\nbsatn(Array(a)) = bsatn(len(a) as u32)\r\n ++ bsatn(normalize(a)_0) ++ .. ++ bsatn(normalize(a)_n)\r\nbsatn(Map(map)) = bsatn(len(m) as u32)\r\n ++ bsatn(key(map_0)) ++ bsatn(value(map_0))\r\n ..\r\n ++ bsatn(key(map_n)) ++ bsatn(value(map_n))\r\n```\r\n\r\nWhere\r\n\r\n- `f32_to_raw_bits(x)` is the raw transmute of `x: f32` to `u32`\r\n- `f64_to_raw_bits(x)` is the raw transmute of `x: f64` to `u64`\r\n- `normalize(a)` for `a: ArrayValue` converts `a` to a list of `AlgebraicValue`s\r\n- `key(map_i)` extracts the key of the `i`th entry of `map`\r\n- `value(map_i)` extracts the value of the `i`th entry of `map`\r\n\r\n## Types\r\n\r\nAll SATS types are BSATN-encoded by converting them to an `AlgebraicValue`,\r\nthen BSATN-encoding that meta-value.\r\n\r\nSee [the SATN JSON Format](/docs/satn-reference-json-format)\r\nfor more details of the conversion to meta values.\r\nNote that these meta values are converted to BSATN and _not JSON_.\r\n", - "editUrl": "Binary%20Format.md", - "jumpLinks": [ - { - "title": "SATN Binary Format (BSATN)", - "route": "satn-binary-format-bsatn-", - "depth": 1 - }, - { - "title": "Notes on notation", - "route": "notes-on-notation", - "depth": 2 - }, - { - "title": "Values", - "route": "values", - "depth": 2 - }, - { - "title": "At a glance", - "route": "at-a-glance", - "depth": 3 - }, - { - "title": "`AlgebraicValue`", - "route": "-algebraicvalue-", - "depth": 3 - }, - { - "title": "`SumValue`", - "route": "-sumvalue-", - "depth": 3 - }, - { - "title": "`ProductValue`", - "route": "-productvalue-", - "depth": 3 - }, - { - "title": "`BuiltinValue`", - "route": "-builtinvalue-", - "depth": 3 - }, - { - "title": "Types", - "route": "types", - "depth": 2 - } - ], - "pages": [] - }, - { - "title": "SATN JSON Format", - "identifier": "index", - "indexIdentifier": "index", - "content": "# SATN JSON Format\r\n\r\nThe 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).\r\n\r\n## Values\r\n\r\n### At a glance\r\n\r\n| Type | Description |\r\n| ---------------- | ---------------------------------------------------------------- |\r\n| `AlgebraicValue` | A value whose type may be any [`AlgebraicType`](#algebraictype). |\r\n| `SumValue` | A value whose type is a [`SumType`](#sumtype). |\r\n| `ProductValue` | A value whose type is a [`ProductType`](#producttype). |\r\n| `BuiltinValue` | A value whose type is a [`BuiltinType`](#builtintype). |\r\n| | |\r\n\r\n### `AlgebraicValue`\r\n\r\n```json\r\nSumValue | ProductValue | BuiltinValue\r\n```\r\n\r\n### `SumValue`\r\n\r\nAn instance of a [`SumType`](#sumtype). `SumValue`s are encoded as a JSON object with a single key, a non-negative integer tag which identifies the variant. The value associated with this key is the variant data. Variants which hold no data will have an empty array as their value.\r\n\r\nThe tag is an index into the [`SumType.variants`](#sumtype) array of the value's [`SumType`](#sumtype).\r\n\r\n```json\r\n{\r\n \"\": AlgebraicValue\r\n}\r\n```\r\n\r\n### `ProductValue`\r\n\r\nAn 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).\r\n\r\n```json\r\narray\r\n```\r\n\r\n### `BuiltinValue`\r\n\r\nAn instance of a [`BuiltinType`](#builtintype). `BuiltinValue`s are encoded as JSON values of corresponding types.\r\n\r\n```json\r\nboolean | number | string | array | map\r\n```\r\n\r\n| [`BuiltinType`](#builtintype) | JSON type |\r\n| ----------------------------- | ------------------------------------- |\r\n| `Bool` | `boolean` |\r\n| Integer types | `number` |\r\n| Float types | `number` |\r\n| `String` | `string` |\r\n| Array types | `array` |\r\n| Map types | `map` |\r\n\r\nAll SATS integer types are encoded as JSON `number`s, so values of 64-bit and 128-bit integer types may lose precision when encoding values larger than 2⁵².\r\n\r\n## Types\r\n\r\nAll SATS types are JSON-encoded by converting them to an `AlgebraicValue`, then JSON-encoding that meta-value.\r\n\r\n### At a glance\r\n\r\n| Type | Description |\r\n| --------------------------------------- | ------------------------------------------------------------------------------------ |\r\n| [`AlgebraicType`](#algebraictype) | Any SATS type. |\r\n| [`SumType`](#sumtype) | Sum types, i.e. tagged unions. |\r\n| [`ProductType`](#productype) | Product types, i.e. structures. |\r\n| [`BuiltinType`](#builtintype) | Built-in and primitive types, including booleans, numbers, strings, arrays and maps. |\r\n| [`AlgebraicTypeRef`](#algebraictyperef) | An indirect reference to a type, used to implement recursive types. |\r\n\r\n#### `AlgebraicType`\r\n\r\n`AlgebraicType` is the most general meta-type in the Spacetime Algebraic Type System. Any SATS type can be represented as an `AlgebraicType`. `AlgebraicType` is encoded as a tagged union, with variants for [`SumType`](#sumtype), [`ProductType`](#producttype), [`BuiltinType`](#builtintype) and [`AlgebraicTypeRef`](#algebraictyperef).\r\n\r\n```json\r\n{ \"Sum\": SumType }\r\n| { \"Product\": ProductType }\r\n| { \"Builtin\": BuiltinType }\r\n| { \"Ref\": AlgebraicTypeRef }\r\n```\r\n\r\n#### `SumType`\r\n\r\nThe meta-type `SumType` represents sum types, also called tagged unions or Rust `enum`s. A sum type has some number of variants, each of which has an `AlgebraicType` of variant data, and an optional string discriminant. For each instance, exactly one variant will be active. The instance will contain only that variant's data.\r\n\r\nA `SumType` with zero variants is called an empty type or never type because it is impossible to construct an instance.\r\n\r\nInstances of `SumType`s are [`SumValue`s](#sumvalue), and store a tag which identifies the active variant.\r\n\r\n```json\r\n// SumType:\r\n{\r\n \"variants\": array,\r\n}\r\n\r\n// SumTypeVariant:\r\n{\r\n \"algebraic_type\": AlgebraicType,\r\n \"name\": { \"some\": string } | { \"none\": [] }\r\n}\r\n```\r\n\r\n### `ProductType`\r\n\r\nThe meta-type `ProductType` represents product types, also called structs or tuples. A product type has some number of fields, each of which has an `AlgebraicType` of field data, and an optional string field name. Each instance will contain data for all of the product type's fields.\r\n\r\nA `ProductType` with zero fields is called a unit type because it has a single instance, the unit, which is empty.\r\n\r\nInstances of `ProductType`s are [`ProductValue`s](#productvalue), and store an array of field data.\r\n\r\n```json\r\n// ProductType:\r\n{\r\n \"elements\": array,\r\n}\r\n\r\n// ProductTypeElement:\r\n{\r\n \"algebraic_type\": AlgebraicType,\r\n \"name\": { \"some\": string } | { \"none\": [] }\r\n}\r\n```\r\n\r\n### `BuiltinType`\r\n\r\nThe meta-type `BuiltinType` represents SATS primitive types: booleans, integers, floating-point numbers, strings, arrays and maps. `BuiltinType` is encoded as a tagged union, with a variant for each SATS primitive type.\r\n\r\nSATS integer types are identified by their signedness and width in bits. SATS supports the same set of integer types as Rust, i.e. 8, 16, 32, 64 and 128-bit signed and unsigned integers.\r\n\r\nSATS floating-point number types are identified by their width in bits. SATS supports 32 and 64-bit floats, which correspond to [IEEE 754](https://en.wikipedia.org/wiki/IEEE_754) single- and double-precision binary floats, respectively.\r\n\r\nSATS array and map types are homogeneous, meaning that each array has a single element type to which all its elements must conform, and each map has a key type and a value type to which all of its keys and values must conform.\r\n\r\n```json\r\n{ \"Bool\": [] }\r\n| { \"I8\": [] }\r\n| { \"U8\": [] }\r\n| { \"I16\": [] }\r\n| { \"U16\": [] }\r\n| { \"I32\": [] }\r\n| { \"U32\": [] }\r\n| { \"I64\": [] }\r\n| { \"U64\": [] }\r\n| { \"I128\": [] }\r\n| { \"U128\": [] }\r\n| { \"F32\": [] }\r\n| { \"F64\": [] }\r\n| { \"String\": [] }\r\n| { \"Array\": AlgebraicType }\r\n| { \"Map\": {\r\n \"key_ty\": AlgebraicType,\r\n \"ty\": AlgebraicType,\r\n } }\r\n```\r\n\r\n### `AlgebraicTypeRef`\r\n\r\n`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).\r\n", - "hasPages": false, - "editUrl": "index.md", - "jumpLinks": [ - { - "title": "SATN JSON Format", - "route": "satn-json-format", - "depth": 1 - }, - { - "title": "Values", - "route": "values", - "depth": 2 - }, - { - "title": "At a glance", - "route": "at-a-glance", - "depth": 3 - }, - { - "title": "`AlgebraicValue`", - "route": "-algebraicvalue-", - "depth": 3 - }, - { - "title": "`SumValue`", - "route": "-sumvalue-", - "depth": 3 - }, - { - "title": "`ProductValue`", - "route": "-productvalue-", - "depth": 3 - }, - { - "title": "`BuiltinValue`", - "route": "-builtinvalue-", - "depth": 3 - }, - { - "title": "Types", - "route": "types", - "depth": 2 - }, - { - "title": "At a glance", - "route": "at-a-glance", - "depth": 3 - }, - { - "title": "`AlgebraicType`", - "route": "-algebraictype-", - "depth": 4 - }, - { - "title": "`SumType`", - "route": "-sumtype-", - "depth": 4 - }, - { - "title": "`ProductType`", - "route": "-producttype-", - "depth": 3 - }, - { - "title": "`BuiltinType`", - "route": "-builtintype-", - "depth": 3 - }, - { - "title": "`AlgebraicTypeRef`", - "route": "-algebraictyperef-", - "depth": 3 - } - ], - "pages": [] - } - ], - "previousKey": { - "title": "HTTP API Reference", - "route": "index", - "depth": 1 - }, - "nextKey": { - "title": "SQL Reference", - "route": "index", - "depth": 1 - } - }, - { - "title": "SQL Reference", - "identifier": "SQL Reference", - "indexIdentifier": "index", - "comingSoon": false, - "hasPages": true, - "editUrl": "SQL%20Reference/index.md", - "jumpLinks": [], - "pages": [ - { - "title": "SQL Support", - "identifier": "index", - "indexIdentifier": "index", - "content": "# SQL Support\r\n\r\nSpacetimeDB 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.\r\n\r\nSpacetimeDB 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/).\r\n\r\nSpacetimeDB 0.6 implements a relatively small subset of SQL. Future SpacetimeDB versions will implement additional SQL features.\r\n\r\n## Types\r\n\r\n| Type | Description |\r\n| --------------------------------------------- | -------------------------------------- |\r\n| [Nullable types](#nullable-types) | Types which may not hold a value. |\r\n| [Logic types](#logic-types) | Booleans, i.e. `true` and `false`. |\r\n| [Integer types](#integer-types) | Numbers without fractional components. |\r\n| [Floating-point types](#floating-point-types) | Numbers with fractional components. |\r\n| [Text types](#text-types) | UTF-8 encoded text. |\r\n\r\n### Definition statements\r\n\r\n| Statement | Description |\r\n| ----------------------------- | ------------------------------------ |\r\n| [CREATE TABLE](#create-table) | Create a new table. |\r\n| [DROP TABLE](#drop-table) | Remove a table, discarding all rows. |\r\n\r\n### Query statements\r\n\r\n| Statement | Description |\r\n| ----------------- | -------------------------------------------------------------------------------------------- |\r\n| [FROM](#from) | A source of data, like a table or a value. |\r\n| [JOIN](#join) | Combine several data sources. |\r\n| [SELECT](#select) | Select specific rows and columns from a data source, and optionally compute a derived value. |\r\n| [DELETE](#delete) | Delete specific rows from a table. |\r\n| [INSERT](#insert) | Insert rows into a table. |\r\n| [UPDATE](#update) | Update specific rows in a table. |\r\n\r\n## Data types\r\n\r\nSpacetimeDB 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.\r\n\r\nBecause 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.\r\n\r\nMost SATS builtin types map cleanly to SQL types.\r\n\r\n### Nullable types\r\n\r\nSpacetimeDB 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`.\r\n\r\n### Logic types\r\n\r\n| SQL | SATS | Example |\r\n| --------- | ------ | --------------- |\r\n| `BOOLEAN` | `Bool` | `true`, `false` |\r\n\r\n### Numeric types\r\n\r\n#### Integer types\r\n\r\nAn integer is a number without a fractional component.\r\n\r\nAdding 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.\r\n\r\n| SQL | SATS | Example | Min | Max |\r\n| ------------------- | ----- | ------- | ------ | ----- |\r\n| `TINYINT` | `I8` | 1 | -(2⁷) | 2⁷-1 |\r\n| `TINYINT UNSIGNED` | `U8` | 1 | 0 | 2⁸-1 |\r\n| `SMALLINT` | `I16` | 1 | -(2¹⁵) | 2¹⁵-1 |\r\n| `SMALLINT UNSIGNED` | `U16` | 1 | 0 | 2¹⁶-1 |\r\n| `INT`, `INTEGER` | `I32` | 1 | -(2³¹) | 2³¹-1 |\r\n| `INT UNSIGNED` | `U32` | 1 | 0 | 2³²-1 |\r\n| `BIGINT` | `I64` | 1 | -(2⁶³) | 2⁶³-1 |\r\n| `BIGINT UNSIGNED` | `U64` | 1 | 0 | 2⁶⁴-1 |\r\n\r\n#### Floating-point types\r\n\r\nSpacetimeDB supports single- and double-precision [binary IEEE-754 floats](https://en.wikipedia.org/wiki/IEEE_754).\r\n\r\n| SQL | SATS | Example | Min | Max |\r\n| ----------------- | ----- | ------- | ------------------------ | ----------------------- |\r\n| `REAL` | `F32` | 1.0 | -3.40282347E+38 | 3.40282347E+38 |\r\n| `DOUBLE`, `FLOAT` | `F64` | 1.0 | -1.7976931348623157E+308 | 1.7976931348623157E+308 |\r\n\r\n### Text types\r\n\r\nSpacetimeDB supports a single string type, `String`. SpacetimeDB strings are UTF-8 encoded.\r\n\r\n| SQL | SATS | Example | Notes |\r\n| ----------------------------------------------- | -------- | ------- | -------------------- |\r\n| `CHAR`, `VARCHAR`, `NVARCHAR`, `TEXT`, `STRING` | `String` | 'hello' | Always UTF-8 encoded |\r\n\r\n> SpacetimeDB SQL currently does not support length contraints like `CHAR(10)`.\r\n\r\n## Syntax\r\n\r\n### Comments\r\n\r\nSQL line comments begin with `--`.\r\n\r\n```sql\r\n-- This is a comment\r\n```\r\n\r\n### Expressions\r\n\r\nWe can express different, composable, values that are universally called `expressions`.\r\n\r\nAn expression is one of the following:\r\n\r\n#### Literals\r\n\r\n| Example | Description |\r\n| --------- | ----------- |\r\n| `1` | An integer. |\r\n| `1.0` | A float. |\r\n| `'hello'` | A string. |\r\n| `true` | A boolean. |\r\n\r\n#### Binary operators\r\n\r\n| Example | Description |\r\n| ------- | ------------------- |\r\n| `1 > 2` | Integer comparison. |\r\n| `1 + 2` | Integer addition. |\r\n\r\n#### Logical expressions\r\n\r\nAny expression which returns a boolean, i.e. `true` or `false`, is a logical expression.\r\n\r\n| Example | Description |\r\n| ---------------- | ------------------------------------------------------------ |\r\n| `1 > 2` | Integer comparison. |\r\n| `1 + 2 == 3` | Equality comparison between a constant and a computed value. |\r\n| `true AND false` | Boolean and. |\r\n| `true OR false` | Boolean or. |\r\n| `NOT true` | Boolean inverse. |\r\n\r\n#### Function calls\r\n\r\n| Example | Description |\r\n| --------------- | -------------------------------------------------- |\r\n| `lower('JOHN')` | Apply the function `lower` to the string `'JOHN'`. |\r\n\r\n#### Table identifiers\r\n\r\n| Example | Description |\r\n| ------------- | ------------------------- |\r\n| `inventory` | Refers to a table. |\r\n| `\"inventory\"` | Refers to the same table. |\r\n\r\n#### Column references\r\n\r\n| Example | Description |\r\n| -------------------------- | ------------------------------------------------------- |\r\n| `inventory_id` | Refers to a column. |\r\n| `\"inventory_id\"` | Refers to the same column. |\r\n| `\"inventory.inventory_id\"` | Refers to the same column, explicitly naming its table. |\r\n\r\n#### Wildcards\r\n\r\nSpecial \"star\" expressions which select all the columns of a table.\r\n\r\n| Example | Description |\r\n| ------------- | ------------------------------------------------------- |\r\n| `*` | Refers to all columns of a table identified by context. |\r\n| `inventory.*` | Refers to all columns of the `inventory` table. |\r\n\r\n#### Parenthesized expressions\r\n\r\nSub-expressions can be enclosed in parentheses for grouping and to override operator precedence.\r\n\r\n| Example | Description |\r\n| ------------- | ----------------------- |\r\n| `1 + (2 / 3)` | One plus a fraction. |\r\n| `(1 + 2) / 3` | A sum divided by three. |\r\n\r\n### `CREATE TABLE`\r\n\r\nA `CREATE TABLE` statement creates a new, initially empty table in the database.\r\n\r\nThe syntax of the `CREATE TABLE` statement is:\r\n\r\n> **CREATE TABLE** _table_name_ (_column_name_ _data_type_, ...);\r\n\r\n![create-table](/images/syntax/create_table.svg)\r\n\r\n#### Examples\r\n\r\nCreate a table `inventory` with two columns, an integer `inventory_id` and a string `name`:\r\n\r\n```sql\r\nCREATE TABLE inventory (inventory_id INTEGER, name TEXT);\r\n```\r\n\r\nCreate a table `player` with two integer columns, an `entity_id` and an `inventory_id`:\r\n\r\n```sql\r\nCREATE TABLE player (entity_id INTEGER, inventory_id INTEGER);\r\n```\r\n\r\nCreate a table `location` with three columns, an integer `entity_id` and floats `x` and `z`:\r\n\r\n```sql\r\nCREATE TABLE location (entity_id INTEGER, x REAL, z REAL);\r\n```\r\n\r\n### `DROP TABLE`\r\n\r\nA `DROP TABLE` statement removes a table from the database, deleting all its associated rows, indexes, constraints and sequences.\r\n\r\nTo empty a table of rows without destroying the table, use [`DELETE`](#delete).\r\n\r\nThe syntax of the `DROP TABLE` statement is:\r\n\r\n> **DROP TABLE** _table_name_;\r\n\r\n![drop-table](/images/syntax/drop_table.svg)\r\n\r\nExamples:\r\n\r\n```sql\r\nDROP TABLE inventory;\r\n```\r\n\r\n## Queries\r\n\r\n### `FROM`\r\n\r\nA `FROM` clause derives a data source from a table name.\r\n\r\nThe syntax of the `FROM` clause is:\r\n\r\n> **FROM** _table_name_ _join_clause_?;\r\n\r\n![from](/images/syntax/from.svg)\r\n\r\n#### Examples\r\n\r\nSelect all rows from the `inventory` table:\r\n\r\n```sql\r\nSELECT * FROM inventory;\r\n```\r\n\r\n### `JOIN`\r\n\r\nA `JOIN` clause combines two data sources into a new data source.\r\n\r\nCurrently, SpacetimeDB SQL supports only inner joins, which return rows from two data sources where the values of two columns match.\r\n\r\nThe syntax of the `JOIN` clause is:\r\n\r\n> **JOIN** _table_name_ **ON** _expr_ = _expr_;\r\n\r\n![join](/images/syntax/join.svg)\r\n\r\n### Examples\r\n\r\nSelect all players rows who have a corresponding location:\r\n\r\n```sql\r\nSELECT player.* FROM player\r\n JOIN location\r\n ON location.entity_id = player.entity_id;\r\n```\r\n\r\nSelect all inventories which have a corresponding player, and where that player has a corresponding location:\r\n\r\n```sql\r\nSELECT inventory.* FROM inventory\r\n JOIN player\r\n ON inventory.inventory_id = player.inventory_id\r\n JOIN location\r\n ON player.entity_id = location.entity_id;\r\n```\r\n\r\n### `SELECT`\r\n\r\nA `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.\r\n\r\nThe syntax of the `SELECT` command is:\r\n\r\n> **SELECT** _column_expr_ > **FROM** _from_expr_\r\n> {**WHERE** _expr_}?\r\n\r\n![sql-select](/images/syntax/select.svg)\r\n\r\n#### Examples\r\n\r\nSelect all columns of all rows from the `inventory` table:\r\n\r\n```sql\r\nSELECT * FROM inventory;\r\nSELECT inventory.* FROM inventory;\r\n```\r\n\r\nSelect only the `inventory_id` column of all rows from the `inventory` table:\r\n\r\n```sql\r\nSELECT inventory_id FROM inventory;\r\nSELECT inventory.inventory_id FROM inventory;\r\n```\r\n\r\nAn 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`.\r\n\r\n#### Examples\r\n\r\nSelect all columns of all rows from the `inventory` table, with a filter that is always true:\r\n\r\n```sql\r\nSELECT * FROM inventory WHERE 1 = 1;\r\n```\r\n\r\nSelect all columns of all rows from the `inventory` table with the `inventory_id` 1:\r\n\r\n```sql\r\nSELECT * FROM inventory WHERE inventory_id = 1;\r\n```\r\n\r\nSelect only the `name` column of all rows from the `inventory` table with the `inventory_id` 1:\r\n\r\n```sql\r\nSELECT name FROM inventory WHERE inventory_id = 1;\r\n```\r\n\r\nSelect all columns of all rows from the `inventory` table where the `inventory_id` is 2 or greater:\r\n\r\n```sql\r\nSELECT * FROM inventory WHERE inventory_id > 1;\r\n```\r\n\r\n### `INSERT`\r\n\r\nAn `INSERT INTO` statement inserts new rows into a table.\r\n\r\nOne can insert one or more rows specified by value expressions.\r\n\r\nThe syntax of the `INSERT INTO` statement is:\r\n\r\n> **INSERT INTO** _table_name_ (_column_name_, ...) **VALUES** (_expr_, ...), ...;\r\n\r\n![sql-insert](/images/syntax/insert.svg)\r\n\r\n#### Examples\r\n\r\nInsert a single row:\r\n\r\n```sql\r\nINSERT INTO inventory (inventory_id, name) VALUES (1, 'health1');\r\n```\r\n\r\nInsert two rows:\r\n\r\n```sql\r\nINSERT INTO inventory (inventory_id, name) VALUES (1, 'health1'), (2, 'health2');\r\n```\r\n\r\n### UPDATE\r\n\r\nAn `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.\r\n\r\nColumns not explicitly modified with the `SET` clause retain their previous values.\r\n\r\nIf the `WHERE` clause is absent, the effect is to update all rows in the table.\r\n\r\nThe syntax of the `UPDATE` statement is\r\n\r\n> **UPDATE** _table_name_ **SET** > _column_name_ = _expr_, ...\r\n> {_WHERE expr_}?;\r\n\r\n![sql-update](/images/syntax/update.svg)\r\n\r\n#### Examples\r\n\r\nSet the `name` column of all rows from the `inventory` table with the `inventory_id` 1 to `'new name'`:\r\n\r\n```sql\r\nUPDATE inventory\r\n SET name = 'new name'\r\n WHERE inventory_id = 1;\r\n```\r\n\r\n### DELETE\r\n\r\nA `DELETE` statement deletes rows that satisfy the `WHERE` clause from the specified table.\r\n\r\nIf 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.\r\n\r\nThe syntax of the `DELETE` statement is\r\n\r\n> **DELETE** _table_name_\r\n> {**WHERE** _expr_}?;\r\n\r\n![sql-delete](/images/syntax/delete.svg)\r\n\r\n#### Examples\r\n\r\nDelete all the rows from the `inventory` table with the `inventory_id` 1:\r\n\r\n```sql\r\nDELETE FROM inventory WHERE inventory_id = 1;\r\n```\r\n\r\nDelete all rows from the `inventory` table, leaving it empty:\r\n\r\n```sql\r\nDELETE FROM inventory;\r\n```\r\n", - "hasPages": false, - "editUrl": "index.md", - "jumpLinks": [ - { - "title": "SQL Support", - "route": "sql-support", - "depth": 1 - }, - { - "title": "Types", - "route": "types", - "depth": 2 - }, - { - "title": "Definition statements", - "route": "definition-statements", - "depth": 3 - }, - { - "title": "Query statements", - "route": "query-statements", - "depth": 3 - }, - { - "title": "Data types", - "route": "data-types", - "depth": 2 - }, - { - "title": "Nullable types", - "route": "nullable-types", - "depth": 3 - }, - { - "title": "Logic types", - "route": "logic-types", - "depth": 3 - }, - { - "title": "Numeric types", - "route": "numeric-types", - "depth": 3 - }, - { - "title": "Integer types", - "route": "integer-types", - "depth": 4 - }, - { - "title": "Floating-point types", - "route": "floating-point-types", - "depth": 4 - }, - { - "title": "Text types", - "route": "text-types", - "depth": 3 - }, - { - "title": "Syntax", - "route": "syntax", - "depth": 2 - }, - { - "title": "Comments", - "route": "comments", - "depth": 3 - }, - { - "title": "Expressions", - "route": "expressions", - "depth": 3 - }, - { - "title": "Literals", - "route": "literals", - "depth": 4 - }, - { - "title": "Binary operators", - "route": "binary-operators", - "depth": 4 - }, - { - "title": "Logical expressions", - "route": "logical-expressions", - "depth": 4 - }, - { - "title": "Function calls", - "route": "function-calls", - "depth": 4 - }, - { - "title": "Table identifiers", - "route": "table-identifiers", - "depth": 4 - }, - { - "title": "Column references", - "route": "column-references", - "depth": 4 - }, - { - "title": "Wildcards", - "route": "wildcards", - "depth": 4 - }, - { - "title": "Parenthesized expressions", - "route": "parenthesized-expressions", - "depth": 4 - }, - { - "title": "`CREATE TABLE`", - "route": "-create-table-", - "depth": 3 - }, - { - "title": "Examples", - "route": "examples", - "depth": 4 - }, - { - "title": "`DROP TABLE`", - "route": "-drop-table-", - "depth": 3 - }, - { - "title": "Queries", - "route": "queries", - "depth": 2 - }, - { - "title": "`FROM`", - "route": "-from-", - "depth": 3 - }, - { - "title": "Examples", - "route": "examples", - "depth": 4 - }, - { - "title": "`JOIN`", - "route": "-join-", - "depth": 3 - }, - { - "title": "Examples", - "route": "examples", - "depth": 3 - }, - { - "title": "`SELECT`", - "route": "-select-", - "depth": 3 - }, - { - "title": "Examples", - "route": "examples", - "depth": 4 - }, - { - "title": "Examples", - "route": "examples", - "depth": 4 - }, - { - "title": "`INSERT`", - "route": "-insert-", - "depth": 3 - }, - { - "title": "Examples", - "route": "examples", - "depth": 4 - }, - { - "title": "UPDATE", - "route": "update", - "depth": 3 - }, - { - "title": "Examples", - "route": "examples", - "depth": 4 - }, - { - "title": "DELETE", - "route": "delete", - "depth": 3 - }, - { - "title": "Examples", - "route": "examples", - "depth": 4 - } - ], - "pages": [] - } - ], - "previousKey": { - "title": "SATN Reference", - "route": "index", - "depth": 1 - }, - "nextKey": { - "title": "WebSocket API Reference", - "route": "index", - "depth": 1 - } - }, - { - "title": "WebSocket API Reference", - "identifier": "WebSocket API Reference", - "indexIdentifier": "index", - "comingSoon": false, - "hasPages": true, - "editUrl": "WebSocket%20API%20Reference/index.md", - "jumpLinks": [], - "pages": [ - { - "title": "The SpacetimeDB WebSocket API", - "identifier": "index", - "indexIdentifier": "index", - "content": "# The SpacetimeDB WebSocket API\r\n\r\nAs 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.\r\n\r\nThe SpacetimeDB SDKs comminicate with their corresponding database using the WebSocket API.\r\n\r\n## Connecting\r\n\r\nTo 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).\r\n\r\nTo 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.\r\n\r\n## Protocols\r\n\r\nClients 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.\r\n\r\n| `Sec-WebSocket-Protocol` header value | Selected protocol |\r\n| ------------------------------------- | -------------------------- |\r\n| `v1.bin.spacetimedb` | [Binary](#binary-protocol) |\r\n| `v1.text.spacetimedb` | [Text](#text-protocol) |\r\n\r\n### Binary Protocol\r\n\r\nThe 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).\r\n\r\nThe 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).\r\n\r\n### Text Protocol\r\n\r\nThe 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).\r\n\r\n## Messages\r\n\r\n### Client to server\r\n\r\n| Message | Description |\r\n| ------------------------------- | --------------------------------------------------------------------------- |\r\n| [`FunctionCall`](#functioncall) | Invoke a reducer. |\r\n| [`Subscribe`](#subscribe) | Register queries to receive streaming updates for a subset of the database. |\r\n\r\n#### `FunctionCall`\r\n\r\nClients 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.\r\n\r\n##### Binary: ProtoBuf definition\r\n\r\n```protobuf\r\nmessage FunctionCall {\r\n string reducer = 1;\r\n bytes argBytes = 2;\r\n}\r\n```\r\n\r\n| Field | Value |\r\n| ---------- | -------------------------------------------------------- |\r\n| `reducer` | The name of the reducer to invoke. |\r\n| `argBytes` | The reducer arguments encoded as a BSATN `ProductValue`. |\r\n\r\n##### Text: JSON encoding\r\n\r\n```typescript\r\n{\r\n \"call\": {\r\n \"fn\": string,\r\n \"args\": array,\r\n }\r\n}\r\n```\r\n\r\n| Field | Value |\r\n| ------ | ---------------------------------------------- |\r\n| `fn` | The name of the reducer to invoke. |\r\n| `args` | The reducer arguments encoded as a JSON array. |\r\n\r\n#### `Subscribe`\r\n\r\nClients send a `Subscribe` message to register SQL queries in order to receive streaming updates.\r\n\r\nThe 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.\r\n\r\nSpacetimeDB responds to each `Subscribe` message with a [`SubscriptionUpdate` message](#subscriptionupdate) containing all matching rows at the time the subscription is applied.\r\n\r\nEach `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.\r\n\r\nEach 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.\r\n\r\n##### Binary: ProtoBuf definition\r\n\r\n```protobuf\r\nmessage Subscribe {\r\n repeated string query_strings = 1;\r\n}\r\n```\r\n\r\n| Field | Value |\r\n| --------------- | ----------------------------------------------------------------- |\r\n| `query_strings` | A sequence of strings, each of which contains a single SQL query. |\r\n\r\n##### Text: JSON encoding\r\n\r\n```typescript\r\n{\r\n \"subscribe\": {\r\n \"query_strings\": array\r\n }\r\n}\r\n```\r\n\r\n| Field | Value |\r\n| --------------- | --------------------------------------------------------------- |\r\n| `query_strings` | An array of strings, each of which contains a single SQL query. |\r\n\r\n### Server to client\r\n\r\n| Message | Description |\r\n| ------------------------------------------- | -------------------------------------------------------------------------- |\r\n| [`IdentityToken`](#identitytoken) | Sent once upon successful connection with the client's identity and token. |\r\n| [`SubscriptionUpdate`](#subscriptionupdate) | Initial message in response to a [`Subscribe` message](#subscribe). |\r\n| [`TransactionUpdate`](#transactionupdate) | Streaming update after a reducer runs containing altered rows. |\r\n\r\n#### `IdentityToken`\r\n\r\nUpon 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.\r\n\r\n##### Binary: ProtoBuf definition\r\n\r\n```protobuf\r\nmessage IdentityToken {\r\n bytes identity = 1;\r\n string token = 2;\r\n}\r\n```\r\n\r\n| Field | Value |\r\n| ---------- | --------------------------------------- |\r\n| `identity` | The client's public Spacetime identity. |\r\n| `token` | The client's private access token. |\r\n\r\n##### Text: JSON encoding\r\n\r\n```typescript\r\n{\r\n \"IdentityToken\": {\r\n \"identity\": array,\r\n \"token\": string\r\n }\r\n}\r\n```\r\n\r\n| Field | Value |\r\n| ---------- | --------------------------------------- |\r\n| `identity` | The client's public Spacetime identity. |\r\n| `token` | The client's private access token. |\r\n\r\n#### `SubscriptionUpdate`\r\n\r\nIn 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.\r\n\r\n##### Binary: ProtoBuf definition\r\n\r\n```protobuf\r\nmessage SubscriptionUpdate {\r\n repeated TableUpdate tableUpdates = 1;\r\n}\r\n\r\nmessage TableUpdate {\r\n uint32 tableId = 1;\r\n string tableName = 2;\r\n repeated TableRowOperation tableRowOperations = 3;\r\n}\r\n\r\nmessage TableRowOperation {\r\n enum OperationType {\r\n DELETE = 0;\r\n INSERT = 1;\r\n }\r\n OperationType op = 1;\r\n bytes row_pk = 2;\r\n bytes row = 3;\r\n}\r\n```\r\n\r\nEach `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`.\r\n\r\n| `TableUpdate` field | Value |\r\n| -------------------- | ------------------------------------------------------------------------------------------------------------- |\r\n| `tableId` | An integer identifier for the table. A table's `tableId` is not stable, so clients should not depend on it. |\r\n| `tableName` | The string name of the table. Clients should use this field to identify the table, rather than the `tableId`. |\r\n| `tableRowOperations` | A `TableRowOperation` for each inserted or deleted row. |\r\n\r\n| `TableRowOperation` field | Value |\r\n| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\r\n| `op` | `INSERT` for inserted rows during a [`TransactionUpdate`](#transactionupdate) or rows resident upon applying a subscription; `DELETE` for deleted rows during a [`TransactionUpdate`](#transactionupdate). |\r\n| `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`. |\r\n| `row` | The altered row, encoded as a BSATN `ProductValue`. |\r\n\r\n##### Text: JSON encoding\r\n\r\n```typescript\r\n// SubscriptionUpdate:\r\n{\r\n \"SubscriptionUpdate\": {\r\n \"table_updates\": array\r\n }\r\n}\r\n\r\n// TableUpdate:\r\n{\r\n \"table_id\": number,\r\n \"table_name\": string,\r\n \"table_row_operations\": array\r\n}\r\n\r\n// TableRowOperation:\r\n{\r\n \"op\": \"insert\" | \"delete\",\r\n \"row_pk\": string,\r\n \"row\": array\r\n}\r\n```\r\n\r\nEach `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\"`.\r\n\r\n| `TableUpdate` field | Value |\r\n| ---------------------- | -------------------------------------------------------------------------------------------------------------- |\r\n| `table_id` | An integer identifier for the table. A table's `table_id` is not stable, so clients should not depend on it. |\r\n| `table_name` | The string name of the table. Clients should use this field to identify the table, rather than the `table_id`. |\r\n| `table_row_operations` | A `TableRowOperation` for each inserted or deleted row. |\r\n\r\n| `TableRowOperation` field | Value |\r\n| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\r\n| `op` | `\"insert\"` for inserted rows during a [`TransactionUpdate`](#transactionupdate) or rows resident upon applying a subscription; `\"delete\"` for deleted rows during a [`TransactionUpdate`](#transactionupdate). |\r\n| `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. |\r\n| `row` | The altered row, encoded as a JSON array. |\r\n\r\n#### `TransactionUpdate`\r\n\r\nUpon 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:\r\n\r\n1. The reducer ran successfully and altered at least one row to which the client subscribes.\r\n2. The reducer was invoked by the client, and either failed or was terminated due to insufficient energy.\r\n\r\nEach `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.\r\n\r\n##### Binary: ProtoBuf definition\r\n\r\n```protobuf\r\nmessage TransactionUpdate {\r\n Event event = 1;\r\n SubscriptionUpdate subscriptionUpdate = 2;\r\n}\r\n\r\nmessage Event {\r\n enum Status {\r\n committed = 0;\r\n failed = 1;\r\n out_of_energy = 2;\r\n }\r\n uint64 timestamp = 1;\r\n bytes callerIdentity = 2;\r\n FunctionCall functionCall = 3;\r\n Status status = 4;\r\n string message = 5;\r\n int64 energy_quanta_used = 6;\r\n uint64 host_execution_duration_micros = 7;\r\n}\r\n```\r\n\r\n| Field | Value |\r\n| -------------------- | --------------------------------------------------------------------------------------------------------------------------- |\r\n| `event` | An `Event` containing information about the reducer run. |\r\n| `subscriptionUpdate` | A [`SubscriptionUpdate`](#subscriptionupdate) containing all the row insertions and deletions committed by the transaction. |\r\n\r\n| `Event` field | Value |\r\n| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\r\n| `timestamp` | The time when the reducer started, as microseconds since the Unix epoch. |\r\n| `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. |\r\n| `functionCall` | A [`FunctionCall`](#functioncall) containing the name of the reducer and the arguments passed to it. |\r\n| `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. |\r\n| `message` | The error message with which the reducer failed if `status` is `failed`, or the empty string otherwise. |\r\n| `energy_quanta_used` | The amount of energy consumed by running the reducer. |\r\n| `host_execution_duration_micros` | The duration of the reducer's execution, in microseconds. |\r\n\r\n##### Text: JSON encoding\r\n\r\n```typescript\r\n// TransactionUpdate:\r\n{\r\n \"TransactionUpdate\": {\r\n \"event\": Event,\r\n \"subscription_update\": SubscriptionUpdate\r\n }\r\n}\r\n\r\n// Event:\r\n{\r\n \"timestamp\": number,\r\n \"status\": \"committed\" | \"failed\" | \"out_of_energy\",\r\n \"caller_identity\": string,\r\n \"function_call\": {\r\n \"reducer\": string,\r\n \"args\": array,\r\n },\r\n \"energy_quanta_used\": number,\r\n \"message\": string\r\n}\r\n```\r\n\r\n| Field | Value |\r\n| --------------------- | --------------------------------------------------------------------------------------------------------------------------- |\r\n| `event` | An `Event` containing information about the reducer run. |\r\n| `subscription_update` | A [`SubscriptionUpdate`](#subscriptionupdate) containing all the row insertions and deletions committed by the transaction. |\r\n\r\n| `Event` field | Value |\r\n| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\r\n| `timestamp` | The time when the reducer started, as microseconds since the Unix epoch. |\r\n| `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. |\r\n| `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. |\r\n| `function_call.reducer` | The name of the reducer. |\r\n| `function_call.args` | The reducer arguments encoded as a JSON array. |\r\n| `energy_quanta_used` | The amount of energy consumed by running the reducer. |\r\n| `message` | The error message with which the reducer failed if `status` is `failed`, or the empty string otherwise. |\r\n", - "hasPages": false, - "editUrl": "index.md", - "jumpLinks": [ - { - "title": "The SpacetimeDB WebSocket API", - "route": "the-spacetimedb-websocket-api", - "depth": 1 - }, - { - "title": "Connecting", - "route": "connecting", - "depth": 2 - }, - { - "title": "Protocols", - "route": "protocols", - "depth": 2 - }, - { - "title": "Binary Protocol", - "route": "binary-protocol", - "depth": 3 - }, - { - "title": "Text Protocol", - "route": "text-protocol", - "depth": 3 - }, - { - "title": "Messages", - "route": "messages", - "depth": 2 - }, - { - "title": "Client to server", - "route": "client-to-server", - "depth": 3 - }, - { - "title": "`FunctionCall`", - "route": "-functioncall-", - "depth": 4 - }, - { - "title": "Binary: ProtoBuf definition", - "route": "binary-protobuf-definition", - "depth": 5 - }, - { - "title": "Text: JSON encoding", - "route": "text-json-encoding", - "depth": 5 - }, - { - "title": "`Subscribe`", - "route": "-subscribe-", - "depth": 4 - }, - { - "title": "Binary: ProtoBuf definition", - "route": "binary-protobuf-definition", - "depth": 5 - }, - { - "title": "Text: JSON encoding", - "route": "text-json-encoding", - "depth": 5 - }, - { - "title": "Server to client", - "route": "server-to-client", - "depth": 3 - }, - { - "title": "`IdentityToken`", - "route": "-identitytoken-", - "depth": 4 - }, - { - "title": "Binary: ProtoBuf definition", - "route": "binary-protobuf-definition", - "depth": 5 - }, - { - "title": "Text: JSON encoding", - "route": "text-json-encoding", - "depth": 5 - }, - { - "title": "`SubscriptionUpdate`", - "route": "-subscriptionupdate-", - "depth": 4 - }, - { - "title": "Binary: ProtoBuf definition", - "route": "binary-protobuf-definition", - "depth": 5 - }, - { - "title": "Text: JSON encoding", - "route": "text-json-encoding", - "depth": 5 - }, - { - "title": "`TransactionUpdate`", - "route": "-transactionupdate-", - "depth": 4 - }, - { - "title": "Binary: ProtoBuf definition", - "route": "binary-protobuf-definition", - "depth": 5 - }, - { - "title": "Text: JSON encoding", - "route": "text-json-encoding", - "depth": 5 - } - ], - "pages": [] - } - ], - "previousKey": { - "title": "SQL Reference", - "route": "index", - "depth": 1 - }, - "nextKey": null - } - ], - "rootEditURL": "https://github.com/clockworklabs/spacetime-docs/edit/master/docs/" -}; \ No newline at end of file diff --git a/docs/package.json b/docs/package.json deleted file mode 100644 index 34621ce0657..00000000000 --- a/docs/package.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "spacetime-docs", - "version": "1.0.0", - "description": "", - "main": "dist/index.js", - "scripts": { - "build": "npx tsc", - "test": "echo \"Error: no test specified\" && exit 1" - }, - "bin": { - "spacetime-docs": "./dist/index.js" - }, - "author": "", - "license": "ISC", - "dependencies": { - "clear": "^0.1.0", - "commander": "^11.0.0", - "figlet": "^1.6.0", - "fs-extra": "^11.1.1" - }, - "devDependencies": { - "@types/node": "^20.6.2", - "typescript": "^5.2.2" - } -} diff --git a/docs/spacetime-docs.json b/docs/spacetime-docs.json deleted file mode 100644 index 289514c1eb1..00000000000 --- a/docs/spacetime-docs.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "docPath": "./docs", - "order": [ - "Overview", - "Getting Started", - "Cloud Testnet", - "Unity Tutorial", - "Server Module Languages", - "Client SDK Languages", - "Module ABI Reference", - "HTTP API Reference", - "WebScoket API Reference", - "SATN Reference", - "SQL Reference" - ], - "editURLRoot": "https://github.com/clockworklabs/spacetime-docs/edit/master/docs/" -} \ No newline at end of file diff --git a/docs/src/index.ts b/docs/src/index.ts deleted file mode 100644 index 32fac1b6510..00000000000 --- a/docs/src/index.ts +++ /dev/null @@ -1,281 +0,0 @@ -#! /usr/bin/env node - -import { DocConfig, DocSectionConfig, JumpLink } from "./types"; - -const { Command } = require("commander"); -const clear = require("clear"); -const figlet = require("figlet"); -const path = require("path"); -const fs = require("fs"); -const fsExtra = require("fs-extra"); - -const cwd = process.cwd(); -const DOCS_PATH = path.join(__dirname, "docs"); -const CONFIG_PATH = path.join(cwd, "spacetime-docs.json"); - -function extractHeadersFromMarkdown(filePath) { - const content = fs.readFileSync(filePath, "utf-8"); - const headers: JumpLink[] = []; - const titleRegex = /^#\s+(.+)$/m; - const headerMatch = content.match(titleRegex); - const title = headerMatch ? headerMatch[1] : null; - - const headerRegex = /^(#+)\s+(.+)$/gm; // This captures the hashes and the header text - let match; - while ((match = headerRegex.exec(content))) { - const depth = match[1].length; // Count of #'s indicate depth - headers.push({ - title: match[2], - route: match[2].toLowerCase().replace(/[^\w]+/g, "-"), - depth: depth, - }); - } - - return { title, jumpLinks: headers }; -} -let config = { - docPath: "", - order: [] as any[], - editURLRoot: "", -}; - -if (fs.existsSync(CONFIG_PATH)) { - const configOpts = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")); - config.docPath = configOpts.docPath || path.join(cwd, "docs"); - config.order = configOpts.order || []; - config.editURLRoot = configOpts.editURLRoot || ""; -} else { - config.docPath = path.join(cwd, "docs"); - config.order = []; - config.editURLRoot = ""; -} - -clear(); - -console.log(figlet.textSync("spacetime-docs", { horizontalLayout: "full" })); - -const program = new Command(); - -program.version("1.0.0").description("Spacetime Docs CLI"); - -program.command("generate").action(() => { - const rootDir = config.docPath; - - function processDirectory(dir) { - const categoryFile = path.join(dir, "_category.json"); - if (!fs.existsSync(categoryFile)) return null; - - const category = fsExtra.readJSONSync(categoryFile); - const docSectionConfig = { - title: category.title, - identifier: path.basename(dir), - indexIdentifier: category.index.replace(".md", ""), - comingSoon: category.disabled || false, - tag: category.tag || undefined, - hasPages: false, - editUrl: encodeURIComponent(category.title) + "/" + category.index, - jumpLinks: [], - pages: [] as any[], - }; - - const items = fs.readdirSync(dir); - const subSections: any[] = []; - - items.forEach((item) => { - const itemPath = path.join(dir, item); - const isDirectory = fs.statSync(itemPath).isDirectory(); - const isMarkdownFile = path.extname(item) === ".md"; - - if (isDirectory) { - const subSection = processDirectory(itemPath); - if (subSection) { - subSections.push(subSection); - } - } else if ( - isMarkdownFile && - item !== "_category.json" && - item !== "index.md" - ) { - const { title, jumpLinks } = extractHeadersFromMarkdown(itemPath); - const pageIdentifier = item.replace(".md", ""); - - subSections.push({ - title: title || pageIdentifier, // Use the extracted title if available, otherwise fallback to the pageIdentifier - identifier: pageIdentifier, - indexIdentifier: pageIdentifier, - hasPages: false, - content: `${fs.readFileSync(itemPath, "utf-8")}`, - editUrl: encodeURIComponent(pageIdentifier) + ".md", - jumpLinks: jumpLinks, - pages: [], - }); - } else if (isMarkdownFile && item === "index.md") { - const { title, jumpLinks } = extractHeadersFromMarkdown(itemPath); - const pageIdentifier = item.replace(".md", ""); - - subSections.push({ - title: title || pageIdentifier, - identifier: pageIdentifier, - indexIdentifier: pageIdentifier, - content: `${fs.readFileSync(itemPath, "utf-8")}`, - hasPages: false, - editUrl: encodeURIComponent(pageIdentifier) + ".md", - jumpLinks: jumpLinks, - pages: [], - }); - } - }); - - if (subSections.length > 0) { - docSectionConfig.hasPages = true; - docSectionConfig.pages = subSections; - } - - return docSectionConfig; - } - - const docConfig = { - sections: [] as any[], - rootEditURL: config.editURLRoot, - }; - - const folders = fs.readdirSync(rootDir); - folders.forEach((folder) => { - const folderPath = path.join(rootDir, folder); - if (fs.statSync(folderPath).isDirectory()) { - const section = processDirectory(folderPath); - if (section) { - docConfig.sections.push(section); - } - } - }); - - docConfig.sections = docConfig.sections.sort((a: any, b: any) => { - const orderA = config.order.indexOf(a.title); - const orderB = config.order.indexOf(b.title); - - if (orderA === -1 && orderB === -1) return 0; - if (orderA === -1) return 1; - if (orderB === -1) return -1; - - return orderA - orderB; - }); - - for (let i = 0; i < docConfig.sections.length; i++) { - const section = docConfig.sections[i]; - - if (i > 0) { - section.previousKey = { - title: docConfig.sections[i - 1].title, - route: docConfig.sections[i - 1].indexIdentifier, - depth: 1, - }; - } else { - section.previousKey = null; - } - - if (i < docConfig.sections.length - 1) { - section.nextKey = { - title: docConfig.sections[i + 1].title, - route: docConfig.sections[i + 1].indexIdentifier, - depth: 1, - }; - } else { - section.nextKey = null; - } - } - - fs.writeFileSync( - path.join(rootDir, "docs-config.ts"), - `export const docsConfig = ${JSON.stringify(docConfig, null, 2)};` - ); -}); - -program - .command("page") - .argument("", "The route to create the page in") - .argument("", "The name of the page") - .action((route: string, pageName: string) => { - const routePath = path.join(config.docPath, route); - const pagePath = path.join(routePath, `${pageName}.md`); - - if (!fs.existsSync(routePath)) { - console.log(`Route ${route} does not exist.`); - return; - } - - if (fs.existsSync(pagePath)) { - console.log(`Page ${pageName} already exists in route ${route}.`); - return; - } - - fs.writeFileSync(pagePath, `# ${pageName}`); - console.log(`Page ${pageName} created successfully in route ${route}.`); - }); - -program - .command("remove-route") - .argument("", "The route to remove") - .action((route: string) => { - const routePath = path.join(config.docPath, route); - - if (fs.existsSync(routePath)) { - fsExtra.removeSync(routePath); - console.log(`Successfully removed route: ${route}`); - } else { - console.log(`Route ${route} does not exist.`); - } - }); - -program - .command("create-route") - .argument("", "The route to create") - .option( - "-p, --parent ", - "Parent route under which to create the subroute" - ) - .action((routeName: string, options: any) => { - let routePath = path.join(config.docPath, routeName); - - // Check for parent option - if (options.parent) { - routePath = path.join(config.docPath, options.parent, routeName); - } - - const titleName = routeName.charAt(0).toUpperCase() + routeName.slice(1); - - // Check if ./docs exists, if not create it - if (!fs.existsSync(config.docPath)) { - fs.mkdirSync(config.docPath); - } - - // Create the route folder - if (!fs.existsSync(routePath)) { - fs.mkdirSync(routePath, { recursive: true }); // Ensure parent directories are created - } - - // Create the index.md file inside the route folder - const indexPath = path.join(routePath, "index.md"); - const categoryPath = path.join(routePath, "_category.json"); - - if (!fs.existsSync(categoryPath)) { - fs.writeFileSync( - categoryPath, - JSON.stringify({ - title: titleName, - disabled: false, - index: "index.md", - }) - ); - } else { - console.log(`_category.json already exists in ${titleName}`); - } - - if (!fs.existsSync(indexPath)) { - fs.writeFileSync(indexPath, "# Welcome to " + titleName); - } else { - console.log(`index.md already exists in ${titleName}`); - } - }); - -program.parse(process.argv); diff --git a/docs/src/types.ts b/docs/src/types.ts deleted file mode 100644 index 0226b3b0afb..00000000000 --- a/docs/src/types.ts +++ /dev/null @@ -1,25 +0,0 @@ -export type DocConfig = { - sections: DocSectionConfig[]; - rootEditURL: string; -}; - -export type DocSectionConfig = { - title: string; - identifier: string; - indexIdentifier?: string; - comingSoon: boolean; - tag?: boolean; - hasPages: boolean; - editUrl: string; - nextKey?: JumpLink; - previousKey?: JumpLink; - content?: string; - pages?: DocSectionConfig[]; - jumpLinks: JumpLink[]; -}; - -export type JumpLink = { - title: string; - route: string; - depth: number; -}; diff --git a/docs/tsconfig.json b/docs/tsconfig.json deleted file mode 100644 index 4777e8b13b1..00000000000 --- a/docs/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "rootDir": "src", - "outDir": "dist", - "strict": true, - "target": "es6", - "module": "commonjs", - "sourceMap": true, - "esModuleInterop": true, - "moduleResolution": "node", - "noImplicitAny": false - }, - "exclude": ["node_modules", "*/docs-config.ts"] -} diff --git a/docs/yarn.lock b/docs/yarn.lock deleted file mode 100644 index 0c75aadff05..00000000000 --- a/docs/yarn.lock +++ /dev/null @@ -1,56 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - -"@types/node@^20.6.2": - version "20.6.2" - resolved "https://registry.yarnpkg.com/@types/node/-/node-20.6.2.tgz#a065925409f59657022e9063275cd0b9bd7e1b12" - integrity sha512-Y+/1vGBHV/cYk6OI1Na/LHzwnlNCAfU3ZNGrc1LdRe/LAIbdDPTTv/HU3M7yXN448aTVDq3eKRm2cg7iKLb8gw== - -clear@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/clear/-/clear-0.1.0.tgz#b81b1e03437a716984fd7ac97c87d73bdfe7048a" - integrity sha512-qMjRnoL+JDPJHeLePZJuao6+8orzHMGP04A8CdwCNsKhRbOnKRjefxONR7bwILT3MHecxKBjHkKL/tkZ8r4Uzw== - -commander@^11.0.0: - version "11.0.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-11.0.0.tgz#43e19c25dbedc8256203538e8d7e9346877a6f67" - integrity sha512-9HMlXtt/BNoYr8ooyjjNRdIilOTkVJXB+GhxMTtOKwk0R4j4lS4NpjuqmRxroBfnfTSHQIHQB7wryHhXarNjmQ== - -figlet@^1.6.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/figlet/-/figlet-1.6.0.tgz#812050fa9f01043b4d44ddeb11f20fb268fa4b93" - integrity sha512-31EQGhCEITv6+hi2ORRPyn3bulaV9Fl4xOdR169cBzH/n1UqcxsiSB/noo6SJdD7Kfb1Ljit+IgR1USvF/XbdA== - -fs-extra@^11.1.1: - version "11.1.1" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.1.1.tgz#da69f7c39f3b002378b0954bb6ae7efdc0876e2d" - integrity sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ== - dependencies: - graceful-fs "^4.2.0" - jsonfile "^6.0.1" - universalify "^2.0.0" - -graceful-fs@^4.1.6, graceful-fs@^4.2.0: - version "4.2.11" - resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" - integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== - -jsonfile@^6.0.1: - version "6.1.0" - resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" - integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== - dependencies: - universalify "^2.0.0" - optionalDependencies: - graceful-fs "^4.1.6" - -typescript@^5.2.2: - version "5.2.2" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.2.2.tgz#5ebb5e5a5b75f085f22bc3f8460fba308310fa78" - integrity sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w== - -universalify@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" - integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== From 3651aedf6c3c092df998208735189a4f31b816cd Mon Sep 17 00:00:00 2001 From: Nathaniel Richards Date: Thu, 28 Sep 2023 11:18:03 -0400 Subject: [PATCH 007/195] Changed part 1 of unity tut --- .../{index.md => Part 1 - Basic Multiplayer.md} | 0 docs/docs/Unity Tutorial/_category.json | 6 +++++- 2 files changed, 5 insertions(+), 1 deletion(-) rename docs/docs/Unity Tutorial/{index.md => Part 1 - Basic Multiplayer.md} (100%) diff --git a/docs/docs/Unity Tutorial/index.md b/docs/docs/Unity Tutorial/Part 1 - Basic Multiplayer.md similarity index 100% rename from docs/docs/Unity Tutorial/index.md rename to docs/docs/Unity Tutorial/Part 1 - Basic Multiplayer.md diff --git a/docs/docs/Unity Tutorial/_category.json b/docs/docs/Unity Tutorial/_category.json index 95b84e96a17..a3c837ad48a 100644 --- a/docs/docs/Unity Tutorial/_category.json +++ b/docs/docs/Unity Tutorial/_category.json @@ -1 +1,5 @@ -{"title":"Unity Tutorial","disabled":false,"index":"index.md"} \ No newline at end of file +{ + "title": "Unity Tutorial", + "disabled": false, + "index": "Part 1 - Basic Multiplayer.md" +} \ No newline at end of file From fac941e4d119722dc9c4266a9976048d1e051e09 Mon Sep 17 00:00:00 2001 From: John Detter Date: Wed, 11 Oct 2023 21:58:10 -0500 Subject: [PATCH 008/195] LICENSE.txt and basic README.md --- docs/LICENSE.txt | 174 +++++++++++++++++++++++++++++++++++++++++++++++ docs/README.md | 34 +++++++++ 2 files changed, 208 insertions(+) create mode 100644 docs/LICENSE.txt create mode 100644 docs/README.md diff --git a/docs/LICENSE.txt b/docs/LICENSE.txt new file mode 100644 index 00000000000..dd5b3a58aa1 --- /dev/null +++ b/docs/LICENSE.txt @@ -0,0 +1,174 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + 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. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000000..cfe1e0af55e --- /dev/null +++ b/docs/README.md @@ -0,0 +1,34 @@ +## SpacetimeDB Documentation + +This repository contains the markdown files which are used to display documentation on our [website](https://spacetimedb.com/docs). + +### Making Edits + +To make changes to our docs, you can open a pull request in this repository. You can typically edit the files directly using the GitHub web interface, but you can also clone our repository and make your edits locally. To do this you can follow these instructions: + +1. Fork our repository +2. Clone your fork: + +```bash +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) +4. Commit your changes: + +```bash +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 +git checkout -b a-branch-name-that-describes-my-change +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 + +## License + +This documentation repository is licensed under Apache 2.0. See LICENSE.txt for more details. From 540293a8076c7b0c2765e47c8bc4897691b65fc6 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Wed, 11 Oct 2023 22:22:57 -0700 Subject: [PATCH 009/195] Update index.md --- docs/docs/Overview/index.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/docs/Overview/index.md b/docs/docs/Overview/index.md index 2464e6e3303..264b586191c 100644 --- a/docs/docs/Overview/index.md +++ b/docs/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 45032f7c3cde5b37c9804c5e394faea64adc71aa Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Wed, 11 Oct 2023 23:07:24 -0700 Subject: [PATCH 010/195] Update index.md --- docs/docs/Overview/index.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/docs/Overview/index.md b/docs/docs/Overview/index.md index 264b586191c..2464e6e3303 100644 --- a/docs/docs/Overview/index.md +++ b/docs/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 f467e18f7a604301c1a6f3453d95937c23f87e13 Mon Sep 17 00:00:00 2001 From: John Detter Date: Thu, 12 Oct 2023 08:22:23 -0500 Subject: [PATCH 011/195] Including this line for completeness --- docs/LICENSE.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/LICENSE.txt b/docs/LICENSE.txt index dd5b3a58aa1..d9a10c0d8e8 100644 --- a/docs/LICENSE.txt +++ b/docs/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 59fc108a4fb9f216e80a89ee56d6dd10487f4afa Mon Sep 17 00:00:00 2001 From: John Detter Date: Thu, 12 Oct 2023 09:39:03 -0500 Subject: [PATCH 012/195] C# Quickstart is working --- docs/docs/Client SDK Languages/C#/index.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/docs/docs/Client SDK Languages/C#/index.md b/docs/docs/Client SDK Languages/C#/index.md index b64ca13d5ab..eb7829a6313 100644 --- a/docs/docs/Client SDK Languages/C#/index.md +++ b/docs/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 d94a48538112d92795fc428a1d959bc3cae6f2bf Mon Sep 17 00:00:00 2001 From: John Detter Date: Thu, 12 Oct 2023 11:16:06 -0500 Subject: [PATCH 013/195] Python quickstart updated --- docs/docs/Client SDK Languages/Python/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/Client SDK Languages/Python/index.md b/docs/docs/Client SDK Languages/Python/index.md index 526304524da..6cdac567e3f 100644 --- a/docs/docs/Client SDK Languages/Python/index.md +++ b/docs/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 8bcfd164b159dabe03bf16553e5edc1a05659a3f Mon Sep 17 00:00:00 2001 From: John Detter Date: Thu, 12 Oct 2023 11:48:08 -0500 Subject: [PATCH 014/195] Another small python quickstart fix --- docs/docs/Client SDK Languages/Python/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/Client SDK Languages/Python/index.md b/docs/docs/Client SDK Languages/Python/index.md index 6cdac567e3f..29552c32334 100644 --- a/docs/docs/Client SDK Languages/Python/index.md +++ b/docs/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 4b1078b0473233f4a7f90d56bb02edf4782e7c68 Mon Sep 17 00:00:00 2001 From: John Detter Date: Thu, 12 Oct 2023 14:10:57 -0500 Subject: [PATCH 015/195] Fix Nuget command --- docs/docs/Client SDK Languages/C#/index.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/Client SDK Languages/C#/index.md b/docs/docs/Client SDK Languages/C#/index.md index eb7829a6313..66342bceb53 100644 --- a/docs/docs/Client SDK Languages/C#/index.md +++ b/docs/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 a6a2419d179aa6cfa4a780b262c61eb917a95e1b Mon Sep 17 00:00:00 2001 From: John Detter Date: Thu, 12 Oct 2023 15:16:44 -0500 Subject: [PATCH 016/195] Applied Phoebe's patch --- .../Client SDK Languages/C#/SDK Reference.md | 49 ++++--- docs/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/docs/Client SDK Languages/Rust/index.md | 18 ++- .../Typescript/SDK Reference.md | 127 +++++++++++++++--- .../Client SDK Languages/Typescript/index.md | 8 +- docs/docs/Overview/index.md | 12 +- .../C#/ModuleReference.md | 9 +- docs/docs/Server Module Languages/C#/index.md | 5 +- .../Server Module Languages/Rust/index.md | 6 +- .../Part 1 - Basic Multiplayer.md | 6 +- 13 files changed, 295 insertions(+), 114 deletions(-) diff --git a/docs/docs/Client SDK Languages/C#/SDK Reference.md b/docs/docs/Client SDK Languages/C#/SDK Reference.md index 3284e6fefc8..ad4c8c482f1 100644 --- a/docs/docs/Client SDK Languages/C#/SDK Reference.md +++ b/docs/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/docs/Client SDK Languages/C#/index.md b/docs/docs/Client SDK Languages/C#/index.md index 66342bceb53..f4d8b7eeca7 100644 --- a/docs/docs/Client SDK Languages/C#/index.md +++ b/docs/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/docs/Client SDK Languages/Python/SDK Reference.md b/docs/docs/Client SDK Languages/Python/SDK Reference.md index 8cd4b4ca3c0..276d59df3c5 100644 --- a/docs/docs/Client SDK Languages/Python/SDK Reference.md +++ b/docs/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/docs/Client SDK Languages/Python/index.md b/docs/docs/Client SDK Languages/Python/index.md index 29552c32334..25723fcce05 100644 --- a/docs/docs/Client SDK Languages/Python/index.md +++ b/docs/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/docs/Client SDK Languages/Rust/SDK Reference.md b/docs/docs/Client SDK Languages/Rust/SDK Reference.md index c61a06f3dd5..bd914b00c9a 100644 --- a/docs/docs/Client SDK Languages/Rust/SDK Reference.md +++ b/docs/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/docs/Client SDK Languages/Rust/index.md b/docs/docs/Client SDK Languages/Rust/index.md index c44ab49d9e4..f35f082971c 100644 --- a/docs/docs/Client SDK Languages/Rust/index.md +++ b/docs/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/docs/Client SDK Languages/Typescript/SDK Reference.md b/docs/docs/Client SDK Languages/Typescript/SDK Reference.md index 657115d7e46..fb7d5be641d 100644 --- a/docs/docs/Client SDK Languages/Typescript/SDK Reference.md +++ b/docs/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/docs/Client SDK Languages/Typescript/index.md b/docs/docs/Client SDK Languages/Typescript/index.md index ae893af5fa2..8baed6fb8d0 100644 --- a/docs/docs/Client SDK Languages/Typescript/index.md +++ b/docs/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/docs/Overview/index.md b/docs/docs/Overview/index.md index 2464e6e3303..0e1a63949c8 100644 --- a/docs/docs/Overview/index.md +++ b/docs/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/docs/Server Module Languages/C#/ModuleReference.md b/docs/docs/Server Module Languages/C#/ModuleReference.md index 305ea211d08..d655ea6db31 100644 --- a/docs/docs/Server Module Languages/C#/ModuleReference.md +++ b/docs/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/docs/Server Module Languages/C#/index.md b/docs/docs/Server Module Languages/C#/index.md index e849002f6e7..6893a0898fd 100644 --- a/docs/docs/Server Module Languages/C#/index.md +++ b/docs/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/docs/Server Module Languages/Rust/index.md b/docs/docs/Server Module Languages/Rust/index.md index ed59d8dd4ba..9f0a6636c35 100644 --- a/docs/docs/Server Module Languages/Rust/index.md +++ b/docs/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/docs/Unity Tutorial/Part 1 - Basic Multiplayer.md b/docs/docs/Unity Tutorial/Part 1 - Basic Multiplayer.md index 92f1a04c130..4d51790f6fc 100644 --- a/docs/docs/Unity Tutorial/Part 1 - Basic Multiplayer.md +++ b/docs/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 10f6d69473110f78f9bafe5d70d2ff706008f9d7 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 017/195] This was an error in applying a patch (#6) Co-authored-by: John Detter --- docs/docs/Server Module Languages/C#/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/Server Module Languages/C#/index.md b/docs/docs/Server Module Languages/C#/index.md index 6893a0898fd..473a8ac6e6f 100644 --- a/docs/docs/Server Module Languages/C#/index.md +++ b/docs/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 c78d478b631a3c7bc99d1bb12aed07c2e3fbfe21 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 018/195] 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 + .../Unity Tutorial/Part 3 - BitCraft Mini.md | 2 + 3 files changed, 405 insertions(+), 460 deletions(-) diff --git a/docs/docs/Unity Tutorial/Part 1 - Basic Multiplayer.md b/docs/docs/Unity Tutorial/Part 1 - Basic Multiplayer.md index 4d51790f6fc..915fd4448d1 100644 --- a/docs/docs/Unity Tutorial/Part 1 - Basic Multiplayer.md +++ b/docs/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/docs/Unity Tutorial/Part 2 - Resources And Scheduling.md b/docs/docs/Unity Tutorial/Part 2 - Resources And Scheduling.md index 5cd205efc17..537edd4437c 100644 --- a/docs/docs/Unity Tutorial/Part 2 - Resources And Scheduling.md +++ b/docs/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/docs/Unity Tutorial/Part 3 - BitCraft Mini.md b/docs/docs/Unity Tutorial/Part 3 - BitCraft Mini.md index e1f5e3eb616..16816dd6304 100644 --- a/docs/docs/Unity Tutorial/Part 3 - BitCraft Mini.md +++ b/docs/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 0c28c87b1d84869f8f287e000d0ac03bd9f1adbe 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 019/195] Addressing Chippy's feedback (#8) Co-authored-by: John Detter --- docs/docs/Server Module Languages/C#/index.md | 2 +- docs/docs/Unity Tutorial/Part 1 - Basic Multiplayer.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/Server Module Languages/C#/index.md b/docs/docs/Server Module Languages/C#/index.md index 473a8ac6e6f..dd818b07222 100644 --- a/docs/docs/Server Module Languages/C#/index.md +++ b/docs/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/docs/Unity Tutorial/Part 1 - Basic Multiplayer.md b/docs/docs/Unity Tutorial/Part 1 - Basic Multiplayer.md index 915fd4448d1..30bd3137cac 100644 --- a/docs/docs/Unity Tutorial/Part 1 - Basic Multiplayer.md +++ b/docs/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 17c3c12b4e84b99045e28621d3c7515bc8e69c20 Mon Sep 17 00:00:00 2001 From: Ingvar Stepanyan Date: Wed, 25 Oct 2023 11:53:25 +0100 Subject: [PATCH 020/195] Update C# docs for connect/disconnect (#9) Update after the change in https://github.com/clockworklabs/SpacetimeDB/pull/309. --- .../C#/ModuleReference.md | 16 +--- docs/docs/Server Module Languages/C#/index.md | 80 +++++++++---------- 2 files changed, 42 insertions(+), 54 deletions(-) diff --git a/docs/docs/Server Module Languages/C#/ModuleReference.md b/docs/docs/Server Module Languages/C#/ModuleReference.md index d655ea6db31..36a9618a95f 100644 --- a/docs/docs/Server Module Languages/C#/ModuleReference.md +++ b/docs/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/docs/Server Module Languages/C#/index.md b/docs/docs/Server Module Languages/C#/index.md index dd818b07222..03937466a6d 100644 --- a/docs/docs/Server Module Languages/C#/index.md +++ b/docs/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 7f1b1565e66a638a114889fea2b57e0455acf8b5 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 021/195] Fix syntax highlighting (#10) Co-authored-by: John Detter --- docs/docs/Server Module Languages/C#/index.md | 192 +++++++++--------- 1 file changed, 96 insertions(+), 96 deletions(-) diff --git a/docs/docs/Server Module Languages/C#/index.md b/docs/docs/Server Module Languages/C#/index.md index 03937466a6d..0346157fc1c 100644 --- a/docs/docs/Server Module Languages/C#/index.md +++ b/docs/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 8dcb3c9571e8bd6f12ed6860d5baf81cfb00d0e0 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Mon, 20 Nov 2023 17:49:34 -0800 Subject: [PATCH 022/195] Update index.md Fixes a margin for the figure that kinda breaks it on mobile. --- docs/docs/Overview/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/Overview/index.md b/docs/docs/Overview/index.md index 0e1a63949c8..35ebbcb73be 100644 --- a/docs/docs/Overview/index.md +++ b/docs/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 34fdd1f64e399ed29a77ab14502885ad46b7b414 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Tue, 21 Nov 2023 19:34:27 -0800 Subject: [PATCH 023/195] Update index.md --- docs/docs/Client SDK Languages/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/Client SDK Languages/index.md b/docs/docs/Client SDK Languages/index.md index 27c9284fd69..2e3e074079c 100644 --- a/docs/docs/Client SDK Languages/index.md +++ b/docs/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 6ef985f6c6ebe19736e3ae7adc45b182046fe35a Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Tue, 21 Nov 2023 22:39:35 -0800 Subject: [PATCH 024/195] Added nav.ts --- docs/docs/nav.ts | 75 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 docs/docs/nav.ts diff --git a/docs/docs/nav.ts b/docs/docs/nav.ts new file mode 100644 index 00000000000..caccf9d19c1 --- /dev/null +++ b/docs/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 dbc1745ec03c96bdef71c870572e203c5f466143 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Tue, 21 Nov 2023 22:52:36 -0800 Subject: [PATCH 025/195] Removed satisfies keyword for better Typescript compat --- docs/docs/nav.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/docs/nav.ts b/docs/docs/nav.ts index caccf9d19c1..7a1a032fd43 100644 --- a/docs/docs/nav.ts +++ b/docs/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 7d7437463cb731949052743426ac1ec5b0d52e17 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Wed, 22 Nov 2023 20:14:10 -0800 Subject: [PATCH 026/195] Added slugs to nav --- docs/docs/nav.ts | 63 ++++++++++++++++++++++++------------------------ 1 file changed, 32 insertions(+), 31 deletions(-) diff --git a/docs/docs/nav.ts b/docs/docs/nav.ts index 7a1a032fd43..f7681728dbc 100644 --- a/docs/docs/nav.ts +++ b/docs/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 31a977819d6c75618c4cc146b0f63501c0a62267 Mon Sep 17 00:00:00 2001 From: Ingvar Stepanyan Date: Mon, 27 Nov 2023 20:41:01 +0000 Subject: [PATCH 027/195] Ask users to install .NET 8 --- docs/docs/Server Module Languages/C#/index.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/Server Module Languages/C#/index.md b/docs/docs/Server Module Languages/C#/index.md index 0346157fc1c..d56384235e8 100644 --- a/docs/docs/Server Module Languages/C#/index.md +++ b/docs/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 355d5fa919c314de33033daffe1e5c95d3463dd3 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Mon, 27 Nov 2023 23:58:02 -0800 Subject: [PATCH 028/195] Fix most (but possibly not all) links in the docs --- .../Client SDK Languages/C#/SDK Reference.md | 6 ++-- docs/docs/Client SDK Languages/C#/index.md | 2 +- .../Python/SDK Reference.md | 2 +- .../docs/Client SDK Languages/Python/index.md | 2 +- docs/docs/Client SDK Languages/Rust/index.md | 4 +-- .../Client SDK Languages/Typescript/index.md | 2 +- docs/docs/Client SDK Languages/index.md | 8 ++--- docs/docs/Getting Started/index.md | 14 ++++---- docs/docs/HTTP API Reference/Databases.md | 34 +++++++++---------- docs/docs/HTTP API Reference/Energy.md | 2 +- docs/docs/HTTP API Reference/Identities.md | 6 ++-- docs/docs/HTTP API Reference/index.md | 4 +-- docs/docs/Overview/index.md | 18 +++++----- docs/docs/SATN Reference/index.md | 4 +-- docs/docs/SQL Reference/index.md | 2 +- docs/docs/Server Module Languages/C#/index.md | 2 +- .../Server Module Languages/Rust/index.md | 4 +-- docs/docs/Server Module Languages/index.md | 8 ++--- .../Unity Tutorial/Part 3 - BitCraft Mini.md | 2 +- docs/docs/WebSocket API Reference/index.md | 12 +++---- 20 files changed, 69 insertions(+), 69 deletions(-) diff --git a/docs/docs/Client SDK Languages/C#/SDK Reference.md b/docs/docs/Client SDK Languages/C#/SDK Reference.md index ad4c8c482f1..473ca1ba636 100644 --- a/docs/docs/Client SDK Languages/C#/SDK Reference.md +++ b/docs/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/docs/Client SDK Languages/C#/index.md b/docs/docs/Client SDK Languages/C#/index.md index f4d8b7eeca7..f7565019933 100644 --- a/docs/docs/Client SDK Languages/C#/index.md +++ b/docs/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/docs/Client SDK Languages/Python/SDK Reference.md b/docs/docs/Client SDK Languages/Python/SDK Reference.md index 276d59df3c5..8b1ceb8b25a 100644 --- a/docs/docs/Client SDK Languages/Python/SDK Reference.md +++ b/docs/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/docs/Client SDK Languages/Python/index.md b/docs/docs/Client SDK Languages/Python/index.md index 25723fcce05..2b9d7aa128d 100644 --- a/docs/docs/Client SDK Languages/Python/index.md +++ b/docs/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/docs/Client SDK Languages/Rust/index.md b/docs/docs/Client SDK Languages/Rust/index.md index f35f082971c..d1969fc3852 100644 --- a/docs/docs/Client SDK Languages/Rust/index.md +++ b/docs/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/docs/Client SDK Languages/Typescript/index.md b/docs/docs/Client SDK Languages/Typescript/index.md index 8baed6fb8d0..ab7cfe897ac 100644 --- a/docs/docs/Client SDK Languages/Typescript/index.md +++ b/docs/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/docs/Client SDK Languages/index.md b/docs/docs/Client SDK Languages/index.md index 2e3e074079c..6357e653915 100644 --- a/docs/docs/Client SDK Languages/index.md +++ b/docs/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/docs/Getting Started/index.md b/docs/docs/Getting Started/index.md index 854d227c81e..54337d08625 100644 --- a/docs/docs/Getting Started/index.md +++ b/docs/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/docs/HTTP API Reference/Databases.md b/docs/docs/HTTP API Reference/Databases.md index 91e7d0a97ea..2d55188a74c 100644 --- a/docs/docs/HTTP API Reference/Databases.md +++ b/docs/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/docs/HTTP API Reference/Energy.md b/docs/docs/HTTP API Reference/Energy.md index a7b6d05a2cd..b49a1ee7f21 100644 --- a/docs/docs/HTTP API Reference/Energy.md +++ b/docs/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/docs/HTTP API Reference/Identities.md b/docs/docs/HTTP API Reference/Identities.md index 87411759f52..5fb45867883 100644 --- a/docs/docs/HTTP API Reference/Identities.md +++ b/docs/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/docs/HTTP API Reference/index.md b/docs/docs/HTTP API Reference/index.md index 224aaf7766b..a4e885b1a97 100644 --- a/docs/docs/HTTP API Reference/index.md +++ b/docs/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/docs/Overview/index.md b/docs/docs/Overview/index.md index 35ebbcb73be..7a95f4f8516 100644 --- a/docs/docs/Overview/index.md +++ b/docs/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/docs/SATN Reference/index.md b/docs/docs/SATN Reference/index.md index cedc496a726..f21e9b3068b 100644 --- a/docs/docs/SATN Reference/index.md +++ b/docs/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/docs/SQL Reference/index.md b/docs/docs/SQL Reference/index.md index 08f9536a36a..660972096ae 100644 --- a/docs/docs/SQL Reference/index.md +++ b/docs/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/docs/Server Module Languages/C#/index.md b/docs/docs/Server Module Languages/C#/index.md index 0346157fc1c..3d543f4a573 100644 --- a/docs/docs/Server Module Languages/C#/index.md +++ b/docs/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/docs/Server Module Languages/Rust/index.md b/docs/docs/Server Module Languages/Rust/index.md index 9f0a6636c35..6e0f174732d 100644 --- a/docs/docs/Server Module Languages/Rust/index.md +++ b/docs/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/docs/Server Module Languages/index.md b/docs/docs/Server Module Languages/index.md index d66681319f9..d7d136857f7 100644 --- a/docs/docs/Server Module Languages/index.md +++ b/docs/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/docs/Unity Tutorial/Part 3 - BitCraft Mini.md b/docs/docs/Unity Tutorial/Part 3 - BitCraft Mini.md index 16816dd6304..b49b5a5d7b8 100644 --- a/docs/docs/Unity Tutorial/Part 3 - BitCraft Mini.md +++ b/docs/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/docs/WebSocket API Reference/index.md b/docs/docs/WebSocket API Reference/index.md index dd8fbc39a6a..7624016366a 100644 --- a/docs/docs/WebSocket API Reference/index.md +++ b/docs/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 fe6b7b7f39f27dce1548b07634c9a083a3565651 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 029/195] Update nav.ts --- docs/docs/nav.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/docs/nav.ts b/docs/docs/nav.ts index f7681728dbc..b9a64ee06ce 100644 --- a/docs/docs/nav.ts +++ b/docs/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 603b065b2052f9b97784ad0eb26ab2632f3ffc65 Mon Sep 17 00:00:00 2001 From: Nathaniel Richards Date: Tue, 28 Nov 2023 15:01:39 -0500 Subject: [PATCH 030/195] Created buildeR --- docs/.gitignore | 3 +-- docs/docs/nav.js | 52 ++++++++++++++++++++++++++++++++++++++++++ docs/{docs => }/nav.ts | 0 docs/package.json | 15 ++++++++++++ docs/tsconfig.json | 8 +++++++ docs/yarn.lock | 8 +++++++ 6 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 docs/docs/nav.js rename docs/{docs => }/nav.ts (100%) create mode 100644 docs/package.json create mode 100644 docs/tsconfig.json create mode 100644 docs/yarn.lock diff --git a/docs/.gitignore b/docs/.gitignore index 55f71abdcee..589c396ecb4 100644 --- a/docs/.gitignore +++ b/docs/.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/docs/nav.js b/docs/docs/nav.js new file mode 100644 index 00000000000..b62e9b7f7d9 --- /dev/null +++ b/docs/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/docs/nav.ts b/docs/nav.ts similarity index 100% rename from docs/docs/nav.ts rename to docs/nav.ts diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 00000000000..a56ea4e86a6 --- /dev/null +++ b/docs/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/docs/tsconfig.json b/docs/tsconfig.json new file mode 100644 index 00000000000..2a5ee7d21d4 --- /dev/null +++ b/docs/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "commonjs", + "outDir": "./docs", + "skipLibCheck": true + } +} diff --git a/docs/yarn.lock b/docs/yarn.lock new file mode 100644 index 00000000000..fce89544647 --- /dev/null +++ b/docs/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 3d014fb1755eb5c52453992f148127d6b7389415 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Tue, 28 Nov 2023 17:21:24 -0800 Subject: [PATCH 031/195] Reorganized the doc paths to match slugs and removed _category.json files --- .../Client SDK Languages/C#/_category.json | 5 -- .../Python/_category.json | 5 -- .../Client SDK Languages/Rust/_category.json | 5 -- .../Typescript/_category.json | 5 -- docs/docs/Client SDK Languages/_category.json | 1 - docs/docs/Cloud Testnet/_category.json | 1 - docs/docs/Getting Started/_category.json | 1 - docs/docs/HTTP API Reference/_category.json | 1 - docs/docs/Module ABI Reference/_category.json | 1 - docs/docs/Overview/_category.json | 1 - docs/docs/SATN Reference/_category.json | 1 - docs/docs/SQL Reference/_category.json | 1 - .../Server Module Languages/C#/_category.json | 6 -- .../Rust/_category.json | 5 -- .../Server Module Languages/_category.json | 1 - docs/docs/Unity Tutorial/_category.json | 5 -- .../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 .../{HTTP API Reference => http}/index.md | 0 docs/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/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 .../{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/docs/{SQL Reference => sql}/index.md | 0 .../part-1.md} | 0 .../part-2.md} | 0 .../part-3.md} | 0 .../index.md | 0 .../{WebSocket API Reference => ws}/index.md | 0 docs/nav.ts | 58 +++++++++---------- 48 files changed, 58 insertions(+), 104 deletions(-) delete mode 100644 docs/docs/Client SDK Languages/C#/_category.json delete mode 100644 docs/docs/Client SDK Languages/Python/_category.json delete mode 100644 docs/docs/Client SDK Languages/Rust/_category.json delete mode 100644 docs/docs/Client SDK Languages/Typescript/_category.json delete mode 100644 docs/docs/Client SDK Languages/_category.json delete mode 100644 docs/docs/Cloud Testnet/_category.json delete mode 100644 docs/docs/Getting Started/_category.json delete mode 100644 docs/docs/HTTP API Reference/_category.json delete mode 100644 docs/docs/Module ABI Reference/_category.json delete mode 100644 docs/docs/Overview/_category.json delete mode 100644 docs/docs/SATN Reference/_category.json delete mode 100644 docs/docs/SQL Reference/_category.json delete mode 100644 docs/docs/Server Module Languages/C#/_category.json delete mode 100644 docs/docs/Server Module Languages/Rust/_category.json delete mode 100644 docs/docs/Server Module Languages/_category.json delete mode 100644 docs/docs/Unity Tutorial/_category.json delete mode 100644 docs/docs/WebSocket API Reference/_category.json rename docs/docs/{SATN Reference/Binary Format.md => bsatn.md} (100%) rename docs/docs/{Cloud Testnet/index.md => deploying/testnet.md} (100%) rename docs/docs/{Getting Started/index.md => getting-started.md} (100%) rename docs/docs/{HTTP API Reference/Databases.md => http/database.md} (100%) rename docs/docs/{HTTP API Reference/Energy.md => http/energy.md} (100%) rename docs/docs/{HTTP API Reference/Identities.md => http/identity.md} (100%) rename docs/docs/{HTTP API Reference => http}/index.md (100%) rename docs/docs/{Overview => }/index.md (100%) rename docs/docs/{Server Module Languages/C#/ModuleReference.md => modules/c-sharp/index.md} (100%) rename docs/docs/{Server Module Languages/C#/index.md => modules/c-sharp/quickstart.md} (100%) rename docs/docs/{Server Module Languages => modules}/index.md (100%) rename docs/docs/{Server Module Languages/Rust/ModuleReference.md => modules/rust/index.md} (100%) rename docs/docs/{Server Module Languages/Rust/index.md => modules/rust/quickstart.md} (100%) rename docs/docs/{SATN Reference/index.md => satn.md} (100%) rename docs/docs/{Client SDK Languages/C#/SDK Reference.md => sdks/c-sharp/index.md} (100%) rename docs/docs/{Client SDK Languages/C#/index.md => sdks/c-sharp/quickstart.md} (100%) rename docs/docs/{Client SDK Languages => sdks}/index.md (100%) rename docs/docs/{Client SDK Languages/Python/SDK Reference.md => sdks/python/index.md} (100%) rename docs/docs/{Client SDK Languages/Python/index.md => sdks/python/quickstart.md} (100%) rename docs/docs/{Client SDK Languages/Rust/SDK Reference.md => sdks/rust/index.md} (100%) rename docs/docs/{Client SDK Languages/Rust/index.md => sdks/rust/quickstart.md} (100%) rename docs/docs/{Client SDK Languages/Typescript/SDK Reference.md => sdks/typescript/index.md} (100%) rename docs/docs/{Client SDK Languages/Typescript/index.md => sdks/typescript/quickstart.md} (100%) rename docs/docs/{SQL Reference => sql}/index.md (100%) rename docs/docs/{Unity Tutorial/Part 1 - Basic Multiplayer.md => unity/part-1.md} (100%) rename docs/docs/{Unity Tutorial/Part 2 - Resources And Scheduling.md => unity/part-2.md} (100%) rename docs/docs/{Unity Tutorial/Part 3 - BitCraft Mini.md => unity/part-3.md} (100%) rename docs/docs/{Module ABI Reference => webassembly-abi}/index.md (100%) rename docs/docs/{WebSocket API Reference => ws}/index.md (100%) diff --git a/docs/docs/Client SDK Languages/C#/_category.json b/docs/docs/Client SDK Languages/C#/_category.json deleted file mode 100644 index 60238f8ed6f..00000000000 --- a/docs/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/docs/Client SDK Languages/Python/_category.json b/docs/docs/Client SDK Languages/Python/_category.json deleted file mode 100644 index 4e08cfa1cb3..00000000000 --- a/docs/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/docs/Client SDK Languages/Rust/_category.json b/docs/docs/Client SDK Languages/Rust/_category.json deleted file mode 100644 index 6280366ccfe..00000000000 --- a/docs/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/docs/Client SDK Languages/Typescript/_category.json b/docs/docs/Client SDK Languages/Typescript/_category.json deleted file mode 100644 index 590d44a25ba..00000000000 --- a/docs/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/docs/Client SDK Languages/_category.json b/docs/docs/Client SDK Languages/_category.json deleted file mode 100644 index 530c17aa6e9..00000000000 --- a/docs/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/docs/Cloud Testnet/_category.json b/docs/docs/Cloud Testnet/_category.json deleted file mode 100644 index e6fa11b9bf0..00000000000 --- a/docs/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/docs/Getting Started/_category.json b/docs/docs/Getting Started/_category.json deleted file mode 100644 index a68dc36c049..00000000000 --- a/docs/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/docs/HTTP API Reference/_category.json b/docs/docs/HTTP API Reference/_category.json deleted file mode 100644 index c8ad821bd65..00000000000 --- a/docs/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/docs/Module ABI Reference/_category.json b/docs/docs/Module ABI Reference/_category.json deleted file mode 100644 index 7583598ddca..00000000000 --- a/docs/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/docs/Overview/_category.json b/docs/docs/Overview/_category.json deleted file mode 100644 index 35164a50a91..00000000000 --- a/docs/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/docs/SATN Reference/_category.json b/docs/docs/SATN Reference/_category.json deleted file mode 100644 index e26b2f0564a..00000000000 --- a/docs/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/docs/SQL Reference/_category.json b/docs/docs/SQL Reference/_category.json deleted file mode 100644 index 73d7df23590..00000000000 --- a/docs/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/docs/Server Module Languages/C#/_category.json b/docs/docs/Server Module Languages/C#/_category.json deleted file mode 100644 index 71ae9015f93..00000000000 --- a/docs/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/docs/Server Module Languages/Rust/_category.json b/docs/docs/Server Module Languages/Rust/_category.json deleted file mode 100644 index 6280366ccfe..00000000000 --- a/docs/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/docs/Server Module Languages/_category.json b/docs/docs/Server Module Languages/_category.json deleted file mode 100644 index 3bfa0e87292..00000000000 --- a/docs/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/docs/Unity Tutorial/_category.json b/docs/docs/Unity Tutorial/_category.json deleted file mode 100644 index a3c837ad48a..00000000000 --- a/docs/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/docs/WebSocket API Reference/_category.json b/docs/docs/WebSocket API Reference/_category.json deleted file mode 100644 index d27973062d0..00000000000 --- a/docs/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/docs/SATN Reference/Binary Format.md b/docs/docs/bsatn.md similarity index 100% rename from docs/docs/SATN Reference/Binary Format.md rename to docs/docs/bsatn.md diff --git a/docs/docs/Cloud Testnet/index.md b/docs/docs/deploying/testnet.md similarity index 100% rename from docs/docs/Cloud Testnet/index.md rename to docs/docs/deploying/testnet.md diff --git a/docs/docs/Getting Started/index.md b/docs/docs/getting-started.md similarity index 100% rename from docs/docs/Getting Started/index.md rename to docs/docs/getting-started.md diff --git a/docs/docs/HTTP API Reference/Databases.md b/docs/docs/http/database.md similarity index 100% rename from docs/docs/HTTP API Reference/Databases.md rename to docs/docs/http/database.md diff --git a/docs/docs/HTTP API Reference/Energy.md b/docs/docs/http/energy.md similarity index 100% rename from docs/docs/HTTP API Reference/Energy.md rename to docs/docs/http/energy.md diff --git a/docs/docs/HTTP API Reference/Identities.md b/docs/docs/http/identity.md similarity index 100% rename from docs/docs/HTTP API Reference/Identities.md rename to docs/docs/http/identity.md diff --git a/docs/docs/HTTP API Reference/index.md b/docs/docs/http/index.md similarity index 100% rename from docs/docs/HTTP API Reference/index.md rename to docs/docs/http/index.md diff --git a/docs/docs/Overview/index.md b/docs/docs/index.md similarity index 100% rename from docs/docs/Overview/index.md rename to docs/docs/index.md diff --git a/docs/docs/Server Module Languages/C#/ModuleReference.md b/docs/docs/modules/c-sharp/index.md similarity index 100% rename from docs/docs/Server Module Languages/C#/ModuleReference.md rename to docs/docs/modules/c-sharp/index.md diff --git a/docs/docs/Server Module Languages/C#/index.md b/docs/docs/modules/c-sharp/quickstart.md similarity index 100% rename from docs/docs/Server Module Languages/C#/index.md rename to docs/docs/modules/c-sharp/quickstart.md diff --git a/docs/docs/Server Module Languages/index.md b/docs/docs/modules/index.md similarity index 100% rename from docs/docs/Server Module Languages/index.md rename to docs/docs/modules/index.md diff --git a/docs/docs/Server Module Languages/Rust/ModuleReference.md b/docs/docs/modules/rust/index.md similarity index 100% rename from docs/docs/Server Module Languages/Rust/ModuleReference.md rename to docs/docs/modules/rust/index.md diff --git a/docs/docs/Server Module Languages/Rust/index.md b/docs/docs/modules/rust/quickstart.md similarity index 100% rename from docs/docs/Server Module Languages/Rust/index.md rename to docs/docs/modules/rust/quickstart.md diff --git a/docs/docs/nav.js b/docs/docs/nav.js index b62e9b7f7d9..cb8d22f1715 100644 --- a/docs/docs/nav.js +++ b/docs/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/docs/SATN Reference/index.md b/docs/docs/satn.md similarity index 100% rename from docs/docs/SATN Reference/index.md rename to docs/docs/satn.md diff --git a/docs/docs/Client SDK Languages/C#/SDK Reference.md b/docs/docs/sdks/c-sharp/index.md similarity index 100% rename from docs/docs/Client SDK Languages/C#/SDK Reference.md rename to docs/docs/sdks/c-sharp/index.md diff --git a/docs/docs/Client SDK Languages/C#/index.md b/docs/docs/sdks/c-sharp/quickstart.md similarity index 100% rename from docs/docs/Client SDK Languages/C#/index.md rename to docs/docs/sdks/c-sharp/quickstart.md diff --git a/docs/docs/Client SDK Languages/index.md b/docs/docs/sdks/index.md similarity index 100% rename from docs/docs/Client SDK Languages/index.md rename to docs/docs/sdks/index.md diff --git a/docs/docs/Client SDK Languages/Python/SDK Reference.md b/docs/docs/sdks/python/index.md similarity index 100% rename from docs/docs/Client SDK Languages/Python/SDK Reference.md rename to docs/docs/sdks/python/index.md diff --git a/docs/docs/Client SDK Languages/Python/index.md b/docs/docs/sdks/python/quickstart.md similarity index 100% rename from docs/docs/Client SDK Languages/Python/index.md rename to docs/docs/sdks/python/quickstart.md diff --git a/docs/docs/Client SDK Languages/Rust/SDK Reference.md b/docs/docs/sdks/rust/index.md similarity index 100% rename from docs/docs/Client SDK Languages/Rust/SDK Reference.md rename to docs/docs/sdks/rust/index.md diff --git a/docs/docs/Client SDK Languages/Rust/index.md b/docs/docs/sdks/rust/quickstart.md similarity index 100% rename from docs/docs/Client SDK Languages/Rust/index.md rename to docs/docs/sdks/rust/quickstart.md diff --git a/docs/docs/Client SDK Languages/Typescript/SDK Reference.md b/docs/docs/sdks/typescript/index.md similarity index 100% rename from docs/docs/Client SDK Languages/Typescript/SDK Reference.md rename to docs/docs/sdks/typescript/index.md diff --git a/docs/docs/Client SDK Languages/Typescript/index.md b/docs/docs/sdks/typescript/quickstart.md similarity index 100% rename from docs/docs/Client SDK Languages/Typescript/index.md rename to docs/docs/sdks/typescript/quickstart.md diff --git a/docs/docs/SQL Reference/index.md b/docs/docs/sql/index.md similarity index 100% rename from docs/docs/SQL Reference/index.md rename to docs/docs/sql/index.md diff --git a/docs/docs/Unity Tutorial/Part 1 - Basic Multiplayer.md b/docs/docs/unity/part-1.md similarity index 100% rename from docs/docs/Unity Tutorial/Part 1 - Basic Multiplayer.md rename to docs/docs/unity/part-1.md diff --git a/docs/docs/Unity Tutorial/Part 2 - Resources And Scheduling.md b/docs/docs/unity/part-2.md similarity index 100% rename from docs/docs/Unity Tutorial/Part 2 - Resources And Scheduling.md rename to docs/docs/unity/part-2.md diff --git a/docs/docs/Unity Tutorial/Part 3 - BitCraft Mini.md b/docs/docs/unity/part-3.md similarity index 100% rename from docs/docs/Unity Tutorial/Part 3 - BitCraft Mini.md rename to docs/docs/unity/part-3.md diff --git a/docs/docs/Module ABI Reference/index.md b/docs/docs/webassembly-abi/index.md similarity index 100% rename from docs/docs/Module ABI Reference/index.md rename to docs/docs/webassembly-abi/index.md diff --git a/docs/docs/WebSocket API Reference/index.md b/docs/docs/ws/index.md similarity index 100% rename from docs/docs/WebSocket API Reference/index.md rename to docs/docs/ws/index.md diff --git a/docs/nav.ts b/docs/nav.ts index b9a64ee06ce..6d5a304b236 100644 --- a/docs/nav.ts +++ b/docs/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 9f9bf5794770f23d58291b30efc4f6fa09d77c80 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Tue, 28 Nov 2023 20:03:42 -0800 Subject: [PATCH 032/195] Revert "Reorganized the doc paths to match slugs and removed _category.json files" --- .../C#/SDK Reference.md} | 0 .../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 .../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/docs/Client SDK Languages/_category.json | 1 + .../{sdks => Client SDK Languages}/index.md | 0 docs/docs/Cloud Testnet/_category.json | 1 + .../testnet.md => Cloud Testnet/index.md} | 0 docs/docs/Getting Started/_category.json | 1 + .../index.md} | 0 .../Databases.md} | 0 .../Energy.md} | 0 .../Identities.md} | 0 docs/docs/HTTP API Reference/_category.json | 1 + .../{http => HTTP API Reference}/index.md | 0 docs/docs/Module ABI Reference/_category.json | 1 + .../index.md | 0 docs/docs/Overview/_category.json | 1 + docs/docs/{ => Overview}/index.md | 0 .../Binary Format.md} | 0 docs/docs/SATN Reference/_category.json | 1 + .../docs/{satn.md => SATN Reference/index.md} | 0 docs/docs/SQL Reference/_category.json | 1 + docs/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 .../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/docs/Unity Tutorial/_category.json | 5 ++ .../WebSocket API Reference/_category.json | 1 + .../{ws => WebSocket API Reference}/index.md | 0 docs/docs/nav.js | 58 +++++++++---------- docs/nav.ts | 58 +++++++++---------- 48 files changed, 104 insertions(+), 58 deletions(-) rename docs/docs/{sdks/c-sharp/index.md => Client SDK Languages/C#/SDK Reference.md} (100%) create mode 100644 docs/docs/Client SDK Languages/C#/_category.json rename docs/docs/{sdks/c-sharp/quickstart.md => Client SDK Languages/C#/index.md} (100%) rename docs/docs/{sdks/python/index.md => Client SDK Languages/Python/SDK Reference.md} (100%) create mode 100644 docs/docs/Client SDK Languages/Python/_category.json rename docs/docs/{sdks/python/quickstart.md => Client SDK Languages/Python/index.md} (100%) rename docs/docs/{sdks/rust/index.md => Client SDK Languages/Rust/SDK Reference.md} (100%) create mode 100644 docs/docs/Client SDK Languages/Rust/_category.json rename docs/docs/{sdks/rust/quickstart.md => Client SDK Languages/Rust/index.md} (100%) rename docs/docs/{sdks/typescript/index.md => Client SDK Languages/Typescript/SDK Reference.md} (100%) create mode 100644 docs/docs/Client SDK Languages/Typescript/_category.json rename docs/docs/{sdks/typescript/quickstart.md => Client SDK Languages/Typescript/index.md} (100%) create mode 100644 docs/docs/Client SDK Languages/_category.json rename docs/docs/{sdks => Client SDK Languages}/index.md (100%) create mode 100644 docs/docs/Cloud Testnet/_category.json rename docs/docs/{deploying/testnet.md => Cloud Testnet/index.md} (100%) create mode 100644 docs/docs/Getting Started/_category.json rename docs/docs/{getting-started.md => Getting Started/index.md} (100%) rename docs/docs/{http/database.md => HTTP API Reference/Databases.md} (100%) rename docs/docs/{http/energy.md => HTTP API Reference/Energy.md} (100%) rename docs/docs/{http/identity.md => HTTP API Reference/Identities.md} (100%) create mode 100644 docs/docs/HTTP API Reference/_category.json rename docs/docs/{http => HTTP API Reference}/index.md (100%) create mode 100644 docs/docs/Module ABI Reference/_category.json rename docs/docs/{webassembly-abi => Module ABI Reference}/index.md (100%) create mode 100644 docs/docs/Overview/_category.json rename docs/docs/{ => Overview}/index.md (100%) rename docs/docs/{bsatn.md => SATN Reference/Binary Format.md} (100%) create mode 100644 docs/docs/SATN Reference/_category.json rename docs/docs/{satn.md => SATN Reference/index.md} (100%) create mode 100644 docs/docs/SQL Reference/_category.json rename docs/docs/{sql => SQL Reference}/index.md (100%) rename docs/docs/{modules/c-sharp/index.md => Server Module Languages/C#/ModuleReference.md} (100%) create mode 100644 docs/docs/Server Module Languages/C#/_category.json rename docs/docs/{modules/c-sharp/quickstart.md => Server Module Languages/C#/index.md} (100%) rename docs/docs/{modules/rust/index.md => Server Module Languages/Rust/ModuleReference.md} (100%) create mode 100644 docs/docs/Server Module Languages/Rust/_category.json rename docs/docs/{modules/rust/quickstart.md => Server Module Languages/Rust/index.md} (100%) create mode 100644 docs/docs/Server Module Languages/_category.json rename docs/docs/{modules => Server Module Languages}/index.md (100%) rename docs/docs/{unity/part-1.md => Unity Tutorial/Part 1 - Basic Multiplayer.md} (100%) rename docs/docs/{unity/part-2.md => Unity Tutorial/Part 2 - Resources And Scheduling.md} (100%) rename docs/docs/{unity/part-3.md => Unity Tutorial/Part 3 - BitCraft Mini.md} (100%) create mode 100644 docs/docs/Unity Tutorial/_category.json create mode 100644 docs/docs/WebSocket API Reference/_category.json rename docs/docs/{ws => WebSocket API Reference}/index.md (100%) diff --git a/docs/docs/sdks/c-sharp/index.md b/docs/docs/Client SDK Languages/C#/SDK Reference.md similarity index 100% rename from docs/docs/sdks/c-sharp/index.md rename to docs/docs/Client SDK Languages/C#/SDK Reference.md diff --git a/docs/docs/Client SDK Languages/C#/_category.json b/docs/docs/Client SDK Languages/C#/_category.json new file mode 100644 index 00000000000..60238f8ed6f --- /dev/null +++ b/docs/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/docs/sdks/c-sharp/quickstart.md b/docs/docs/Client SDK Languages/C#/index.md similarity index 100% rename from docs/docs/sdks/c-sharp/quickstart.md rename to docs/docs/Client SDK Languages/C#/index.md diff --git a/docs/docs/sdks/python/index.md b/docs/docs/Client SDK Languages/Python/SDK Reference.md similarity index 100% rename from docs/docs/sdks/python/index.md rename to docs/docs/Client SDK Languages/Python/SDK Reference.md diff --git a/docs/docs/Client SDK Languages/Python/_category.json b/docs/docs/Client SDK Languages/Python/_category.json new file mode 100644 index 00000000000..4e08cfa1cb3 --- /dev/null +++ b/docs/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/docs/sdks/python/quickstart.md b/docs/docs/Client SDK Languages/Python/index.md similarity index 100% rename from docs/docs/sdks/python/quickstart.md rename to docs/docs/Client SDK Languages/Python/index.md diff --git a/docs/docs/sdks/rust/index.md b/docs/docs/Client SDK Languages/Rust/SDK Reference.md similarity index 100% rename from docs/docs/sdks/rust/index.md rename to docs/docs/Client SDK Languages/Rust/SDK Reference.md diff --git a/docs/docs/Client SDK Languages/Rust/_category.json b/docs/docs/Client SDK Languages/Rust/_category.json new file mode 100644 index 00000000000..6280366ccfe --- /dev/null +++ b/docs/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/docs/sdks/rust/quickstart.md b/docs/docs/Client SDK Languages/Rust/index.md similarity index 100% rename from docs/docs/sdks/rust/quickstart.md rename to docs/docs/Client SDK Languages/Rust/index.md diff --git a/docs/docs/sdks/typescript/index.md b/docs/docs/Client SDK Languages/Typescript/SDK Reference.md similarity index 100% rename from docs/docs/sdks/typescript/index.md rename to docs/docs/Client SDK Languages/Typescript/SDK Reference.md diff --git a/docs/docs/Client SDK Languages/Typescript/_category.json b/docs/docs/Client SDK Languages/Typescript/_category.json new file mode 100644 index 00000000000..590d44a25ba --- /dev/null +++ b/docs/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/docs/sdks/typescript/quickstart.md b/docs/docs/Client SDK Languages/Typescript/index.md similarity index 100% rename from docs/docs/sdks/typescript/quickstart.md rename to docs/docs/Client SDK Languages/Typescript/index.md diff --git a/docs/docs/Client SDK Languages/_category.json b/docs/docs/Client SDK Languages/_category.json new file mode 100644 index 00000000000..530c17aa6e9 --- /dev/null +++ b/docs/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/docs/sdks/index.md b/docs/docs/Client SDK Languages/index.md similarity index 100% rename from docs/docs/sdks/index.md rename to docs/docs/Client SDK Languages/index.md diff --git a/docs/docs/Cloud Testnet/_category.json b/docs/docs/Cloud Testnet/_category.json new file mode 100644 index 00000000000..e6fa11b9bf0 --- /dev/null +++ b/docs/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/docs/deploying/testnet.md b/docs/docs/Cloud Testnet/index.md similarity index 100% rename from docs/docs/deploying/testnet.md rename to docs/docs/Cloud Testnet/index.md diff --git a/docs/docs/Getting Started/_category.json b/docs/docs/Getting Started/_category.json new file mode 100644 index 00000000000..a68dc36c049 --- /dev/null +++ b/docs/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/docs/getting-started.md b/docs/docs/Getting Started/index.md similarity index 100% rename from docs/docs/getting-started.md rename to docs/docs/Getting Started/index.md diff --git a/docs/docs/http/database.md b/docs/docs/HTTP API Reference/Databases.md similarity index 100% rename from docs/docs/http/database.md rename to docs/docs/HTTP API Reference/Databases.md diff --git a/docs/docs/http/energy.md b/docs/docs/HTTP API Reference/Energy.md similarity index 100% rename from docs/docs/http/energy.md rename to docs/docs/HTTP API Reference/Energy.md diff --git a/docs/docs/http/identity.md b/docs/docs/HTTP API Reference/Identities.md similarity index 100% rename from docs/docs/http/identity.md rename to docs/docs/HTTP API Reference/Identities.md diff --git a/docs/docs/HTTP API Reference/_category.json b/docs/docs/HTTP API Reference/_category.json new file mode 100644 index 00000000000..c8ad821bd65 --- /dev/null +++ b/docs/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/docs/http/index.md b/docs/docs/HTTP API Reference/index.md similarity index 100% rename from docs/docs/http/index.md rename to docs/docs/HTTP API Reference/index.md diff --git a/docs/docs/Module ABI Reference/_category.json b/docs/docs/Module ABI Reference/_category.json new file mode 100644 index 00000000000..7583598ddca --- /dev/null +++ b/docs/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/docs/webassembly-abi/index.md b/docs/docs/Module ABI Reference/index.md similarity index 100% rename from docs/docs/webassembly-abi/index.md rename to docs/docs/Module ABI Reference/index.md diff --git a/docs/docs/Overview/_category.json b/docs/docs/Overview/_category.json new file mode 100644 index 00000000000..35164a50a91 --- /dev/null +++ b/docs/docs/Overview/_category.json @@ -0,0 +1 @@ +{"title":"Overview","disabled":false,"index":"index.md"} \ No newline at end of file diff --git a/docs/docs/index.md b/docs/docs/Overview/index.md similarity index 100% rename from docs/docs/index.md rename to docs/docs/Overview/index.md diff --git a/docs/docs/bsatn.md b/docs/docs/SATN Reference/Binary Format.md similarity index 100% rename from docs/docs/bsatn.md rename to docs/docs/SATN Reference/Binary Format.md diff --git a/docs/docs/SATN Reference/_category.json b/docs/docs/SATN Reference/_category.json new file mode 100644 index 00000000000..e26b2f0564a --- /dev/null +++ b/docs/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/docs/satn.md b/docs/docs/SATN Reference/index.md similarity index 100% rename from docs/docs/satn.md rename to docs/docs/SATN Reference/index.md diff --git a/docs/docs/SQL Reference/_category.json b/docs/docs/SQL Reference/_category.json new file mode 100644 index 00000000000..73d7df23590 --- /dev/null +++ b/docs/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/docs/sql/index.md b/docs/docs/SQL Reference/index.md similarity index 100% rename from docs/docs/sql/index.md rename to docs/docs/SQL Reference/index.md diff --git a/docs/docs/modules/c-sharp/index.md b/docs/docs/Server Module Languages/C#/ModuleReference.md similarity index 100% rename from docs/docs/modules/c-sharp/index.md rename to docs/docs/Server Module Languages/C#/ModuleReference.md diff --git a/docs/docs/Server Module Languages/C#/_category.json b/docs/docs/Server Module Languages/C#/_category.json new file mode 100644 index 00000000000..71ae9015f93 --- /dev/null +++ b/docs/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/docs/modules/c-sharp/quickstart.md b/docs/docs/Server Module Languages/C#/index.md similarity index 100% rename from docs/docs/modules/c-sharp/quickstart.md rename to docs/docs/Server Module Languages/C#/index.md diff --git a/docs/docs/modules/rust/index.md b/docs/docs/Server Module Languages/Rust/ModuleReference.md similarity index 100% rename from docs/docs/modules/rust/index.md rename to docs/docs/Server Module Languages/Rust/ModuleReference.md diff --git a/docs/docs/Server Module Languages/Rust/_category.json b/docs/docs/Server Module Languages/Rust/_category.json new file mode 100644 index 00000000000..6280366ccfe --- /dev/null +++ b/docs/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/docs/modules/rust/quickstart.md b/docs/docs/Server Module Languages/Rust/index.md similarity index 100% rename from docs/docs/modules/rust/quickstart.md rename to docs/docs/Server Module Languages/Rust/index.md diff --git a/docs/docs/Server Module Languages/_category.json b/docs/docs/Server Module Languages/_category.json new file mode 100644 index 00000000000..3bfa0e87292 --- /dev/null +++ b/docs/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/docs/modules/index.md b/docs/docs/Server Module Languages/index.md similarity index 100% rename from docs/docs/modules/index.md rename to docs/docs/Server Module Languages/index.md diff --git a/docs/docs/unity/part-1.md b/docs/docs/Unity Tutorial/Part 1 - Basic Multiplayer.md similarity index 100% rename from docs/docs/unity/part-1.md rename to docs/docs/Unity Tutorial/Part 1 - Basic Multiplayer.md diff --git a/docs/docs/unity/part-2.md b/docs/docs/Unity Tutorial/Part 2 - Resources And Scheduling.md similarity index 100% rename from docs/docs/unity/part-2.md rename to docs/docs/Unity Tutorial/Part 2 - Resources And Scheduling.md diff --git a/docs/docs/unity/part-3.md b/docs/docs/Unity Tutorial/Part 3 - BitCraft Mini.md similarity index 100% rename from docs/docs/unity/part-3.md rename to docs/docs/Unity Tutorial/Part 3 - BitCraft Mini.md diff --git a/docs/docs/Unity Tutorial/_category.json b/docs/docs/Unity Tutorial/_category.json new file mode 100644 index 00000000000..a3c837ad48a --- /dev/null +++ b/docs/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/docs/WebSocket API Reference/_category.json b/docs/docs/WebSocket API Reference/_category.json new file mode 100644 index 00000000000..d27973062d0 --- /dev/null +++ b/docs/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/docs/ws/index.md b/docs/docs/WebSocket API Reference/index.md similarity index 100% rename from docs/docs/ws/index.md rename to docs/docs/WebSocket API Reference/index.md diff --git a/docs/docs/nav.js b/docs/docs/nav.js index cb8d22f1715..b62e9b7f7d9 100644 --- a/docs/docs/nav.js +++ b/docs/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/docs/nav.ts b/docs/nav.ts index 6d5a304b236..b9a64ee06ce 100644 --- a/docs/nav.ts +++ b/docs/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 b21ec868ab91908fb79e70bbe49aa7f9ab65a537 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Thu, 7 Dec 2023 18:30:11 -0800 Subject: [PATCH 033/195] Update index.md --- docs/docs/Server Module Languages/Rust/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/Server Module Languages/Rust/index.md b/docs/docs/Server Module Languages/Rust/index.md index 6e0f174732d..e0ff0f5f82d 100644 --- a/docs/docs/Server Module Languages/Rust/index.md +++ b/docs/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 21b73f1e937561ec431fd72885ae8cc8697dd856 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Thu, 7 Dec 2023 18:34:55 -0800 Subject: [PATCH 034/195] Update index.md --- docs/docs/Getting Started/index.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/docs/docs/Getting Started/index.md b/docs/docs/Getting Started/index.md index 54337d08625..5a0c6041827 100644 --- a/docs/docs/Getting Started/index.md +++ b/docs/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 95a2224d2d7f7a89529c768b3ee7d0de95a9fb9f Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Fri, 15 Dec 2023 16:22:38 -0800 Subject: [PATCH 035/195] Update index.md --- docs/docs/Client SDK Languages/Typescript/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/Client SDK Languages/Typescript/index.md b/docs/docs/Client SDK Languages/Typescript/index.md index ab7cfe897ac..0ec6b0eb786 100644 --- a/docs/docs/Client SDK Languages/Typescript/index.md +++ b/docs/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 f3df3a4259fd04702993de7f9de5d05463564939 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Fri, 15 Dec 2023 16:24:16 -0800 Subject: [PATCH 036/195] Update index.md --- docs/docs/Cloud Testnet/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/Cloud Testnet/index.md b/docs/docs/Cloud Testnet/index.md index abb90fb8f35..ce648043b5a 100644 --- a/docs/docs/Cloud Testnet/index.md +++ b/docs/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 e5b129eec1381016628baebf0c2c81be35f0a2c6 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Fri, 15 Dec 2023 23:19:22 -0800 Subject: [PATCH 037/195] Revert "Revert "Reorganized the doc paths to match slugs and removed _category.json files"" (#23) This reverts commit 9f9bf5794770f23d58291b30efc4f6fa09d77c80. --- .../Client SDK Languages/C#/_category.json | 5 -- .../Python/_category.json | 5 -- .../Client SDK Languages/Rust/_category.json | 5 -- .../Typescript/_category.json | 5 -- docs/docs/Client SDK Languages/_category.json | 1 - docs/docs/Cloud Testnet/_category.json | 1 - docs/docs/Getting Started/_category.json | 1 - docs/docs/HTTP API Reference/_category.json | 1 - docs/docs/Module ABI Reference/_category.json | 1 - docs/docs/Overview/_category.json | 1 - docs/docs/SATN Reference/_category.json | 1 - docs/docs/SQL Reference/_category.json | 1 - .../Server Module Languages/C#/_category.json | 6 -- .../Rust/_category.json | 5 -- .../Server Module Languages/_category.json | 1 - docs/docs/Unity Tutorial/_category.json | 5 -- .../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 .../{HTTP API Reference => http}/index.md | 0 docs/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/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 .../{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/docs/{SQL Reference => sql}/index.md | 0 .../part-1.md} | 0 .../part-2.md} | 0 .../part-3.md} | 0 .../index.md | 0 .../{WebSocket API Reference => ws}/index.md | 0 docs/nav.ts | 58 +++++++++---------- 48 files changed, 58 insertions(+), 104 deletions(-) delete mode 100644 docs/docs/Client SDK Languages/C#/_category.json delete mode 100644 docs/docs/Client SDK Languages/Python/_category.json delete mode 100644 docs/docs/Client SDK Languages/Rust/_category.json delete mode 100644 docs/docs/Client SDK Languages/Typescript/_category.json delete mode 100644 docs/docs/Client SDK Languages/_category.json delete mode 100644 docs/docs/Cloud Testnet/_category.json delete mode 100644 docs/docs/Getting Started/_category.json delete mode 100644 docs/docs/HTTP API Reference/_category.json delete mode 100644 docs/docs/Module ABI Reference/_category.json delete mode 100644 docs/docs/Overview/_category.json delete mode 100644 docs/docs/SATN Reference/_category.json delete mode 100644 docs/docs/SQL Reference/_category.json delete mode 100644 docs/docs/Server Module Languages/C#/_category.json delete mode 100644 docs/docs/Server Module Languages/Rust/_category.json delete mode 100644 docs/docs/Server Module Languages/_category.json delete mode 100644 docs/docs/Unity Tutorial/_category.json delete mode 100644 docs/docs/WebSocket API Reference/_category.json rename docs/docs/{SATN Reference/Binary Format.md => bsatn.md} (100%) rename docs/docs/{Cloud Testnet/index.md => deploying/testnet.md} (100%) rename docs/docs/{Getting Started/index.md => getting-started.md} (100%) rename docs/docs/{HTTP API Reference/Databases.md => http/database.md} (100%) rename docs/docs/{HTTP API Reference/Energy.md => http/energy.md} (100%) rename docs/docs/{HTTP API Reference/Identities.md => http/identity.md} (100%) rename docs/docs/{HTTP API Reference => http}/index.md (100%) rename docs/docs/{Overview => }/index.md (100%) rename docs/docs/{Server Module Languages/C#/ModuleReference.md => modules/c-sharp/index.md} (100%) rename docs/docs/{Server Module Languages/C#/index.md => modules/c-sharp/quickstart.md} (100%) rename docs/docs/{Server Module Languages => modules}/index.md (100%) rename docs/docs/{Server Module Languages/Rust/ModuleReference.md => modules/rust/index.md} (100%) rename docs/docs/{Server Module Languages/Rust/index.md => modules/rust/quickstart.md} (100%) rename docs/docs/{SATN Reference/index.md => satn.md} (100%) rename docs/docs/{Client SDK Languages/C#/SDK Reference.md => sdks/c-sharp/index.md} (100%) rename docs/docs/{Client SDK Languages/C#/index.md => sdks/c-sharp/quickstart.md} (100%) rename docs/docs/{Client SDK Languages => sdks}/index.md (100%) rename docs/docs/{Client SDK Languages/Python/SDK Reference.md => sdks/python/index.md} (100%) rename docs/docs/{Client SDK Languages/Python/index.md => sdks/python/quickstart.md} (100%) rename docs/docs/{Client SDK Languages/Rust/SDK Reference.md => sdks/rust/index.md} (100%) rename docs/docs/{Client SDK Languages/Rust/index.md => sdks/rust/quickstart.md} (100%) rename docs/docs/{Client SDK Languages/Typescript/SDK Reference.md => sdks/typescript/index.md} (100%) rename docs/docs/{Client SDK Languages/Typescript/index.md => sdks/typescript/quickstart.md} (100%) rename docs/docs/{SQL Reference => sql}/index.md (100%) rename docs/docs/{Unity Tutorial/Part 1 - Basic Multiplayer.md => unity/part-1.md} (100%) rename docs/docs/{Unity Tutorial/Part 2 - Resources And Scheduling.md => unity/part-2.md} (100%) rename docs/docs/{Unity Tutorial/Part 3 - BitCraft Mini.md => unity/part-3.md} (100%) rename docs/docs/{Module ABI Reference => webassembly-abi}/index.md (100%) rename docs/docs/{WebSocket API Reference => ws}/index.md (100%) diff --git a/docs/docs/Client SDK Languages/C#/_category.json b/docs/docs/Client SDK Languages/C#/_category.json deleted file mode 100644 index 60238f8ed6f..00000000000 --- a/docs/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/docs/Client SDK Languages/Python/_category.json b/docs/docs/Client SDK Languages/Python/_category.json deleted file mode 100644 index 4e08cfa1cb3..00000000000 --- a/docs/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/docs/Client SDK Languages/Rust/_category.json b/docs/docs/Client SDK Languages/Rust/_category.json deleted file mode 100644 index 6280366ccfe..00000000000 --- a/docs/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/docs/Client SDK Languages/Typescript/_category.json b/docs/docs/Client SDK Languages/Typescript/_category.json deleted file mode 100644 index 590d44a25ba..00000000000 --- a/docs/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/docs/Client SDK Languages/_category.json b/docs/docs/Client SDK Languages/_category.json deleted file mode 100644 index 530c17aa6e9..00000000000 --- a/docs/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/docs/Cloud Testnet/_category.json b/docs/docs/Cloud Testnet/_category.json deleted file mode 100644 index e6fa11b9bf0..00000000000 --- a/docs/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/docs/Getting Started/_category.json b/docs/docs/Getting Started/_category.json deleted file mode 100644 index a68dc36c049..00000000000 --- a/docs/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/docs/HTTP API Reference/_category.json b/docs/docs/HTTP API Reference/_category.json deleted file mode 100644 index c8ad821bd65..00000000000 --- a/docs/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/docs/Module ABI Reference/_category.json b/docs/docs/Module ABI Reference/_category.json deleted file mode 100644 index 7583598ddca..00000000000 --- a/docs/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/docs/Overview/_category.json b/docs/docs/Overview/_category.json deleted file mode 100644 index 35164a50a91..00000000000 --- a/docs/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/docs/SATN Reference/_category.json b/docs/docs/SATN Reference/_category.json deleted file mode 100644 index e26b2f0564a..00000000000 --- a/docs/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/docs/SQL Reference/_category.json b/docs/docs/SQL Reference/_category.json deleted file mode 100644 index 73d7df23590..00000000000 --- a/docs/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/docs/Server Module Languages/C#/_category.json b/docs/docs/Server Module Languages/C#/_category.json deleted file mode 100644 index 71ae9015f93..00000000000 --- a/docs/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/docs/Server Module Languages/Rust/_category.json b/docs/docs/Server Module Languages/Rust/_category.json deleted file mode 100644 index 6280366ccfe..00000000000 --- a/docs/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/docs/Server Module Languages/_category.json b/docs/docs/Server Module Languages/_category.json deleted file mode 100644 index 3bfa0e87292..00000000000 --- a/docs/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/docs/Unity Tutorial/_category.json b/docs/docs/Unity Tutorial/_category.json deleted file mode 100644 index a3c837ad48a..00000000000 --- a/docs/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/docs/WebSocket API Reference/_category.json b/docs/docs/WebSocket API Reference/_category.json deleted file mode 100644 index d27973062d0..00000000000 --- a/docs/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/docs/SATN Reference/Binary Format.md b/docs/docs/bsatn.md similarity index 100% rename from docs/docs/SATN Reference/Binary Format.md rename to docs/docs/bsatn.md diff --git a/docs/docs/Cloud Testnet/index.md b/docs/docs/deploying/testnet.md similarity index 100% rename from docs/docs/Cloud Testnet/index.md rename to docs/docs/deploying/testnet.md diff --git a/docs/docs/Getting Started/index.md b/docs/docs/getting-started.md similarity index 100% rename from docs/docs/Getting Started/index.md rename to docs/docs/getting-started.md diff --git a/docs/docs/HTTP API Reference/Databases.md b/docs/docs/http/database.md similarity index 100% rename from docs/docs/HTTP API Reference/Databases.md rename to docs/docs/http/database.md diff --git a/docs/docs/HTTP API Reference/Energy.md b/docs/docs/http/energy.md similarity index 100% rename from docs/docs/HTTP API Reference/Energy.md rename to docs/docs/http/energy.md diff --git a/docs/docs/HTTP API Reference/Identities.md b/docs/docs/http/identity.md similarity index 100% rename from docs/docs/HTTP API Reference/Identities.md rename to docs/docs/http/identity.md diff --git a/docs/docs/HTTP API Reference/index.md b/docs/docs/http/index.md similarity index 100% rename from docs/docs/HTTP API Reference/index.md rename to docs/docs/http/index.md diff --git a/docs/docs/Overview/index.md b/docs/docs/index.md similarity index 100% rename from docs/docs/Overview/index.md rename to docs/docs/index.md diff --git a/docs/docs/Server Module Languages/C#/ModuleReference.md b/docs/docs/modules/c-sharp/index.md similarity index 100% rename from docs/docs/Server Module Languages/C#/ModuleReference.md rename to docs/docs/modules/c-sharp/index.md diff --git a/docs/docs/Server Module Languages/C#/index.md b/docs/docs/modules/c-sharp/quickstart.md similarity index 100% rename from docs/docs/Server Module Languages/C#/index.md rename to docs/docs/modules/c-sharp/quickstart.md diff --git a/docs/docs/Server Module Languages/index.md b/docs/docs/modules/index.md similarity index 100% rename from docs/docs/Server Module Languages/index.md rename to docs/docs/modules/index.md diff --git a/docs/docs/Server Module Languages/Rust/ModuleReference.md b/docs/docs/modules/rust/index.md similarity index 100% rename from docs/docs/Server Module Languages/Rust/ModuleReference.md rename to docs/docs/modules/rust/index.md diff --git a/docs/docs/Server Module Languages/Rust/index.md b/docs/docs/modules/rust/quickstart.md similarity index 100% rename from docs/docs/Server Module Languages/Rust/index.md rename to docs/docs/modules/rust/quickstart.md diff --git a/docs/docs/nav.js b/docs/docs/nav.js index b62e9b7f7d9..cb8d22f1715 100644 --- a/docs/docs/nav.js +++ b/docs/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/docs/SATN Reference/index.md b/docs/docs/satn.md similarity index 100% rename from docs/docs/SATN Reference/index.md rename to docs/docs/satn.md diff --git a/docs/docs/Client SDK Languages/C#/SDK Reference.md b/docs/docs/sdks/c-sharp/index.md similarity index 100% rename from docs/docs/Client SDK Languages/C#/SDK Reference.md rename to docs/docs/sdks/c-sharp/index.md diff --git a/docs/docs/Client SDK Languages/C#/index.md b/docs/docs/sdks/c-sharp/quickstart.md similarity index 100% rename from docs/docs/Client SDK Languages/C#/index.md rename to docs/docs/sdks/c-sharp/quickstart.md diff --git a/docs/docs/Client SDK Languages/index.md b/docs/docs/sdks/index.md similarity index 100% rename from docs/docs/Client SDK Languages/index.md rename to docs/docs/sdks/index.md diff --git a/docs/docs/Client SDK Languages/Python/SDK Reference.md b/docs/docs/sdks/python/index.md similarity index 100% rename from docs/docs/Client SDK Languages/Python/SDK Reference.md rename to docs/docs/sdks/python/index.md diff --git a/docs/docs/Client SDK Languages/Python/index.md b/docs/docs/sdks/python/quickstart.md similarity index 100% rename from docs/docs/Client SDK Languages/Python/index.md rename to docs/docs/sdks/python/quickstart.md diff --git a/docs/docs/Client SDK Languages/Rust/SDK Reference.md b/docs/docs/sdks/rust/index.md similarity index 100% rename from docs/docs/Client SDK Languages/Rust/SDK Reference.md rename to docs/docs/sdks/rust/index.md diff --git a/docs/docs/Client SDK Languages/Rust/index.md b/docs/docs/sdks/rust/quickstart.md similarity index 100% rename from docs/docs/Client SDK Languages/Rust/index.md rename to docs/docs/sdks/rust/quickstart.md diff --git a/docs/docs/Client SDK Languages/Typescript/SDK Reference.md b/docs/docs/sdks/typescript/index.md similarity index 100% rename from docs/docs/Client SDK Languages/Typescript/SDK Reference.md rename to docs/docs/sdks/typescript/index.md diff --git a/docs/docs/Client SDK Languages/Typescript/index.md b/docs/docs/sdks/typescript/quickstart.md similarity index 100% rename from docs/docs/Client SDK Languages/Typescript/index.md rename to docs/docs/sdks/typescript/quickstart.md diff --git a/docs/docs/SQL Reference/index.md b/docs/docs/sql/index.md similarity index 100% rename from docs/docs/SQL Reference/index.md rename to docs/docs/sql/index.md diff --git a/docs/docs/Unity Tutorial/Part 1 - Basic Multiplayer.md b/docs/docs/unity/part-1.md similarity index 100% rename from docs/docs/Unity Tutorial/Part 1 - Basic Multiplayer.md rename to docs/docs/unity/part-1.md diff --git a/docs/docs/Unity Tutorial/Part 2 - Resources And Scheduling.md b/docs/docs/unity/part-2.md similarity index 100% rename from docs/docs/Unity Tutorial/Part 2 - Resources And Scheduling.md rename to docs/docs/unity/part-2.md diff --git a/docs/docs/Unity Tutorial/Part 3 - BitCraft Mini.md b/docs/docs/unity/part-3.md similarity index 100% rename from docs/docs/Unity Tutorial/Part 3 - BitCraft Mini.md rename to docs/docs/unity/part-3.md diff --git a/docs/docs/Module ABI Reference/index.md b/docs/docs/webassembly-abi/index.md similarity index 100% rename from docs/docs/Module ABI Reference/index.md rename to docs/docs/webassembly-abi/index.md diff --git a/docs/docs/WebSocket API Reference/index.md b/docs/docs/ws/index.md similarity index 100% rename from docs/docs/WebSocket API Reference/index.md rename to docs/docs/ws/index.md diff --git a/docs/nav.ts b/docs/nav.ts index b9a64ee06ce..6d5a304b236 100644 --- a/docs/nav.ts +++ b/docs/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 8ef99bf8a025413e16306fd6fadfd5023ac69230 Mon Sep 17 00:00:00 2001 From: Piotr Sarnacki Date: Mon, 18 Dec 2023 20:07:19 +0100 Subject: [PATCH 038/195] Typescript SDK 0.8 changes (#21) * Empty push to trigger a webhook * Update TypeScript docs to 0.8 --- docs/docs/sdks/typescript/index.md | 58 +++++++++++++++++++++++-- docs/docs/sdks/typescript/quickstart.md | 18 ++++---- 2 files changed, 64 insertions(+), 12 deletions(-) diff --git a/docs/docs/sdks/typescript/index.md b/docs/docs/sdks/typescript/index.md index fb7d5be641d..fd7c9e91e85 100644 --- a/docs/docs/sdks/typescript/index.md +++ b/docs/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/docs/sdks/typescript/quickstart.md b/docs/docs/sdks/typescript/quickstart.md index 0ec6b0eb786..ca8abff99c6 100644 --- a/docs/docs/sdks/typescript/quickstart.md +++ b/docs/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 00e3cada4a3a5a64d8187ae623dd4b9c7be23c6a Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Wed, 24 Jan 2024 12:26:38 -0800 Subject: [PATCH 039/195] Update README.md --- docs/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/README.md b/docs/README.md index cfe1e0af55e..af34b88a2b5 100644 --- a/docs/README.md +++ b/docs/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 0e02ad425c504331830eb5c348dfd1282b9e140f Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Wed, 28 Feb 2024 14:12:58 -0500 Subject: [PATCH 040/195] 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/docs/ws/index.md | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/docs/docs/ws/index.md b/docs/docs/ws/index.md index 7624016366a..b00bfa56d45 100644 --- a/docs/docs/ws/index.md +++ b/docs/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 7b3b96cdd228c715e13f9be5fca9c9790a5638de Mon Sep 17 00:00:00 2001 From: Dylan Hunt Date: Fri, 29 Mar 2024 17:16:35 +0800 Subject: [PATCH 041/195] 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 --- docs/README.md | 2 +- docs/docs/getting-started.md | 24 +++++---- docs/docs/modules/c-sharp/index.md | 3 ++ docs/docs/modules/c-sharp/quickstart.md | 37 +++++++++++--- docs/docs/modules/rust/quickstart.md | 2 +- docs/docs/sdks/c-sharp/index.md | 20 +++++--- docs/docs/sdks/c-sharp/quickstart.md | 66 ++++++++++++++---------- docs/docs/unity/part-1.md | 67 ++++++++++++++++--------- 8 files changed, 145 insertions(+), 76 deletions(-) diff --git a/docs/README.md b/docs/README.md index af34b88a2b5..0f9998b0894 100644 --- a/docs/README.md +++ b/docs/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/docs/getting-started.md b/docs/docs/getting-started.md index 5a0c6041827..177a0d2514c 100644 --- a/docs/docs/getting-started.md +++ b/docs/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/docs/modules/c-sharp/index.md b/docs/docs/modules/c-sharp/index.md index 36a9618a95f..31ebd1d4ced 100644 --- a/docs/docs/modules/c-sharp/index.md +++ b/docs/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/docs/modules/c-sharp/quickstart.md b/docs/docs/modules/c-sharp/quickstart.md index fb97c316b8d..f5f734015bc 100644 --- a/docs/docs/modules/c-sharp/quickstart.md +++ b/docs/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/docs/modules/rust/quickstart.md b/docs/docs/modules/rust/quickstart.md index e0ff0f5f82d..e015b881351 100644 --- a/docs/docs/modules/rust/quickstart.md +++ b/docs/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/docs/sdks/c-sharp/index.md b/docs/docs/sdks/c-sharp/index.md index 473ca1ba636..7c920cf5bdd 100644 --- a/docs/docs/sdks/c-sharp/index.md +++ b/docs/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/docs/sdks/c-sharp/quickstart.md b/docs/docs/sdks/c-sharp/quickstart.md index f7565019933..07aa6cf602b 100644 --- a/docs/docs/sdks/c-sharp/quickstart.md +++ b/docs/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/docs/unity/part-1.md b/docs/docs/unity/part-1.md index 30bd3137cac..0e899750fb3 100644 --- a/docs/docs/unity/part-1.md +++ b/docs/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 d6d31b594c1fd0777fd606b3fd1799f44a052f35 Mon Sep 17 00:00:00 2001 From: Dylan Hunt Date: Fri, 26 Apr 2024 03:51:37 +0800 Subject: [PATCH 042/195] 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/docs/sdks/c-sharp/quickstart.md | 2 +- docs/docs/unity/index.md | 23 + docs/docs/unity/part-1.md | 769 +---------------------- docs/docs/unity/part-2a-rust.md | 312 +++++++++ docs/docs/unity/part-2b-c-sharp.md | 344 ++++++++++ docs/docs/unity/part-3.md | 487 ++++++++++++-- docs/docs/unity/{part-2.md => part-4.md} | 6 +- docs/docs/unity/part-5.md | 108 ++++ docs/nav.ts | 16 +- 9 files changed, 1242 insertions(+), 825 deletions(-) create mode 100644 docs/docs/unity/index.md create mode 100644 docs/docs/unity/part-2a-rust.md create mode 100644 docs/docs/unity/part-2b-c-sharp.md rename docs/docs/unity/{part-2.md => part-4.md} (97%) create mode 100644 docs/docs/unity/part-5.md diff --git a/docs/docs/sdks/c-sharp/quickstart.md b/docs/docs/sdks/c-sharp/quickstart.md index 07aa6cf602b..92980f42be3 100644 --- a/docs/docs/sdks/c-sharp/quickstart.md +++ b/docs/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/docs/unity/index.md b/docs/docs/unity/index.md new file mode 100644 index 00000000000..2b8e6d67dbd --- /dev/null +++ b/docs/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/docs/unity/part-1.md b/docs/docs/unity/part-1.md index 0e899750fb3..b8b8c3c0eeb 100644 --- a/docs/docs/unity/part-1.md +++ b/docs/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/docs/unity/part-2a-rust.md b/docs/docs/unity/part-2a-rust.md new file mode 100644 index 00000000000..9b12de47062 --- /dev/null +++ b/docs/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/docs/unity/part-2b-c-sharp.md b/docs/docs/unity/part-2b-c-sharp.md new file mode 100644 index 00000000000..f324a36d087 --- /dev/null +++ b/docs/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/docs/unity/part-3.md b/docs/docs/unity/part-3.md index b49b5a5d7b8..c80000e1fb1 100644 --- a/docs/docs/unity/part-3.md +++ b/docs/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/docs/unity/part-2.md b/docs/docs/unity/part-4.md similarity index 97% rename from docs/docs/unity/part-2.md rename to docs/docs/unity/part-4.md index 537edd4437c..a87f27a208b 100644 --- a/docs/docs/unity/part-2.md +++ b/docs/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/docs/unity/part-5.md b/docs/docs/unity/part-5.md new file mode 100644 index 00000000000..6ebce1c0af4 --- /dev/null +++ b/docs/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/docs/nav.ts b/docs/nav.ts index 6d5a304b236..8f463ad79d5 100644 --- a/docs/nav.ts +++ b/docs/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 a92dbc08c48d97aa4476461ca1b02e5494e01453 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 043/195] 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 --- docs/.prettierrc | 19 + docs/build.js | 29 + docs/docs/nav.js | 298 ++++++++-- docs/nav.ts | 163 ++++-- docs/package.json | 29 +- docs/pnpm-lock.yaml | 1261 +++++++++++++++++++++++++++++++++++++++++++ docs/yarn.lock | 8 - 7 files changed, 1674 insertions(+), 133 deletions(-) create mode 100644 docs/.prettierrc create mode 100644 docs/build.js create mode 100644 docs/pnpm-lock.yaml delete mode 100644 docs/yarn.lock diff --git a/docs/.prettierrc b/docs/.prettierrc new file mode 100644 index 00000000000..81d845ca407 --- /dev/null +++ b/docs/.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/build.js b/docs/build.js new file mode 100644 index 00000000000..5f7414c04fb --- /dev/null +++ b/docs/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/docs/nav.js b/docs/docs/nav.js index cb8d22f1715..c4346d75850 100644 --- a/docs/docs/nav.js +++ b/docs/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/docs/nav.ts b/docs/nav.ts index 8f463ad79d5..b6eea77a946 100644 --- a/docs/nav.ts +++ b/docs/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/docs/package.json b/docs/package.json index a56ea4e86a6..0a764ee6572 100644 --- a/docs/package.json +++ b/docs/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/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml new file mode 100644 index 00000000000..bec77ca8c5f --- /dev/null +++ b/docs/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/docs/yarn.lock b/docs/yarn.lock deleted file mode 100644 index fce89544647..00000000000 --- a/docs/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 30f628dcfd2bb79cefb2f55e7507e66e0819e0a7 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Wed, 1 May 2024 23:57:58 -0400 Subject: [PATCH 044/195] Revert "fix: Docs build, pnpm, vm evaluate (#46)" (#48) This reverts commit a92dbc08c48d97aa4476461ca1b02e5494e01453. --- docs/.prettierrc | 19 - docs/build.js | 29 - docs/docs/nav.js | 298 ++-------- docs/nav.ts | 163 ++---- docs/package.json | 29 +- docs/pnpm-lock.yaml | 1261 ------------------------------------------- docs/yarn.lock | 8 + 7 files changed, 133 insertions(+), 1674 deletions(-) delete mode 100644 docs/.prettierrc delete mode 100644 docs/build.js delete mode 100644 docs/pnpm-lock.yaml create mode 100644 docs/yarn.lock diff --git a/docs/.prettierrc b/docs/.prettierrc deleted file mode 100644 index 81d845ca407..00000000000 --- a/docs/.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/build.js b/docs/build.js deleted file mode 100644 index 5f7414c04fb..00000000000 --- a/docs/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/docs/nav.js b/docs/docs/nav.js index c4346d75850..cb8d22f1715 100644 --- a/docs/docs/nav.js +++ b/docs/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/docs/nav.ts b/docs/nav.ts index b6eea77a946..8f463ad79d5 100644 --- a/docs/nav.ts +++ b/docs/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/docs/package.json b/docs/package.json index 0a764ee6572..a56ea4e86a6 100644 --- a/docs/package.json +++ b/docs/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/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml deleted file mode 100644 index bec77ca8c5f..00000000000 --- a/docs/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/docs/yarn.lock b/docs/yarn.lock new file mode 100644 index 00000000000..fce89544647 --- /dev/null +++ b/docs/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 6c11566a237baab741e055ef88fcfe103e49fd6f Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Thu, 2 May 2024 10:02:46 -0400 Subject: [PATCH 045/195] 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> --- docs/.prettierrc | 19 ++++++ docs/docs/nav.js | 92 +++++++++++++------------ docs/nav.ts | 163 ++++++++++++++++++++++++++++---------------- docs/package.json | 27 ++++---- docs/pnpm-lock.yaml | 18 +++++ docs/yarn.lock | 8 --- 6 files changed, 203 insertions(+), 124 deletions(-) create mode 100644 docs/.prettierrc create mode 100644 docs/pnpm-lock.yaml delete mode 100644 docs/yarn.lock diff --git a/docs/.prettierrc b/docs/.prettierrc new file mode 100644 index 00000000000..81d845ca407 --- /dev/null +++ b/docs/.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/docs/nav.js b/docs/docs/nav.js index cb8d22f1715..ec6d9d668fc 100644 --- a/docs/docs/nav.js +++ b/docs/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/docs/nav.ts b/docs/nav.ts index 8f463ad79d5..b6eea77a946 100644 --- a/docs/nav.ts +++ b/docs/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/docs/package.json b/docs/package.json index a56ea4e86a6..4b23519cdb7 100644 --- a/docs/package.json +++ b/docs/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/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml new file mode 100644 index 00000000000..8cffafc89c8 --- /dev/null +++ b/docs/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/docs/yarn.lock b/docs/yarn.lock deleted file mode 100644 index fce89544647..00000000000 --- a/docs/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 6a071583f5dd12a6353aad69abecb70088fe38e0 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Thu, 2 May 2024 10:11:54 -0400 Subject: [PATCH 046/195] Revert "fix: Docs build, pnpm, vm evaluate (#46) (#50)" (#52) This reverts commit 6c11566a237baab741e055ef88fcfe103e49fd6f. --- docs/.prettierrc | 19 ------ docs/docs/nav.js | 92 ++++++++++++------------- docs/nav.ts | 163 ++++++++++++++++---------------------------- docs/package.json | 27 ++++---- docs/pnpm-lock.yaml | 18 ----- docs/yarn.lock | 8 +++ 6 files changed, 124 insertions(+), 203 deletions(-) delete mode 100644 docs/.prettierrc delete mode 100644 docs/pnpm-lock.yaml create mode 100644 docs/yarn.lock diff --git a/docs/.prettierrc b/docs/.prettierrc deleted file mode 100644 index 81d845ca407..00000000000 --- a/docs/.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/docs/nav.js b/docs/docs/nav.js index ec6d9d668fc..cb8d22f1715 100644 --- a/docs/docs/nav.js +++ b/docs/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/docs/nav.ts b/docs/nav.ts index b6eea77a946..8f463ad79d5 100644 --- a/docs/nav.ts +++ b/docs/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/docs/package.json b/docs/package.json index 4b23519cdb7..a56ea4e86a6 100644 --- a/docs/package.json +++ b/docs/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/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml deleted file mode 100644 index 8cffafc89c8..00000000000 --- a/docs/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/docs/yarn.lock b/docs/yarn.lock new file mode 100644 index 00000000000..fce89544647 --- /dev/null +++ b/docs/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 35640c113f04c9e6caed91e0aae587ccfe0606a1 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 047/195] fix: Unity tutorial slugs (#51) --- docs/docs/nav.js | 15 ++++++++++----- docs/nav.ts | 2 +- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/docs/nav.js b/docs/docs/nav.js index cb8d22f1715..4413888ef48 100644 --- a/docs/docs/nav.js +++ b/docs/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/docs/nav.ts b/docs/nav.ts index 8f463ad79d5..26a83f4c568 100644 --- a/docs/nav.ts +++ b/docs/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 fb32452b712c81d18423923f7ac61141c5812f18 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 048/195] fix: Broken docs links (#53) --- docs/docs/sdks/c-sharp/quickstart.md | 24 ++-- docs/docs/sdks/typescript/index.md | 191 +++++++++++---------------- docs/docs/unity/index.md | 14 +- docs/docs/unity/part-1.md | 9 +- docs/docs/unity/part-2a-rust.md | 20 +-- docs/docs/unity/part-2b-c-sharp.md | 18 +-- docs/docs/unity/part-3.md | 35 +++-- docs/docs/unity/part-4.md | 2 +- docs/docs/unity/part-5.md | 2 +- 9 files changed, 148 insertions(+), 167 deletions(-) diff --git a/docs/docs/sdks/c-sharp/quickstart.md b/docs/docs/sdks/c-sharp/quickstart.md index 92980f42be3..28f3c2e13f1 100644 --- a/docs/docs/sdks/c-sharp/quickstart.md +++ b/docs/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/docs/sdks/typescript/index.md b/docs/docs/sdks/typescript/index.md index fd7c9e91e85..166c157502b 100644 --- a/docs/docs/sdks/typescript/index.md +++ b/docs/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/docs/unity/index.md b/docs/docs/unity/index.md index 2b8e6d67dbd..7697074800b 100644 --- a/docs/docs/unity/index.md +++ b/docs/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/docs/unity/part-1.md b/docs/docs/unity/part-1.md index b8b8c3c0eeb..0db2f5aaec0 100644 --- a/docs/docs/unity/part-1.md +++ b/docs/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/docs/unity/part-2a-rust.md b/docs/docs/unity/part-2a-rust.md index 9b12de47062..fd9361f2b7f 100644 --- a/docs/docs/unity/part-2a-rust.md +++ b/docs/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/docs/unity/part-2b-c-sharp.md b/docs/docs/unity/part-2b-c-sharp.md index f324a36d087..ee6c0028b97 100644 --- a/docs/docs/unity/part-2b-c-sharp.md +++ b/docs/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/docs/unity/part-3.md b/docs/docs/unity/part-3.md index c80000e1fb1..d1db4dbb784 100644 --- a/docs/docs/unity/part-3.md +++ b/docs/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/docs/unity/part-4.md b/docs/docs/unity/part-4.md index a87f27a208b..b8af1018a8b 100644 --- a/docs/docs/unity/part-4.md +++ b/docs/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/docs/unity/part-5.md b/docs/docs/unity/part-5.md index 6ebce1c0af4..2c59c73b91c 100644 --- a/docs/docs/unity/part-5.md +++ b/docs/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 f10dcf2ad85813a8cee5ab494f3682e3d9438bb9 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 049/195] Fix the C# module link in overview (#54) * [bfops/docs]: C# fix * [bfops/docs]: empty --------- Co-authored-by: Zeke Foppa --- docs/docs/unity/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/unity/index.md b/docs/docs/unity/index.md index 7697074800b..0fa181c6744 100644 --- a/docs/docs/unity/index.md +++ b/docs/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 f813a298d52d67279ef4ec1fd6ce4f4ab0841114 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 050/195] Fix the C# module link in part 1 (#55) * [bfops/docs]: empty * [bfops/docs]: one more fix --------- Co-authored-by: Zeke Foppa --- docs/docs/unity/part-1.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/unity/part-1.md b/docs/docs/unity/part-1.md index 0db2f5aaec0..c53814d15d1 100644 --- a/docs/docs/unity/part-1.md +++ b/docs/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 fce4df6f665bf231267df1a681a4b66b64f2a872 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 051/195] Update docs for `#[spacetimedb(table)]` (#61) * [bfops/public-tables]: update docs for #[spacetimedb(table)] * [bfops/public-tables]: review --------- Co-authored-by: Zeke Foppa --- docs/docs/modules/rust/index.md | 24 +++++++++++++----------- docs/docs/modules/rust/quickstart.md | 12 ++++++++---- docs/docs/unity/part-2a-rust.md | 12 ++++++------ docs/docs/unity/part-2b-c-sharp.md | 2 +- docs/docs/unity/part-4.md | 6 +++--- 5 files changed, 31 insertions(+), 25 deletions(-) diff --git a/docs/docs/modules/rust/index.md b/docs/docs/modules/rust/index.md index 05d62bdc201..5e64051d4f0 100644 --- a/docs/docs/modules/rust/index.md +++ b/docs/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/docs/modules/rust/quickstart.md b/docs/docs/modules/rust/quickstart.md index e015b881351..062aa16302c 100644 --- a/docs/docs/modules/rust/quickstart.md +++ b/docs/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/docs/unity/part-2a-rust.md b/docs/docs/unity/part-2a-rust.md index fd9361f2b7f..0acac51c5ba 100644 --- a/docs/docs/unity/part-2a-rust.md +++ b/docs/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/docs/unity/part-2b-c-sharp.md b/docs/docs/unity/part-2b-c-sharp.md index ee6c0028b97..8cdb0947f2e 100644 --- a/docs/docs/unity/part-2b-c-sharp.md +++ b/docs/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/docs/unity/part-4.md b/docs/docs/unity/part-4.md index b8af1018a8b..b3a174393f6 100644 --- a/docs/docs/unity/part-4.md +++ b/docs/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 c86e3daca6163dd8930bd1197afafb8572fcddb2 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 052/195] Revert "Update docs for `#[spacetimedb(table)]` (#61)" (#62) This reverts commit fce4df6f665bf231267df1a681a4b66b64f2a872. --- docs/docs/modules/rust/index.md | 24 +++++++++++------------- docs/docs/modules/rust/quickstart.md | 12 ++++-------- docs/docs/unity/part-2a-rust.md | 12 ++++++------ docs/docs/unity/part-2b-c-sharp.md | 2 +- docs/docs/unity/part-4.md | 6 +++--- 5 files changed, 25 insertions(+), 31 deletions(-) diff --git a/docs/docs/modules/rust/index.md b/docs/docs/modules/rust/index.md index 5e64051d4f0..05d62bdc201 100644 --- a/docs/docs/modules/rust/index.md +++ b/docs/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/docs/modules/rust/quickstart.md b/docs/docs/modules/rust/quickstart.md index 062aa16302c..e015b881351 100644 --- a/docs/docs/modules/rust/quickstart.md +++ b/docs/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/docs/unity/part-2a-rust.md b/docs/docs/unity/part-2a-rust.md index 0acac51c5ba..fd9361f2b7f 100644 --- a/docs/docs/unity/part-2a-rust.md +++ b/docs/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/docs/unity/part-2b-c-sharp.md b/docs/docs/unity/part-2b-c-sharp.md index 8cdb0947f2e..ee6c0028b97 100644 --- a/docs/docs/unity/part-2b-c-sharp.md +++ b/docs/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/docs/unity/part-4.md b/docs/docs/unity/part-4.md index b3a174393f6..b8af1018a8b 100644 --- a/docs/docs/unity/part-4.md +++ b/docs/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 7cd55a06ad6dbebc40c94f51a82807f8959dcb17 Mon Sep 17 00:00:00 2001 From: Kim Altintop Date: Fri, 14 Jun 2024 16:29:25 +0200 Subject: [PATCH 053/195] Update response of `/database/info` (#64) To match changes in clockworklabs/spacetimedb#1305 --- docs/docs/http/database.md | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/docs/docs/http/database.md b/docs/docs/http/database.md index 2d55188a74c..16ee729caff 100644 --- a/docs/docs/http/database.md +++ b/docs/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 1ce3b53a1c78f7b736fe07c7b3264e611a222748 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 054/195] 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/docs/modules/c-sharp/index.md | 8 ++++++-- docs/docs/modules/c-sharp/quickstart.md | 8 ++++++-- docs/docs/modules/rust/index.md | 24 ++++++++++++++---------- docs/docs/modules/rust/quickstart.md | 10 +++++++--- docs/docs/unity/part-2a-rust.md | 10 +++++----- docs/docs/unity/part-2b-c-sharp.md | 10 +++++----- docs/docs/unity/part-4.md | 6 +++--- 7 files changed, 46 insertions(+), 30 deletions(-) diff --git a/docs/docs/modules/c-sharp/index.md b/docs/docs/modules/c-sharp/index.md index 31ebd1d4ced..1af76f84b80 100644 --- a/docs/docs/modules/c-sharp/index.md +++ b/docs/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/docs/modules/c-sharp/quickstart.md b/docs/docs/modules/c-sharp/quickstart.md index f5f734015bc..747f42608d1 100644 --- a/docs/docs/modules/c-sharp/quickstart.md +++ b/docs/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/docs/modules/rust/index.md b/docs/docs/modules/rust/index.md index 05d62bdc201..b08075a05b0 100644 --- a/docs/docs/modules/rust/index.md +++ b/docs/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/docs/modules/rust/quickstart.md b/docs/docs/modules/rust/quickstart.md index e015b881351..ed9fc376ec0 100644 --- a/docs/docs/modules/rust/quickstart.md +++ b/docs/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/docs/unity/part-2a-rust.md b/docs/docs/unity/part-2a-rust.md index fd9361f2b7f..dbfdc88857c 100644 --- a/docs/docs/unity/part-2a-rust.md +++ b/docs/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/docs/unity/part-2b-c-sharp.md b/docs/docs/unity/part-2b-c-sharp.md index ee6c0028b97..f1956b70fc2 100644 --- a/docs/docs/unity/part-2b-c-sharp.md +++ b/docs/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/docs/unity/part-4.md b/docs/docs/unity/part-4.md index b8af1018a8b..b3a174393f6 100644 --- a/docs/docs/unity/part-4.md +++ b/docs/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 1f97c4230d97753f04f208a13271489a42e96197 Mon Sep 17 00:00:00 2001 From: Ingvar Stepanyan Date: Tue, 18 Jun 2024 16:01:53 +0100 Subject: [PATCH 055/195] Remove CreateInstance() from C# client docs (#69) This method no longer exists - an instance with a default logger is set up automatically. --- docs/docs/sdks/c-sharp/index.md | 42 +++------------------------- docs/docs/sdks/c-sharp/quickstart.md | 3 -- 2 files changed, 4 insertions(+), 41 deletions(-) diff --git a/docs/docs/sdks/c-sharp/index.md b/docs/docs/sdks/c-sharp/index.md index 7c920cf5bdd..d0d15237e72 100644 --- a/docs/docs/sdks/c-sharp/index.md +++ b/docs/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/docs/sdks/c-sharp/quickstart.md b/docs/docs/sdks/c-sharp/quickstart.md index 28f3c2e13f1..122465d3995 100644 --- a/docs/docs/sdks/c-sharp/quickstart.md +++ b/docs/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 4b9b82c3c8943c6f9d2ef02eefc3c0cda613bce5 Mon Sep 17 00:00:00 2001 From: Ingvar Stepanyan Date: Tue, 18 Jun 2024 16:12:13 +0100 Subject: [PATCH 056/195] DbEventArgs -> ReducerContext in C# API docs (#66) This API was recently renamed. --- docs/docs/modules/c-sharp/index.md | 8 +++--- docs/docs/modules/c-sharp/quickstart.md | 34 ++++++++++++------------- docs/docs/unity/part-2b-c-sharp.md | 28 ++++++++++---------- 3 files changed, 35 insertions(+), 35 deletions(-) diff --git a/docs/docs/modules/c-sharp/index.md b/docs/docs/modules/c-sharp/index.md index 1af76f84b80..7037a2a8dc5 100644 --- a/docs/docs/modules/c-sharp/index.md +++ b/docs/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/docs/modules/c-sharp/quickstart.md b/docs/docs/modules/c-sharp/quickstart.md index 747f42608d1..66c6e5cb94a 100644 --- a/docs/docs/modules/c-sharp/quickstart.md +++ b/docs/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/docs/unity/part-2b-c-sharp.md b/docs/docs/unity/part-2b-c-sharp.md index f1956b70fc2..e311714a313 100644 --- a/docs/docs/unity/part-2b-c-sharp.md +++ b/docs/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 5b3c85662f7fdd49d234fd83d8dee6f217f0c0ec Mon Sep 17 00:00:00 2001 From: Ingvar Stepanyan Date: Tue, 18 Jun 2024 16:12:24 +0100 Subject: [PATCH 057/195] Remove obsolete C# module imports (#67) --- docs/docs/modules/c-sharp/quickstart.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/docs/docs/modules/c-sharp/quickstart.md b/docs/docs/modules/c-sharp/quickstart.md index 66c6e5cb94a..559dca9247a 100644 --- a/docs/docs/modules/c-sharp/quickstart.md +++ b/docs/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 780884f8b4a3c31ce9fa3e49e4af75c6b1f028f7 Mon Sep 17 00:00:00 2001 From: Ingvar Stepanyan Date: Tue, 18 Jun 2024 16:12:58 +0100 Subject: [PATCH 058/195] 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/docs/index.md | 2 +- docs/docs/modules/rust/index.md | 6 ++-- docs/docs/modules/rust/quickstart.md | 8 ++--- docs/docs/sdks/c-sharp/index.md | 43 +++++++++++++--------- docs/docs/sdks/c-sharp/quickstart.md | 4 +-- docs/docs/sdks/rust/index.md | 26 +++++++++----- docs/docs/sdks/rust/quickstart.md | 4 +-- docs/docs/sdks/typescript/index.md | 47 ++++++++++++++++++++++--- docs/docs/sdks/typescript/quickstart.md | 6 ++-- docs/docs/unity/part-2a-rust.md | 12 +++---- docs/docs/unity/part-2b-c-sharp.md | 2 +- docs/docs/unity/part-3.md | 18 +++++----- docs/docs/unity/part-4.md | 4 +-- 13 files changed, 120 insertions(+), 62 deletions(-) diff --git a/docs/docs/index.md b/docs/docs/index.md index 7a95f4f8516..eaee2c8363f 100644 --- a/docs/docs/index.md +++ b/docs/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/docs/modules/rust/index.md b/docs/docs/modules/rust/index.md index b08075a05b0..f4d0249004c 100644 --- a/docs/docs/modules/rust/index.md +++ b/docs/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/docs/modules/rust/quickstart.md b/docs/docs/modules/rust/quickstart.md index ed9fc376ec0..346810d794f 100644 --- a/docs/docs/modules/rust/quickstart.md +++ b/docs/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/docs/sdks/c-sharp/index.md b/docs/docs/sdks/c-sharp/index.md index d0d15237e72..e8a3d01a126 100644 --- a/docs/docs/sdks/c-sharp/index.md +++ b/docs/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/docs/sdks/c-sharp/quickstart.md b/docs/docs/sdks/c-sharp/quickstart.md index 122465d3995..db06d9a4180 100644 --- a/docs/docs/sdks/c-sharp/quickstart.md +++ b/docs/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/docs/sdks/rust/index.md b/docs/docs/sdks/rust/index.md index bd914b00c9a..dbc23112a82 100644 --- a/docs/docs/sdks/rust/index.md +++ b/docs/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/docs/sdks/rust/quickstart.md b/docs/docs/sdks/rust/quickstart.md index d1969fc3852..6df255e8ed7 100644 --- a/docs/docs/sdks/rust/quickstart.md +++ b/docs/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/docs/sdks/typescript/index.md b/docs/docs/sdks/typescript/index.md index 166c157502b..00917813f3a 100644 --- a/docs/docs/sdks/typescript/index.md +++ b/docs/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/docs/sdks/typescript/quickstart.md b/docs/docs/sdks/typescript/quickstart.md index ca8abff99c6..46b758ea283 100644 --- a/docs/docs/sdks/typescript/quickstart.md +++ b/docs/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/docs/unity/part-2a-rust.md b/docs/docs/unity/part-2a-rust.md index dbfdc88857c..58523f5764a 100644 --- a/docs/docs/unity/part-2a-rust.md +++ b/docs/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/docs/unity/part-2b-c-sharp.md b/docs/docs/unity/part-2b-c-sharp.md index e311714a313..e4dcac7a33f 100644 --- a/docs/docs/unity/part-2b-c-sharp.md +++ b/docs/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/docs/unity/part-3.md b/docs/docs/unity/part-3.md index d1db4dbb784..5c47cdc8676 100644 --- a/docs/docs/unity/part-3.md +++ b/docs/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/docs/unity/part-4.md b/docs/docs/unity/part-4.md index b3a174393f6..10738e84568 100644 --- a/docs/docs/unity/part-4.md +++ b/docs/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 4e15ed15a5a2db1bddc453f875365b4d2e467612 Mon Sep 17 00:00:00 2001 From: Ingvar Stepanyan Date: Tue, 18 Jun 2024 16:13:16 +0100 Subject: [PATCH 059/195] 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/docs/modules/c-sharp/index.md | 47 ++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/docs/docs/modules/c-sharp/index.md b/docs/docs/modules/c-sharp/index.md index 7037a2a8dc5..6fdc84be88b 100644 --- a/docs/docs/modules/c-sharp/index.md +++ b/docs/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 6317ec1ecf06c8eecdd151d70b8847c9ef7757ed 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 060/195] 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/docs/modules/c-sharp/index.md | 21 +++++++++++++++++++-- docs/docs/unity/part-1.md | 4 ++-- docs/docs/unity/part-2b-c-sharp.md | 19 ++++++------------- docs/docs/unity/part-3.md | 26 ++++++++++++++------------ 4 files changed, 41 insertions(+), 29 deletions(-) diff --git a/docs/docs/modules/c-sharp/index.md b/docs/docs/modules/c-sharp/index.md index 6fdc84be88b..ad1446fb926 100644 --- a/docs/docs/modules/c-sharp/index.md +++ b/docs/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/docs/unity/part-1.md b/docs/docs/unity/part-1.md index c53814d15d1..5643a285896 100644 --- a/docs/docs/unity/part-1.md +++ b/docs/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/docs/unity/part-2b-c-sharp.md b/docs/docs/unity/part-2b-c-sharp.md index e4dcac7a33f..5be1c7cbc7d 100644 --- a/docs/docs/unity/part-2b-c-sharp.md +++ b/docs/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/docs/unity/part-3.md b/docs/docs/unity/part-3.md index 5c47cdc8676..d3eeec8ca16 100644 --- a/docs/docs/unity/part-3.md +++ b/docs/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 7472a4ca87141e79eb01c476c7af8b1fcbb23fe5 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 061/195] Remove Python & update "coming soon" languages (#72) * [bfops/remove-python]: do thing * [bfops/remove-python]: empty --------- Co-authored-by: Zeke Foppa --- docs/docs/getting-started.md | 1 - docs/docs/http/index.md | 9 - docs/docs/index.md | 3 +- docs/docs/modules/c-sharp/quickstart.md | 2 +- docs/docs/modules/rust/quickstart.md | 2 +- docs/docs/nav.js | 2 - docs/docs/sdks/index.md | 5 +- docs/docs/sdks/python/index.md | 552 ------------------------ docs/docs/sdks/python/quickstart.md | 379 ---------------- docs/nav.ts | 2 - 10 files changed, 5 insertions(+), 952 deletions(-) delete mode 100644 docs/docs/sdks/python/index.md delete mode 100644 docs/docs/sdks/python/quickstart.md diff --git a/docs/docs/getting-started.md b/docs/docs/getting-started.md index 177a0d2514c..4b0cddae8e9 100644 --- a/docs/docs/getting-started.md +++ b/docs/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/docs/http/index.md b/docs/docs/http/index.md index a4e885b1a97..a59408e409a 100644 --- a/docs/docs/http/index.md +++ b/docs/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/docs/index.md b/docs/docs/index.md index eaee2c8363f..700c2bfcf75 100644 --- a/docs/docs/index.md +++ b/docs/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/docs/modules/c-sharp/quickstart.md b/docs/docs/modules/c-sharp/quickstart.md index 559dca9247a..027b7ef9983 100644 --- a/docs/docs/modules/c-sharp/quickstart.md +++ b/docs/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/docs/modules/rust/quickstart.md b/docs/docs/modules/rust/quickstart.md index 346810d794f..e115ac972ea 100644 --- a/docs/docs/modules/rust/quickstart.md +++ b/docs/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/docs/nav.js b/docs/docs/nav.js index 4413888ef48..6949c4f72e5 100644 --- a/docs/docs/nav.js +++ b/docs/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/docs/sdks/index.md b/docs/docs/sdks/index.md index 6357e653915..940f06aca3e 100644 --- a/docs/docs/sdks/index.md +++ b/docs/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/docs/sdks/python/index.md b/docs/docs/sdks/python/index.md deleted file mode 100644 index 8b1ceb8b25a..00000000000 --- a/docs/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/docs/sdks/python/quickstart.md b/docs/docs/sdks/python/quickstart.md deleted file mode 100644 index 2b9d7aa128d..00000000000 --- a/docs/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/docs/nav.ts b/docs/nav.ts index 26a83f4c568..8b21cc9184e 100644 --- a/docs/nav.ts +++ b/docs/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 cd924d20494fc98e8fc041edc9afd8882a2d48b6 Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Fri, 2 Aug 2024 23:25:52 +0530 Subject: [PATCH 062/195] scheduler table doc update (#73) --- docs/docs/modules/c-sharp/index.md | 77 ++++++++++++++++++++++++------ docs/docs/modules/rust/index.md | 76 +++++++++++++++++++++-------- docs/docs/unity/part-4.md | 21 ++++++-- 3 files changed, 133 insertions(+), 41 deletions(-) diff --git a/docs/docs/modules/c-sharp/index.md b/docs/docs/modules/c-sharp/index.md index ad1446fb926..7380467f650 100644 --- a/docs/docs/modules/c-sharp/index.md +++ b/docs/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/docs/modules/rust/index.md b/docs/docs/modules/rust/index.md index f4d0249004c..c2acf5cbd88 100644 --- a/docs/docs/modules/rust/index.md +++ b/docs/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/docs/unity/part-4.md b/docs/docs/unity/part-4.md index 10738e84568..d7c22280ba6 100644 --- a/docs/docs/unity/part-4.md +++ b/docs/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 73c3918c9ba2083766dffe68539bb841d1b7a718 Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Sat, 3 Aug 2024 00:25:37 +0530 Subject: [PATCH 063/195] Shub/revert scheduler table doc (#76) Revert "scheduler table doc update (#73)" This reverts commit cd924d20494fc98e8fc041edc9afd8882a2d48b6. --- docs/docs/modules/c-sharp/index.md | 77 ++++++------------------------ docs/docs/modules/rust/index.md | 76 ++++++++--------------------- docs/docs/unity/part-4.md | 21 ++------ 3 files changed, 41 insertions(+), 133 deletions(-) diff --git a/docs/docs/modules/c-sharp/index.md b/docs/docs/modules/c-sharp/index.md index 7380467f650..ad1446fb926 100644 --- a/docs/docs/modules/c-sharp/index.md +++ b/docs/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/docs/modules/rust/index.md b/docs/docs/modules/rust/index.md index c2acf5cbd88..f4d0249004c 100644 --- a/docs/docs/modules/rust/index.md +++ b/docs/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/docs/unity/part-4.md b/docs/docs/unity/part-4.md index d7c22280ba6..10738e84568 100644 --- a/docs/docs/unity/part-4.md +++ b/docs/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 efcc979a6fa3e28bb83aa34321ae2ab4f4dcd504 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Fri, 9 Aug 2024 19:28:38 -0400 Subject: [PATCH 064/195] Update quickstart.md (#74) --- docs/docs/sdks/rust/quickstart.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/sdks/rust/quickstart.md b/docs/docs/sdks/rust/quickstart.md index 6df255e8ed7..af07e40346c 100644 --- a/docs/docs/sdks/rust/quickstart.md +++ b/docs/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 c4759844e359e0f4c9dd59c469a252c9934b8eb9 Mon Sep 17 00:00:00 2001 From: Shubham Mishra Date: Sat, 10 Aug 2024 04:58:49 +0530 Subject: [PATCH 065/195] scheduler table doc update (#77) --- docs/docs/modules/c-sharp/index.md | 77 ++++++++++++++++++++++++------ docs/docs/modules/rust/index.md | 76 +++++++++++++++++++++-------- docs/docs/unity/part-4.md | 21 ++++++-- 3 files changed, 133 insertions(+), 41 deletions(-) diff --git a/docs/docs/modules/c-sharp/index.md b/docs/docs/modules/c-sharp/index.md index ad1446fb926..7380467f650 100644 --- a/docs/docs/modules/c-sharp/index.md +++ b/docs/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/docs/modules/rust/index.md b/docs/docs/modules/rust/index.md index f4d0249004c..c2acf5cbd88 100644 --- a/docs/docs/modules/rust/index.md +++ b/docs/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/docs/unity/part-4.md b/docs/docs/unity/part-4.md index 10738e84568..d7c22280ba6 100644 --- a/docs/docs/unity/part-4.md +++ b/docs/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 1b9e7d33c8421563c586271a5a4d189f156c6d41 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Thu, 29 Aug 2024 17:52:53 -0400 Subject: [PATCH 066/195] Update quickstart.md (#81) Revert the find_by changes in rust which were never made. --- docs/docs/modules/rust/quickstart.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/docs/modules/rust/quickstart.md b/docs/docs/modules/rust/quickstart.md index e115ac972ea..d3544f19d2d 100644 --- a/docs/docs/modules/rust/quickstart.md +++ b/docs/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 61f427df9addc46e948abc871825c04096360d35 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 067/195] Update Rust Quickstart to use correct function to find User (#80) Update quickstart.md From 563139305328dfcd8a8147700be4d3f0c1a4b122 Mon Sep 17 00:00:00 2001 From: ike709 Date: Thu, 29 Aug 2024 16:55:13 -0500 Subject: [PATCH 068/195] Explicitly remind the reader to start the server (#43) --- docs/docs/modules/c-sharp/quickstart.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/docs/modules/c-sharp/quickstart.md b/docs/docs/modules/c-sharp/quickstart.md index 027b7ef9983..21e4fcd0562 100644 --- a/docs/docs/modules/c-sharp/quickstart.md +++ b/docs/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 9aad24ebd02ac8574af85bfff8dd8a87f485193e 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 069/195] Fix broken tutorial package link (#86) --- docs/docs/unity/part-1.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/unity/part-1.md b/docs/docs/unity/part-1.md index 5643a285896..14eb2405e52 100644 --- a/docs/docs/unity/part-1.md +++ b/docs/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 6412318479794f4cdac71356cd4569818af0df29 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 070/195] prettier (#85) Push --- docs/.prettierrc | 11 ++ docs/README.md | 1 + docs/docs/getting-started.md | 2 +- docs/docs/http/database.md | 72 +++++----- docs/docs/http/energy.md | 4 +- docs/docs/http/identity.md | 12 +- docs/docs/modules/c-sharp/index.md | 21 ++- docs/docs/modules/c-sharp/quickstart.md | 4 +- docs/docs/modules/rust/index.md | 13 +- docs/docs/nav.js | 116 +++++++++------- docs/docs/sdks/c-sharp/index.md | 6 +- docs/docs/sdks/index.md | 2 +- docs/docs/sdks/rust/index.md | 20 +-- docs/docs/sdks/typescript/index.md | 170 ++++++++++++++---------- docs/docs/sdks/typescript/quickstart.md | 42 +++--- docs/docs/unity/part-1.md | 4 +- docs/docs/unity/part-2b-c-sharp.md | 1 + docs/docs/unity/part-4.md | 2 +- docs/docs/ws/index.md | 4 +- docs/nav.ts | 119 ++++++++++------- docs/package.json | 2 +- 21 files changed, 363 insertions(+), 265 deletions(-) create mode 100644 docs/.prettierrc diff --git a/docs/.prettierrc b/docs/.prettierrc new file mode 100644 index 00000000000..2921455b325 --- /dev/null +++ b/docs/.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/docs/README.md b/docs/README.md index 0f9998b0894..c31b2c3f98f 100644 --- a/docs/README.md +++ b/docs/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/docs/getting-started.md b/docs/docs/getting-started.md index 4b0cddae8e9..33265dc25d0 100644 --- a/docs/docs/getting-started.md +++ b/docs/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/docs/http/database.md b/docs/docs/http/database.md index 16ee729caff..9b6e048828b 100644 --- a/docs/docs/http/database.md +++ b/docs/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/docs/http/energy.md b/docs/docs/http/energy.md index b49a1ee7f21..6f0083145e9 100644 --- a/docs/docs/http/energy.md +++ b/docs/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/docs/http/identity.md b/docs/docs/http/identity.md index 5fb45867883..6f1e22c903e 100644 --- a/docs/docs/http/identity.md +++ b/docs/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/docs/modules/c-sharp/index.md b/docs/docs/modules/c-sharp/index.md index 7380467f650..f6763fc790e 100644 --- a/docs/docs/modules/c-sharp/index.md +++ b/docs/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/docs/modules/c-sharp/quickstart.md b/docs/docs/modules/c-sharp/quickstart.md index 21e4fcd0562..768602e4821 100644 --- a/docs/docs/modules/c-sharp/quickstart.md +++ b/docs/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/docs/modules/rust/index.md b/docs/docs/modules/rust/index.md index c2acf5cbd88..55ceec1896d 100644 --- a/docs/docs/modules/rust/index.md +++ b/docs/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/docs/nav.js b/docs/docs/nav.js index 6949c4f72e5..5a669500c00 100644 --- a/docs/docs/nav.js +++ b/docs/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/docs/sdks/c-sharp/index.md b/docs/docs/sdks/c-sharp/index.md index e8a3d01a126..d85f57029b8 100644 --- a/docs/docs/sdks/c-sharp/index.md +++ b/docs/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/docs/sdks/index.md b/docs/docs/sdks/index.md index 940f06aca3e..46078cb9114 100644 --- a/docs/docs/sdks/index.md +++ b/docs/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/docs/sdks/rust/index.md b/docs/docs/sdks/rust/index.md index dbc23112a82..9c9e6f12d34 100644 --- a/docs/docs/sdks/rust/index.md +++ b/docs/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/docs/sdks/typescript/index.md b/docs/docs/sdks/typescript/index.md index 00917813f3a..4f4e17da60b 100644 --- a/docs/docs/sdks/typescript/index.md +++ b/docs/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/docs/sdks/typescript/quickstart.md b/docs/docs/sdks/typescript/quickstart.md index 46b758ea283..96725cbdef6 100644 --- a/docs/docs/sdks/typescript/quickstart.md +++ b/docs/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/docs/unity/part-1.md b/docs/docs/unity/part-1.md index 14eb2405e52..8e0a49e3084 100644 --- a/docs/docs/unity/part-1.md +++ b/docs/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/docs/unity/part-2b-c-sharp.md b/docs/docs/unity/part-2b-c-sharp.md index 5be1c7cbc7d..fa02d866fc9 100644 --- a/docs/docs/unity/part-2b-c-sharp.md +++ b/docs/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/docs/unity/part-4.md b/docs/docs/unity/part-4.md index d7c22280ba6..029fbe13ff6 100644 --- a/docs/docs/unity/part-4.md +++ b/docs/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/docs/ws/index.md b/docs/docs/ws/index.md index b00bfa56d45..587fbad0853 100644 --- a/docs/docs/ws/index.md +++ b/docs/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/docs/nav.ts b/docs/nav.ts index 8b21cc9184e..19e69c76509 100644 --- a/docs/nav.ts +++ b/docs/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/docs/package.json b/docs/package.json index a56ea4e86a6..2c2b9445c09 100644 --- a/docs/package.json +++ b/docs/package.json @@ -12,4 +12,4 @@ }, "author": "Clockwork Labs", "license": "ISC" -} \ No newline at end of file +} From bb057fc220ad2e6b59ccb591ddfa166cd5357bf0 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Tue, 24 Sep 2024 11:33:31 -0400 Subject: [PATCH 071/195] Update quickstart.md (#84) --- docs/docs/modules/c-sharp/quickstart.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/modules/c-sharp/quickstart.md b/docs/docs/modules/c-sharp/quickstart.md index 768602e4821..5d8c873d810 100644 --- a/docs/docs/modules/c-sharp/quickstart.md +++ b/docs/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 53d27b4ea6b5b6c03914003a4c064183711b18fc Mon Sep 17 00:00:00 2001 From: Egor Gavrilov Date: Tue, 24 Sep 2024 23:34:20 +0800 Subject: [PATCH 072/195] Fix typo in modules/rust/index.md (#83) Person -> Unique (because that belongs to `Unique` table) --- docs/docs/modules/rust/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/modules/rust/index.md b/docs/docs/modules/rust/index.md index 55ceec1896d..28be1c83ec7 100644 --- a/docs/docs/modules/rust/index.md +++ b/docs/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 59a90c679308b67ae51ecf47b6dab9d4faa88ccd Mon Sep 17 00:00:00 2001 From: Arrel Neumiller Date: Tue, 24 Sep 2024 16:35:13 +0100 Subject: [PATCH 073/195] 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/docs/unity/part-2b-c-sharp.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/docs/docs/unity/part-2b-c-sharp.md b/docs/docs/unity/part-2b-c-sharp.md index fa02d866fc9..b1d50e8bff8 100644 --- a/docs/docs/unity/part-2b-c-sharp.md +++ b/docs/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 a749ccb3a8bbe8fa098733f2abc7384b0dfb104e Mon Sep 17 00:00:00 2001 From: Muthsera Date: Tue, 24 Sep 2024 17:54:25 +0200 Subject: [PATCH 074/195] Fixed code examples in rust reference regarding insertion (#42) --- docs/docs/modules/rust/index.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/docs/modules/rust/index.md b/docs/docs/modules/rust/index.md index 28be1c83ec7..443f8171c91 100644 --- a/docs/docs/modules/rust/index.md +++ b/docs/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 2a0ba3d07ecba79ff517165cc90f91940829f88f 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 075/195] 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/docs/sdks/rust/quickstart.md | 299 +++++++++++++++--------------- 1 file changed, 148 insertions(+), 151 deletions(-) diff --git a/docs/docs/sdks/rust/quickstart.md b/docs/docs/sdks/rust/quickstart.md index af07e40346c..9cea42c3404 100644 --- a/docs/docs/sdks/rust/quickstart.md +++ b/docs/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 4267878a52ccbbb3ac67c44e67dad6268f0bd957 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Wed, 2 Oct 2024 12:09:32 -0400 Subject: [PATCH 076/195] I didn't notice that auto-merge was enabled, so here's my review (#94) --- docs/docs/sdks/rust/quickstart.md | 37 ++++++++++++++----------------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/docs/docs/sdks/rust/quickstart.md b/docs/docs/sdks/rust/quickstart.md index 9cea42c3404..38d9dee74cf 100644 --- a/docs/docs/sdks/rust/quickstart.md +++ b/docs/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 0fa848c81839456d3a8ebe38a6b488bcc2cf4c8a Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Wed, 2 Oct 2024 12:35:23 -0400 Subject: [PATCH 077/195] Update Rust SDK ref for the new SDK (#93) --- docs/docs/sdks/rust/index.md | 1153 +++++++--------------------------- 1 file changed, 221 insertions(+), 932 deletions(-) diff --git a/docs/docs/sdks/rust/index.md b/docs/docs/sdks/rust/index.md index 9c9e6f12d34..50e8aa9b4da 100644 --- a/docs/docs/sdks/rust/index.md +++ b/docs/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 aba173904ea1b6549f8f853f065c704adf0dfbb0 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 078/195] Updated rust quickstart for 0.12 (#88) * Updated rust quickstart for 0.12 * Suggested tweaks --------- Co-authored-by: John Detter --- docs/docs/modules/rust/quickstart.md | 91 +++++++++++++++------------- 1 file changed, 48 insertions(+), 43 deletions(-) diff --git a/docs/docs/modules/rust/quickstart.md b/docs/docs/modules/rust/quickstart.md index d3544f19d2d..9fcfe30d1e7 100644 --- a/docs/docs/modules/rust/quickstart.md +++ b/docs/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 151039d8a2c94cea70c7874b233897d539e8fdee 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 079/195] 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/docs/modules/rust/index.md | 150 ++++++++++++++++---------------- 1 file changed, 75 insertions(+), 75 deletions(-) diff --git a/docs/docs/modules/rust/index.md b/docs/docs/modules/rust/index.md index 443f8171c91..83a751be7e6 100644 --- a/docs/docs/modules/rust/index.md +++ b/docs/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 d8e7baa9d63a5c5dd02eea9ae0d8af90fa24c317 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Fri, 4 Oct 2024 02:39:05 -0400 Subject: [PATCH 080/195] 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/docs/migration/v0.12.md | 341 +++++++++++++++++++++++++++++++++++ docs/docs/nav.js | 118 +++++------- docs/nav.ts | 3 + 3 files changed, 394 insertions(+), 68 deletions(-) create mode 100644 docs/docs/migration/v0.12.md diff --git a/docs/docs/migration/v0.12.md b/docs/docs/migration/v0.12.md new file mode 100644 index 00000000000..9384407f3d3 --- /dev/null +++ b/docs/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/docs/nav.js b/docs/docs/nav.js index 5a669500c00..a43c2e298cb 100644 --- a/docs/docs/nav.js +++ b/docs/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/docs/nav.ts b/docs/nav.ts index 19e69c76509..0d191439871 100644 --- a/docs/nav.ts +++ b/docs/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 89ab48cd9d9788806bf6b71c5a6591ffa7893670 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Fri, 4 Oct 2024 12:47:39 -0400 Subject: [PATCH 081/195] Whitespace (#98) --- docs/nav.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/nav.ts b/docs/nav.ts index 0d191439871..8ca41be774e 100644 --- a/docs/nav.ts +++ b/docs/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 88eeb1c23531f55aeae4853fff19985aa9de6d65 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Tue, 22 Oct 2024 10:49:41 -0400 Subject: [PATCH 082/195] 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/docs/modules/rust/index.md | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/docs/docs/modules/rust/index.md b/docs/docs/modules/rust/index.md index 83a751be7e6..24fa82bf174 100644 --- a/docs/docs/modules/rust/index.md +++ b/docs/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 0557d0ef4e6e047ffe382e11a51e7f2d72e43d0f Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Tue, 26 Nov 2024 10:57:41 -0500 Subject: [PATCH 083/195] 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 --- docs/STYLE.md | 349 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 349 insertions(+) create mode 100644 docs/STYLE.md diff --git a/docs/STYLE.md b/docs/STYLE.md new file mode 100644 index 00000000000..25d5848d1d9 --- /dev/null +++ b/docs/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 85c137ca90786d9224cc23052ff74fb654fba918 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Tue, 31 Dec 2024 17:05:13 -0500 Subject: [PATCH 084/195] Added .DS_store to the .gitignore --- docs/.gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/.gitignore b/docs/.gitignore index 589c396ecb4..d839abdeeba 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -1,4 +1,5 @@ **/.vscode .idea *.log -node_modules \ No newline at end of file +node_modules +.DS_store From 48bab90a41df288fb6c06f3689012ac889be587e Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Fri, 3 Jan 2025 00:44:00 -0500 Subject: [PATCH 085/195] 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 --- docs/.github/workflows/check-links.yml | 26 ++ docs/.github/workflows/validate-nav-build.yml | 40 +++ docs/README.md | 4 + docs/docs/bsatn.md | 32 +-- docs/docs/http/database.md | 39 --- docs/docs/modules/c-sharp/quickstart.md | 2 +- docs/docs/nav.js | 17 +- docs/docs/satn.md | 4 +- docs/docs/sdks/c-sharp/index.md | 9 +- docs/docs/sdks/rust/index.md | 4 +- docs/docs/sdks/typescript/index.md | 9 +- docs/docs/unity/part-1.md | 4 +- docs/docs/ws/index.md | 2 +- docs/package.json | 5 +- docs/scripts/checkLinks.ts | 231 ++++++++++++++++++ docs/tsconfig.json | 2 + docs/yarn.lock | 189 ++++++++++++++ 17 files changed, 539 insertions(+), 80 deletions(-) create mode 100644 docs/.github/workflows/check-links.yml create mode 100644 docs/.github/workflows/validate-nav-build.yml create mode 100644 docs/scripts/checkLinks.ts diff --git a/docs/.github/workflows/check-links.yml b/docs/.github/workflows/check-links.yml new file mode 100644 index 00000000000..1053fe7df84 --- /dev/null +++ b/docs/.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/docs/.github/workflows/validate-nav-build.yml b/docs/.github/workflows/validate-nav-build.yml new file mode 100644 index 00000000000..b76378d657a --- /dev/null +++ b/docs/.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/docs/README.md b/docs/README.md index c31b2c3f98f..2165ae6267d 100644 --- a/docs/README.md +++ b/docs/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/docs/bsatn.md b/docs/docs/bsatn.md index 0da55ce73ea..e8e6d945b57 100644 --- a/docs/docs/bsatn.md +++ b/docs/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/docs/http/database.md b/docs/docs/http/database.md index 9b6e048828b..b23701e8d7b 100644 --- a/docs/docs/http/database.md +++ b/docs/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/docs/modules/c-sharp/quickstart.md b/docs/docs/modules/c-sharp/quickstart.md index 5d8c873d810..571351c13cc 100644 --- a/docs/docs/modules/c-sharp/quickstart.md +++ b/docs/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/docs/nav.js b/docs/docs/nav.js index a43c2e298cb..5c3a920ef52 100644 --- a/docs/docs/nav.js +++ b/docs/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/docs/satn.md b/docs/docs/satn.md index f21e9b3068b..6fb0ee9f2c4 100644 --- a/docs/docs/satn.md +++ b/docs/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/docs/sdks/c-sharp/index.md b/docs/docs/sdks/c-sharp/index.md index d85f57029b8..a044e4eacfb 100644 --- a/docs/docs/sdks/c-sharp/index.md +++ b/docs/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/docs/sdks/rust/index.md b/docs/docs/sdks/rust/index.md index 50e8aa9b4da..d8befe53ea4 100644 --- a/docs/docs/sdks/rust/index.md +++ b/docs/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/docs/sdks/typescript/index.md b/docs/docs/sdks/typescript/index.md index 4f4e17da60b..34d9edef2be 100644 --- a/docs/docs/sdks/typescript/index.md +++ b/docs/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/docs/unity/part-1.md b/docs/docs/unity/part-1.md index 8e0a49e3084..10967b33bb3 100644 --- a/docs/docs/unity/part-1.md +++ b/docs/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/docs/ws/index.md b/docs/docs/ws/index.md index 587fbad0853..1a3780ccb7f 100644 --- a/docs/docs/ws/index.md +++ b/docs/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/docs/package.json b/docs/package.json index 2c2b9445c09..e7716fa4b8c 100644 --- a/docs/package.json +++ b/docs/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/docs/scripts/checkLinks.ts b/docs/scripts/checkLinks.ts new file mode 100644 index 00000000000..78a8daf86db --- /dev/null +++ b/docs/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/docs/tsconfig.json b/docs/tsconfig.json index 2a5ee7d21d4..efe136bd60e 100644 --- a/docs/tsconfig.json +++ b/docs/tsconfig.json @@ -3,6 +3,8 @@ "target": "ESNext", "module": "commonjs", "outDir": "./docs", + "esModuleInterop": true, + "strict": true, "skipLibCheck": true } } diff --git a/docs/yarn.lock b/docs/yarn.lock index fce89544647..d923eebd1f8 100644 --- a/docs/yarn.lock +++ b/docs/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 e09444959ed79590faf27f7a69de704227d3330e Mon Sep 17 00:00:00 2001 From: james gilles Date: Fri, 3 Jan 2025 14:30:18 -0500 Subject: [PATCH 086/195] 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/docs/index.md | 182 +++++++++++++++++++++++++++++++-------------- 1 file changed, 126 insertions(+), 56 deletions(-) diff --git a/docs/docs/index.md b/docs/docs/index.md index 700c2bfcf75..86b72bec416 100644 --- a/docs/docs/index.md +++ b/docs/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 639b9269feab678278bbf7b0e8eea99c718c4689 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Fri, 3 Jan 2025 16:06:09 -0500 Subject: [PATCH 087/195] Switches to a canonical GitHub slugger format so that our slugs always match links generated on GitHub (#123) Standardized slugging across docs and web --- docs/package.json | 4 +++- docs/scripts/checkLinks.ts | 7 +++---- docs/yarn.lock | 5 +++++ 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/package.json b/docs/package.json index e7716fa4b8c..26e48ffb122 100644 --- a/docs/package.json +++ b/docs/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/docs/scripts/checkLinks.ts b/docs/scripts/checkLinks.ts index 78a8daf86db..d67302f76ac 100644 --- a/docs/scripts/checkLinks.ts +++ b/docs/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/docs/yarn.lock b/docs/yarn.lock index d923eebd1f8..1527675fd6e 100644 --- a/docs/yarn.lock +++ b/docs/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 f2c0869db3891fc0c94ec967ed07e11a37fef299 Mon Sep 17 00:00:00 2001 From: Ingvar Stepanyan Date: Mon, 6 Jan 2025 21:12:47 +0000 Subject: [PATCH 088/195] 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/docs/modules/c-sharp/index.md | 11 +++++++++++ docs/docs/modules/rust/index.md | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/docs/docs/modules/c-sharp/index.md b/docs/docs/modules/c-sharp/index.md index f6763fc790e..2c31bb1cb93 100644 --- a/docs/docs/modules/c-sharp/index.md +++ b/docs/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/docs/modules/rust/index.md b/docs/docs/modules/rust/index.md index 24fa82bf174..dba75ab22a2 100644 --- a/docs/docs/modules/rust/index.md +++ b/docs/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 a19cc48a187e6cea2d61b3e78908c4dec6b03e24 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Tue, 7 Jan 2025 16:57:58 -0500 Subject: [PATCH 089/195] Fix missing tick marks in Rust quickstart (#124) Fixed missing tick marks in rust quickstart --- docs/docs/sdks/rust/quickstart.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/docs/sdks/rust/quickstart.md b/docs/docs/sdks/rust/quickstart.md index 38d9dee74cf..e7e3fd3eb79 100644 --- a/docs/docs/sdks/rust/quickstart.md +++ b/docs/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 bade25de0869ca778f8437709f345cd2907d4960 Mon Sep 17 00:00:00 2001 From: james gilles Date: Wed, 8 Jan 2025 14:09:11 -0500 Subject: [PATCH 090/195] 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 --- docs/STYLE.md | 41 ++++++++++++++++++++++++++++++++ docs/docs/getting-started.md | 2 +- docs/docs/index.md | 45 ++++++++++++++++++------------------ 3 files changed, 65 insertions(+), 23 deletions(-) diff --git a/docs/STYLE.md b/docs/STYLE.md index 25d5848d1d9..a72b1dd26a7 100644 --- a/docs/STYLE.md +++ b/docs/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/docs/getting-started.md b/docs/docs/getting-started.md index 33265dc25d0..7afeec31912 100644 --- a/docs/docs/getting-started.md +++ b/docs/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/docs/index.md b/docs/docs/index.md index 86b72bec416..bfa957ef4b8 100644 --- a/docs/docs/index.md +++ b/docs/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 96710dab994f64f5c71de3ff359d0fac33c5d8e3 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Wed, 15 Jan 2025 21:12:20 -0500 Subject: [PATCH 091/195] 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/docs/nav.js | 30 +- docs/docs/sdks/c-sharp/index.md | 2 - docs/docs/unity/index.md | 29 +- docs/docs/unity/part-1-hero-image.png | Bin 0 -> 357247 bytes .../unity/part-1-unity-hub-new-project.jpg | Bin 0 -> 38324 bytes .../unity/part-1-universal-2d-template.png | Bin 0 -> 475078 bytes docs/docs/unity/part-1.md | 113 +- docs/docs/unity/part-2.md | 414 ++++++ docs/docs/unity/part-2a-rust.md | 314 ----- docs/docs/unity/part-2b-c-sharp.md | 339 ----- docs/docs/unity/part-3-player-on-screen.png | Bin 0 -> 64962 bytes docs/docs/unity/part-3.md | 1109 ++++++++++++----- docs/docs/unity/part-4.md | 527 +++++--- docs/docs/unity/part-5.md | 108 -- docs/nav.ts | 14 +- docs/package.json | 2 +- docs/scripts/checkLinks.ts | 124 +- docs/tsconfig.json | 7 +- 18 files changed, 1677 insertions(+), 1455 deletions(-) create mode 100644 docs/docs/unity/part-1-hero-image.png create mode 100644 docs/docs/unity/part-1-unity-hub-new-project.jpg create mode 100644 docs/docs/unity/part-1-universal-2d-template.png create mode 100644 docs/docs/unity/part-2.md delete mode 100644 docs/docs/unity/part-2a-rust.md delete mode 100644 docs/docs/unity/part-2b-c-sharp.md create mode 100644 docs/docs/unity/part-3-player-on-screen.png delete mode 100644 docs/docs/unity/part-5.md diff --git a/docs/docs/nav.js b/docs/docs/nav.js index 5c3a920ef52..fea9ed854c2 100644 --- a/docs/docs/nav.js +++ b/docs/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/docs/sdks/c-sharp/index.md b/docs/docs/sdks/c-sharp/index.md index a044e4eacfb..0315d36c5af 100644 --- a/docs/docs/sdks/c-sharp/index.md +++ b/docs/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/docs/unity/index.md b/docs/docs/unity/index.md index 0fa181c6744..2a2d78f41d8 100644 --- a/docs/docs/unity/index.md +++ b/docs/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/docs/unity/part-1-hero-image.png b/docs/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/docs/unity/part-1-unity-hub-new-project.jpg b/docs/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- 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

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/docs/unity/part-2.md b/docs/docs/unity/part-2.md new file mode 100644 index 00000000000..ab82604ed74 --- /dev/null +++ b/docs/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/docs/unity/part-2a-rust.md b/docs/docs/unity/part-2a-rust.md deleted file mode 100644 index 58523f5764a..00000000000 --- a/docs/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/docs/unity/part-2b-c-sharp.md b/docs/docs/unity/part-2b-c-sharp.md deleted file mode 100644 index b1d50e8bff8..00000000000 --- a/docs/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/docs/unity/part-3-player-on-screen.png b/docs/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/docs/unity/part-4.md b/docs/docs/unity/part-4.md index 029fbe13ff6..8d89e7bec1e 100644 --- a/docs/docs/unity/part-4.md +++ b/docs/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/docs/unity/part-5.md b/docs/docs/unity/part-5.md deleted file mode 100644 index 2c59c73b91c..00000000000 --- a/docs/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/docs/nav.ts b/docs/nav.ts index 8ca41be774e..467a86d7b69 100644 --- a/docs/nav.ts +++ b/docs/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/docs/package.json b/docs/package.json index 26e48ffb122..c96b785bada 100644 --- a/docs/package.json +++ b/docs/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/docs/scripts/checkLinks.ts b/docs/scripts/checkLinks.ts index d67302f76ac..58c94f476b6 100644 --- a/docs/scripts/checkLinks.ts +++ b/docs/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/docs/tsconfig.json b/docs/tsconfig.json index efe136bd60e..d3f1db7d141 100644 --- a/docs/tsconfig.json +++ b/docs/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 c2e4314bcc00030eac4e22dbfc016d371c5b68a1 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Wed, 15 Jan 2025 22:59:34 -0500 Subject: [PATCH 092/195] Made fixes to the tutorial for changes that were introduced to the C# in RC3 (#135) --- docs/docs/unity/part-2.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/unity/part-2.md b/docs/docs/unity/part-2.md index ab82604ed74..b5aa6cb1011 100644 --- a/docs/docs/unity/part-2.md +++ b/docs/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 20a8ce2a74f3df16d1ee43e5bea37cf3e4e890a1 Mon Sep 17 00:00:00 2001 From: james gilles Date: Thu, 16 Jan 2025 10:48:41 -0500 Subject: [PATCH 093/195] Add remark to style guide inspired by SpacetimeDB#2050 (#133) --- docs/STYLE.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/STYLE.md b/docs/STYLE.md index a72b1dd26a7..96baef43fd9 100644 --- a/docs/STYLE.md +++ b/docs/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 9a33bfc8a91738b71d7c6cb0bd3395b4e8053b7d Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Fri, 17 Jan 2025 21:08:09 -0500 Subject: [PATCH 094/195] Addresses feedback that John provided (#137) * Addresses feedback that John provided * Fixed broken link --- docs/docs/index.md | 2 +- docs/docs/nav.js | 2 +- docs/docs/sdks/c-sharp/index.md | 2 +- docs/docs/unity/index.md | 7 ++++++- docs/docs/unity/part-1.md | 2 ++ docs/docs/unity/part-2.md | 18 ++++++++++++------ docs/docs/unity/part-3.md | 22 ++++++++++++---------- docs/docs/unity/part-4.md | 4 ++-- docs/nav.ts | 2 +- 9 files changed, 38 insertions(+), 23 deletions(-) diff --git a/docs/docs/index.md b/docs/docs/index.md index bfa957ef4b8..974b543f94a 100644 --- a/docs/docs/index.md +++ b/docs/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/docs/nav.js b/docs/docs/nav.js index fea9ed854c2..bdf49517d52 100644 --- a/docs/docs/nav.js +++ b/docs/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/docs/sdks/c-sharp/index.md b/docs/docs/sdks/c-sharp/index.md index 0315d36c5af..e9c5f23a1ad 100644 --- a/docs/docs/sdks/c-sharp/index.md +++ b/docs/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/docs/unity/index.md b/docs/docs/unity/index.md index 2a2d78f41d8..99ed9420bea 100644 --- a/docs/docs/unity/index.md +++ b/docs/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/docs/unity/part-1.md b/docs/docs/unity/part-1.md index 6f4eafdf019..7678d0f5fe7 100644 --- a/docs/docs/unity/part-1.md +++ b/docs/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/docs/unity/part-2.md b/docs/docs/unity/part-2.md index b5aa6cb1011..efa7bdbac04 100644 --- a/docs/docs/unity/part-2.md +++ b/docs/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/docs/unity/part-3.md b/docs/docs/unity/part-3.md index c8967976000..22791698f83 100644 --- a/docs/docs/unity/part-3.md +++ b/docs/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/docs/unity/part-4.md b/docs/docs/unity/part-4.md index 8d89e7bec1e..e179cd4a689 100644 --- a/docs/docs/unity/part-4.md +++ b/docs/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/docs/nav.ts b/docs/nav.ts index 467a86d7b69..609a7f0170f 100644 --- a/docs/nav.ts +++ b/docs/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 0db1dc0a6fb83c7694e73090a1fb53fe731db6d5 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Sat, 18 Jan 2025 01:15:13 -0500 Subject: [PATCH 095/195] Update quickstart.md --- docs/docs/sdks/typescript/quickstart.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/sdks/typescript/quickstart.md b/docs/docs/sdks/typescript/quickstart.md index 96725cbdef6..660b0bc283e 100644 --- a/docs/docs/sdks/typescript/quickstart.md +++ b/docs/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 80e886a85dc59fc1528cea6feb210004ea10567f Mon Sep 17 00:00:00 2001 From: rekhoff Date: Fri, 24 Jan 2025 14:59:10 -0800 Subject: [PATCH 096/195] 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/docs/unity/index.md | 12 +- docs/docs/unity/part-1.md | 2 +- docs/docs/unity/part-2.md | 209 +++++++++++++++++++++++++++-- docs/docs/unity/part-3.md | 273 +++++++++++++++++++++++++++++++++++++- docs/docs/unity/part-4.md | 201 ++++++++++++++++++++++++++-- 5 files changed, 667 insertions(+), 30 deletions(-) diff --git a/docs/docs/unity/index.md b/docs/docs/unity/index.md index 99ed9420bea..e477c3c3206 100644 --- a/docs/docs/unity/index.md +++ b/docs/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/docs/unity/part-1.md b/docs/docs/unity/part-1.md index 7678d0f5fe7..f19a28bf411 100644 --- a/docs/docs/unity/part-1.md +++ b/docs/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/docs/unity/part-2.md b/docs/docs/unity/part-2.md index efa7bdbac04..9e9936c9253 100644 --- a/docs/docs/unity/part-2.md +++ b/docs/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/docs/unity/part-3.md b/docs/docs/unity/part-3.md index 22791698f83..1bfbc51e8aa 100644 --- a/docs/docs/unity/part-3.md +++ b/docs/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/docs/unity/part-4.md b/docs/docs/unity/part-4.md index e179cd4a689..78c9a3cdaa6 100644 --- a/docs/docs/unity/part-4.md +++ b/docs/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 011187d730a0264868c41239136703accdcc34d5 Mon Sep 17 00:00:00 2001 From: rekhoff Date: Mon, 27 Jan 2025 15:45:45 -0800 Subject: [PATCH 097/195] Changed subscript to SubscribeToAllTables (#155) Missed updating the HandleConnect from `Subscribe("SELECT * FROM *"` to `SubscribeToAllTables()` in the updated code block on page 3. --- docs/docs/unity/part-3.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/unity/part-3.md b/docs/docs/unity/part-3.md index 1bfbc51e8aa..ecd1990a301 100644 --- a/docs/docs/unity/part-3.md +++ b/docs/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 2ab7e5e8ed88f81205fa2bca2552076b86765363 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Mon, 27 Jan 2025 19:41:07 -0500 Subject: [PATCH 098/195] 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/docs/sdks/typescript/quickstart.md | 757 ++++++++++++++---------- 1 file changed, 456 insertions(+), 301 deletions(-) diff --git a/docs/docs/sdks/typescript/quickstart.md b/docs/docs/sdks/typescript/quickstart.md index 660b0bc283e..13f04e21ee9 100644 --- a/docs/docs/sdks/typescript/quickstart.md +++ b/docs/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 3c15d8a06c829688c0ebd4c3cb828b7225ee0123 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Tue, 28 Jan 2025 13:18:13 -0500 Subject: [PATCH 099/195] Update Rust client SDK docs for SpacetimeDB#2118 (#130) --- docs/docs/sdks/rust/index.md | 8 ++++---- docs/docs/sdks/rust/quickstart.md | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/docs/sdks/rust/index.md b/docs/docs/sdks/rust/index.md index d8befe53ea4..71d40b5a273 100644 --- a/docs/docs/sdks/rust/index.md +++ b/docs/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/docs/sdks/rust/quickstart.md b/docs/docs/sdks/rust/quickstart.md index e7e3fd3eb79..ced12969fe3 100644 --- a/docs/docs/sdks/rust/quickstart.md +++ b/docs/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 8355ac263002c2758aecef42dc1c1d536fd3f58d Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Tue, 16 Jul 2024 12:39:04 -0700 Subject: [PATCH 100/195] docs(70): The 1.0 SQL spec Closes #70. --- docs/docs/sql/index.md | 591 +++++++++++++++++++++++------------------ 1 file changed, 333 insertions(+), 258 deletions(-) diff --git a/docs/docs/sql/index.md b/docs/docs/sql/index.md index 660972096ae..d2808d3891f 100644 --- a/docs/docs/sql/index.md +++ b/docs/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 f26e9cc9124c3ecf2111e62527045be8e3642e66 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Sat, 8 Feb 2025 00:38:59 -0500 Subject: [PATCH 101/195] Small TS SDK Quickstart Fixes (#157) Updated quickstart url --- docs/docs/sdks/typescript/quickstart.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/sdks/typescript/quickstart.md b/docs/docs/sdks/typescript/quickstart.md index 13f04e21ee9..6d90531779a 100644 --- a/docs/docs/sdks/typescript/quickstart.md +++ b/docs/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 6c11c8609ed914c839b37789026bab42cbdbff0b Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Sat, 8 Feb 2025 00:39:35 -0500 Subject: [PATCH 102/195] 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 --- docs/STYLE.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/STYLE.md b/docs/STYLE.md index 96baef43fd9..1e958e292e3 100644 --- a/docs/STYLE.md +++ b/docs/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 094f6c95c2fd4e4922de0376c2ca404ca385acd5 Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Wed, 12 Feb 2025 08:28:10 -0800 Subject: [PATCH 103/195] Remove WebSocket api docs and all references to them (#165) Closes #164. --------- Co-authored-by: Phoebe Goldman --- docs/docs/http/database.md | 11 +- docs/docs/http/index.md | 2 +- docs/docs/nav.js | 2 - docs/docs/satn.md | 2 +- docs/docs/sql/index.md | 3 +- docs/docs/ws/index.md | 318 ------------------------------------- docs/nav.ts | 3 - 7 files changed, 11 insertions(+), 330 deletions(-) delete mode 100644 docs/docs/ws/index.md diff --git a/docs/docs/http/database.md b/docs/docs/http/database.md index b23701e8d7b..956a0cec39a 100644 --- a/docs/docs/http/database.md +++ b/docs/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/docs/http/index.md b/docs/docs/http/index.md index a59408e409a..3f790b1054b 100644 --- a/docs/docs/http/index.md +++ b/docs/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/docs/nav.js b/docs/docs/nav.js index bdf49517d52..244f92b8ff2 100644 --- a/docs/docs/nav.js +++ b/docs/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/docs/satn.md b/docs/docs/satn.md index 6fb0ee9f2c4..3deb4851ee3 100644 --- a/docs/docs/satn.md +++ b/docs/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/docs/sql/index.md b/docs/docs/sql/index.md index d2808d3891f..09a250f1af1 100644 --- a/docs/docs/sql/index.md +++ b/docs/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/docs/ws/index.md b/docs/docs/ws/index.md deleted file mode 100644 index 1a3780ccb7f..00000000000 --- a/docs/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/docs/nav.ts b/docs/nav.ts index 609a7f0170f..364b8ceaa5f 100644 --- a/docs/nav.ts +++ b/docs/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 860edb405b894394b5899a4a924bdda9d6dd5856 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 104/195] 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 --- docs/README.md | 10 + docs/docs/cli-reference.md | 589 +++++++++++++++++++++++++++++++++++++ docs/docs/nav.js | 2 + docs/nav.ts | 3 + 4 files changed, 604 insertions(+) create mode 100644 docs/docs/cli-reference.md diff --git a/docs/README.md b/docs/README.md index 2165ae6267d..b5c6655155d 100644 --- a/docs/README.md +++ b/docs/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/docs/cli-reference.md b/docs/docs/cli-reference.md new file mode 100644 index 00000000000..8396f50a812 --- /dev/null +++ b/docs/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/docs/nav.js b/docs/docs/nav.js index 244f92b8ff2..9ac2dd5fb08 100644 --- a/docs/docs/nav.js +++ b/docs/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/docs/nav.ts b/docs/nav.ts index 364b8ceaa5f..9cc2f29514e 100644 --- a/docs/nav.ts +++ b/docs/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 fa321015dac5dd2bc554e18e142d2959e6888693 Mon Sep 17 00:00:00 2001 From: Mario Montoya Date: Thu, 20 Feb 2025 11:17:48 -0500 Subject: [PATCH 105/195] Add link to the `cli` (#171) --- docs/docs/sql/index.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/docs/sql/index.md b/docs/docs/sql/index.md index 09a250f1af1..1983cbb16af 100644 --- a/docs/docs/sql/index.md +++ b/docs/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 3bccc079cdcd57af8374ade071b0a451d114385f Mon Sep 17 00:00:00 2001 From: Noa Date: Thu, 20 Feb 2025 23:45:22 -0600 Subject: [PATCH 106/195] Rename satn.md -> sats-json.md (#158) --- docs/docs/bsatn.md | 2 +- docs/docs/http/database.md | 12 ++++++------ docs/docs/nav.js | 2 +- docs/docs/{satn.md => sats-json.md} | 12 +++++++++--- docs/nav.ts | 2 +- 5 files changed, 18 insertions(+), 12 deletions(-) rename docs/docs/{satn.md => sats-json.md} (88%) diff --git a/docs/docs/bsatn.md b/docs/docs/bsatn.md index e8e6d945b57..703e210cf51 100644 --- a/docs/docs/bsatn.md +++ b/docs/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/docs/http/database.md b/docs/docs/http/database.md index 956a0cec39a..1cff2eabaae 100644 --- a/docs/docs/http/database.md +++ b/docs/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/docs/nav.js b/docs/docs/nav.js index 9ac2dd5fb08..3c514f711ee 100644 --- a/docs/docs/nav.js +++ b/docs/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/docs/satn.md b/docs/docs/sats-json.md similarity index 88% rename from docs/docs/satn.md rename to docs/docs/sats-json.md index 3deb4851ee3..d115bad40a1 100644 --- a/docs/docs/satn.md +++ b/docs/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/docs/nav.ts b/docs/nav.ts index 9cc2f29514e..f9a7d42525c 100644 --- a/docs/nav.ts +++ b/docs/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 ec14d20f2c89105dfed6a604b933f4d9f77382b3 Mon Sep 17 00:00:00 2001 From: Mario Montoya Date: Fri, 21 Feb 2025 13:06:57 -0500 Subject: [PATCH 107/195] Fix auto_inc attribute name (#175) --- docs/docs/modules/rust/index.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/docs/modules/rust/index.md b/docs/docs/modules/rust/index.md index dba75ab22a2..305815997d9 100644 --- a/docs/docs/modules/rust/index.md +++ b/docs/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 43a1632bd7d7b29ed6623d5e736a05becc9a1dee Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Mon, 24 Feb 2025 14:08:26 -0800 Subject: [PATCH 108/195] Document LIMIT and COUNT (#178) Closes #177. --- docs/docs/sql/index.md | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/docs/docs/sql/index.md b/docs/docs/sql/index.md index 1983cbb16af..b1f70d9cf14 100644 --- a/docs/docs/sql/index.md +++ b/docs/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 9efb7f055882f6f467be59d0891dcc300647e984 Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Tue, 25 Feb 2025 08:59:34 -0800 Subject: [PATCH 109/195] Add best practices for Spacetime SQL (#180) Closes #179. --- docs/docs/sql/index.md | 137 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) diff --git a/docs/docs/sql/index.md b/docs/docs/sql/index.md index b1f70d9cf14..59e90ca724e 100644 --- a/docs/docs/sql/index.md +++ b/docs/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 9a86ea12dc9d75a556da3f6b58c8b7427fa50c9c Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Tue, 25 Feb 2025 09:57:09 -0800 Subject: [PATCH 110/195] API for mutable subscriptions (#166) Closes #78. Includes rust and csharp examples. --- docs/docs/nav.js | 2 + docs/docs/subscriptions/index.md | 446 +++++++++++++++++++++++++++++++ docs/nav.ts | 3 + 3 files changed, 451 insertions(+) create mode 100644 docs/docs/subscriptions/index.md diff --git a/docs/docs/nav.js b/docs/docs/nav.js index 3c514f711ee..eba9f4960bd 100644 --- a/docs/docs/nav.js +++ b/docs/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/docs/subscriptions/index.md b/docs/docs/subscriptions/index.md new file mode 100644 index 00000000000..a896f6a6db6 --- /dev/null +++ b/docs/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/docs/nav.ts b/docs/nav.ts index f9a7d42525c..53d8c8169b2 100644 --- a/docs/nav.ts +++ b/docs/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 7b0ad990f65108979ec9ea3effb262e520050f6c Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Tue, 25 Feb 2025 10:10:48 -0800 Subject: [PATCH 111/195] Remove references to SpacetimeDB 0.6 Closes #118. --- docs/docs/http/database.md | 6 ------ docs/docs/http/energy.md | 2 -- docs/docs/modules/index.md | 2 +- 3 files changed, 1 insertion(+), 9 deletions(-) diff --git a/docs/docs/http/database.md b/docs/docs/http/database.md index 1cff2eabaae..749bcefb53f 100644 --- a/docs/docs/http/database.md +++ b/docs/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/docs/http/energy.md b/docs/docs/http/energy.md index 6f0083145e9..5eff240ae9a 100644 --- a/docs/docs/http/energy.md +++ b/docs/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/docs/modules/index.md b/docs/docs/modules/index.md index d7d136857f7..93b74cb39ff 100644 --- a/docs/docs/modules/index.md +++ b/docs/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 df263b14319266e44b4bfcb48ace35666eacf6ee Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Tue, 25 Feb 2025 10:17:09 -0800 Subject: [PATCH 112/195] Remove reference to set energy-balance in http api Closes #119. --- docs/docs/http/energy.md | 41 +--------------------------------------- 1 file changed, 1 insertion(+), 40 deletions(-) diff --git a/docs/docs/http/energy.md b/docs/docs/http/energy.md index 5eff240ae9a..fa035c83031 100644 --- a/docs/docs/http/energy.md +++ b/docs/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 53d99af23d178f29e65a0fcd70910e3549adc6f3 Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Tue, 25 Feb 2025 12:03:32 -0800 Subject: [PATCH 113/195] Remove references to testnet Closes #183. --- docs/STYLE.md | 2 +- docs/docs/deploying/testnet.md | 34 ---------------------------------- docs/docs/nav.js | 2 -- docs/nav.ts | 3 --- 4 files changed, 1 insertion(+), 40 deletions(-) delete mode 100644 docs/docs/deploying/testnet.md diff --git a/docs/STYLE.md b/docs/STYLE.md index 1e958e292e3..f0ff5e8cd6d 100644 --- a/docs/STYLE.md +++ b/docs/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/docs/deploying/testnet.md b/docs/docs/deploying/testnet.md deleted file mode 100644 index ce648043b5a..00000000000 --- a/docs/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/docs/nav.js b/docs/docs/nav.js index eba9f4960bd..edd5a169149 100644 --- a/docs/docs/nav.js +++ b/docs/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/docs/nav.ts b/docs/nav.ts index 53d8c8169b2..a0556dd2b40 100644 --- a/docs/nav.ts +++ b/docs/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 7ed444cf98bf15788839f1d76d5e3192d8348898 Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Wed, 26 Feb 2025 09:18:09 -0800 Subject: [PATCH 114/195] Remove 0.12 migration guide --- docs/docs/migration/v0.12.md | 341 ----------------------------------- docs/docs/nav.js | 2 - docs/nav.ts | 3 - 3 files changed, 346 deletions(-) delete mode 100644 docs/docs/migration/v0.12.md diff --git a/docs/docs/migration/v0.12.md b/docs/docs/migration/v0.12.md deleted file mode 100644 index 9384407f3d3..00000000000 --- a/docs/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/docs/nav.js b/docs/docs/nav.js index edd5a169149..930361c4730 100644 --- a/docs/docs/nav.js +++ b/docs/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/docs/nav.ts b/docs/nav.ts index a0556dd2b40..40c9c31ee77 100644 --- a/docs/nav.ts +++ b/docs/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 d41bc73587170a27b9f191a086d89998477d1272 Mon Sep 17 00:00:00 2001 From: rekhoff Date: Wed, 26 Feb 2025 11:32:17 -0800 Subject: [PATCH 115/195] 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/docs/modules/c-sharp/quickstart.md | 99 +++--- docs/docs/sdks/c-sharp/quickstart.md | 395 ++++++++++++++++-------- 2 files changed, 308 insertions(+), 186 deletions(-) diff --git a/docs/docs/modules/c-sharp/quickstart.md b/docs/docs/modules/c-sharp/quickstart.md index 571351c13cc..5dcb703a3f1 100644 --- a/docs/docs/modules/c-sharp/quickstart.md +++ b/docs/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/docs/sdks/c-sharp/quickstart.md b/docs/docs/sdks/c-sharp/quickstart.md index db06d9a4180..759accbe257 100644 --- a/docs/docs/sdks/c-sharp/quickstart.md +++ b/docs/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 8d04c3ef7bd6bdf03e4606ec6d78c9723c13a493 Mon Sep 17 00:00:00 2001 From: james gilles Date: Wed, 26 Feb 2025 14:49:31 -0500 Subject: [PATCH 116/195] Move Rust Module SDK reference to docs.rs (#114) Move rust reference to rustdoc --- docs/docs/modules/rust/index.md | 525 +------------------------------- 1 file changed, 2 insertions(+), 523 deletions(-) diff --git a/docs/docs/modules/rust/index.md b/docs/docs/modules/rust/index.md index 305815997d9..a86819548ae 100644 --- a/docs/docs/modules/rust/index.md +++ b/docs/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 9e8463a59f99084baf49c1718c9247f64d961a55 Mon Sep 17 00:00:00 2001 From: Mazdak Farrokhzad Date: Thu, 27 Feb 2025 16:32:04 +0100 Subject: [PATCH 117/195] Document reducer semantics wrt. transactionality (#185) document reducer semantics wrt. transactionality --- docs/docs/index.md | 99 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 88 insertions(+), 11 deletions(-) diff --git a/docs/docs/index.md b/docs/docs/index.md index 974b543f94a..e04e3055648 100644 --- a/docs/docs/index.md +++ b/docs/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 5943e6fc4f3a48352bcad96637ff6ef366ed8174 Mon Sep 17 00:00:00 2001 From: Mario Montoya Date: Thu, 27 Feb 2025 16:59:49 -0500 Subject: [PATCH 118/195] 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/docs/appendix.md | 61 ++++++++++++++++++++++++++++++ docs/docs/modules/c-sharp/index.md | 5 +++ docs/docs/nav.js | 1 + docs/nav.ts | 1 + 4 files changed, 68 insertions(+) create mode 100644 docs/docs/appendix.md diff --git a/docs/docs/appendix.md b/docs/docs/appendix.md new file mode 100644 index 00000000000..bc184c24546 --- /dev/null +++ b/docs/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/docs/modules/c-sharp/index.md b/docs/docs/modules/c-sharp/index.md index 2c31bb1cb93..40a25e09063 100644 --- a/docs/docs/modules/c-sharp/index.md +++ b/docs/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/docs/nav.js b/docs/docs/nav.js index 930361c4730..1c73dba7adf 100644 --- a/docs/docs/nav.js +++ b/docs/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/docs/nav.ts b/docs/nav.ts index 40c9c31ee77..5093b9e34bc 100644 --- a/docs/nav.ts +++ b/docs/nav.ts @@ -93,6 +93,7 @@ const nav: Nav = { section('Subscriptions'), page('Subscription Reference', 'subscriptions', 'subscriptions/index.md'), + page('Appendix', 'appendix', 'appendix.md'), ], }; From 05f6de944dedde1241ef2582df200a4edffc4ec6 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Fri, 28 Feb 2025 14:25:55 -0500 Subject: [PATCH 119/195] 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/docs/sdks/rust/quickstart.md | 314 +++++++++++++++++------------- 1 file changed, 175 insertions(+), 139 deletions(-) diff --git a/docs/docs/sdks/rust/quickstart.md b/docs/docs/sdks/rust/quickstart.md index ced12969fe3..888782e69c3 100644 --- a/docs/docs/sdks/rust/quickstart.md +++ b/docs/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 7c878795d34f2d57c12f81837d166bcc1a9ce865 Mon Sep 17 00:00:00 2001 From: Noa Date: Fri, 28 Feb 2025 13:32:12 -0600 Subject: [PATCH 120/195] 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/docs/cli-reference/standalone-config.md | 44 ++++++++++++++++++++ docs/docs/nav.js | 1 + docs/nav.ts | 5 +++ 3 files changed, 50 insertions(+) create mode 100644 docs/docs/cli-reference/standalone-config.md diff --git a/docs/docs/cli-reference/standalone-config.md b/docs/docs/cli-reference/standalone-config.md new file mode 100644 index 00000000000..0ce6350dfe5 --- /dev/null +++ b/docs/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/docs/nav.js b/docs/docs/nav.js index 1c73dba7adf..bed99376f9a 100644 --- a/docs/docs/nav.js +++ b/docs/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/docs/nav.ts b/docs/nav.ts index 5093b9e34bc..0b0c10204c4 100644 --- a/docs/nav.ts +++ b/docs/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 f38e72e57fa9438cd5dc53a47a7c5c8e7d0c7fd7 Mon Sep 17 00:00:00 2001 From: Noa Date: Fri, 28 Feb 2025 13:32:33 -0600 Subject: [PATCH 121/195] 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/docs/http/database.md | 612 ++++++++++++++++--------------------- docs/docs/http/energy.md | 35 --- docs/docs/http/identity.md | 72 ++--- docs/docs/http/index.md | 37 +-- docs/docs/nav.js | 1 - docs/docs/sats-json.md | 2 +- docs/nav.ts | 1 - 7 files changed, 292 insertions(+), 468 deletions(-) delete mode 100644 docs/docs/http/energy.md diff --git a/docs/docs/http/database.md b/docs/docs/http/database.md index 749bcefb53f..8a73759c77c 100644 --- a/docs/docs/http/database.md +++ b/docs/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/docs/http/energy.md b/docs/docs/http/energy.md deleted file mode 100644 index fa035c83031..00000000000 --- a/docs/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/docs/http/identity.md b/docs/docs/http/identity.md index 6f1e22c903e..3cec4eb9cc9 100644 --- a/docs/docs/http/identity.md +++ b/docs/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/docs/http/index.md b/docs/docs/http/index.md index 3f790b1054b..64196fb6dc4 100644 --- a/docs/docs/http/index.md +++ b/docs/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/docs/nav.js b/docs/docs/nav.js index bed99376f9a..85697a6891a 100644 --- a/docs/docs/nav.js +++ b/docs/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/docs/sats-json.md b/docs/docs/sats-json.md index d115bad40a1..38f087567de 100644 --- a/docs/docs/sats-json.md +++ b/docs/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/docs/nav.ts b/docs/nav.ts index 0b0c10204c4..2de94ab3438 100644 --- a/docs/nav.ts +++ b/docs/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 fdfe68ae14b1f3760d8ddaa0581e1c32b972fe11 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Mon, 3 Mar 2025 12:58:32 -0500 Subject: [PATCH 122/195] 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 --- docs/STYLE.md | 2 +- docs/docs/index.md | 10 +- docs/docs/modules/c-sharp/quickstart.md | 2 +- docs/docs/modules/rust/quickstart.md | 14 +- docs/docs/sdks/rust/index.md | 547 ++++++++++- docs/docs/sdks/typescript/index.md | 1191 +++++++++++------------ docs/docs/sql/index.md | 4 +- 7 files changed, 1063 insertions(+), 707 deletions(-) diff --git a/docs/STYLE.md b/docs/STYLE.md index f0ff5e8cd6d..4fe1f6766e8 100644 --- a/docs/STYLE.md +++ b/docs/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/docs/index.md b/docs/docs/index.md index e04e3055648..9375f847c01 100644 --- a/docs/docs/index.md +++ b/docs/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/docs/modules/c-sharp/quickstart.md b/docs/docs/modules/c-sharp/quickstart.md index 5dcb703a3f1..e0fbf33e543 100644 --- a/docs/docs/modules/c-sharp/quickstart.md +++ b/docs/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/docs/modules/rust/quickstart.md b/docs/docs/modules/rust/quickstart.md index 9fcfe30d1e7..057b3ad87e7 100644 --- a/docs/docs/modules/rust/quickstart.md +++ b/docs/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/docs/sdks/rust/index.md b/docs/docs/sdks/rust/index.md index 71d40b5a273..a6dd23bb71f 100644 --- a/docs/docs/sdks/rust/index.md +++ b/docs/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/docs/sdks/typescript/index.md b/docs/docs/sdks/typescript/index.md index 34d9edef2be..322443c997a 100644 --- a/docs/docs/sdks/typescript/index.md +++ b/docs/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/docs/sql/index.md b/docs/docs/sql/index.md index 59e90ca724e..807af40923d 100644 --- a/docs/docs/sql/index.md +++ b/docs/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 11b45c1d2285d38149be62ae8ddfe05e78263d16 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Mon, 3 Mar 2025 15:27:33 -0500 Subject: [PATCH 123/195] 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/docs/how-to/incremental-migrations.md | 369 +++++++++++++++++++++ docs/docs/nav.js | 2 + docs/nav.ts | 3 + 3 files changed, 374 insertions(+) create mode 100644 docs/docs/how-to/incremental-migrations.md diff --git a/docs/docs/how-to/incremental-migrations.md b/docs/docs/how-to/incremental-migrations.md new file mode 100644 index 00000000000..3f9106b1745 --- /dev/null +++ b/docs/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/docs/nav.js b/docs/docs/nav.js index 85697a6891a..95b1c67efc4 100644 --- a/docs/docs/nav.js +++ b/docs/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/docs/nav.ts b/docs/nav.ts index 2de94ab3438..97f9dd9de6d 100644 --- a/docs/nav.ts +++ b/docs/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 84acba23a4ea12f0bdd70dec004e5bb627513044 Mon Sep 17 00:00:00 2001 From: rekhoff Date: Mon, 3 Mar 2025 12:46:50 -0800 Subject: [PATCH 124/195] 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/docs/unity/part-3.md | 6 ------ docs/docs/unity/part-4.md | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/docs/docs/unity/part-3.md b/docs/docs/unity/part-3.md index ecd1990a301..f5f49bd414e 100644 --- a/docs/docs/unity/part-3.md +++ b/docs/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/docs/unity/part-4.md b/docs/docs/unity/part-4.md index 78c9a3cdaa6..c42b36295e7 100644 --- a/docs/docs/unity/part-4.md +++ b/docs/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 bc6ecabbb07309a7157e80e8c72662e213abbe12 Mon Sep 17 00:00:00 2001 From: rekhoff Date: Mon, 3 Mar 2025 13:45:08 -0800 Subject: [PATCH 125/195] =?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/docs/modules/c-sharp/quickstart.md | 28 +++++++++++++------------ docs/docs/sdks/c-sharp/quickstart.md | 15 ++++++++----- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/docs/docs/modules/c-sharp/quickstart.md b/docs/docs/modules/c-sharp/quickstart.md index e0fbf33e543..fafcb380193 100644 --- a/docs/docs/modules/c-sharp/quickstart.md +++ b/docs/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/docs/sdks/c-sharp/quickstart.md b/docs/docs/sdks/c-sharp/quickstart.md index 759accbe257..aba4b77c396 100644 --- a/docs/docs/sdks/c-sharp/quickstart.md +++ b/docs/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 12a1fdf0a7b2a9bc95a2a193a4a9ef9d1e940e81 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Mon, 3 Mar 2025 18:27:50 -0500 Subject: [PATCH 126/195] 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/docs/http/index.md | 4 --- docs/docs/index.md | 9 +++--- docs/docs/modules/c-sharp/quickstart.md | 2 -- docs/docs/modules/index.md | 9 ------ docs/docs/modules/rust/quickstart.md | 2 -- docs/docs/nav.js | 26 ++++++++--------- docs/nav.ts | 38 ++++++++++++------------- 7 files changed, 37 insertions(+), 53 deletions(-) diff --git a/docs/docs/http/index.md b/docs/docs/http/index.md index 64196fb6dc4..4f0973dc9b1 100644 --- a/docs/docs/http/index.md +++ b/docs/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/docs/index.md b/docs/docs/index.md index 9375f847c01..864f73274eb 100644 --- a/docs/docs/index.md +++ b/docs/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/docs/modules/c-sharp/quickstart.md b/docs/docs/modules/c-sharp/quickstart.md index fafcb380193..86bcf16f1bb 100644 --- a/docs/docs/modules/c-sharp/quickstart.md +++ b/docs/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/docs/modules/index.md b/docs/docs/modules/index.md index 93b74cb39ff..78d60d9c559 100644 --- a/docs/docs/modules/index.md +++ b/docs/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/docs/modules/rust/quickstart.md b/docs/docs/modules/rust/quickstart.md index 057b3ad87e7..04b7d20650b 100644 --- a/docs/docs/modules/rust/quickstart.md +++ b/docs/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/docs/nav.js b/docs/docs/nav.js index 95b1c67efc4..7c02811ebfc 100644 --- a/docs/docs/nav.js +++ b/docs/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/docs/nav.ts b/docs/nav.ts index 97f9dd9de6d..b8e38b162f6 100644 --- a/docs/nav.ts +++ b/docs/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 ebce4c2d3d7c2536dbe1c09cd5dc81002f625545 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Mon, 3 Mar 2025 18:43:54 -0500 Subject: [PATCH 127/195] Fixes the typescript quickstart for the new subscription API (#161) * Updated quickstart url * DBConnection -> DbConnection for TypeScript SDK * Updated for the subscription update --- docs/docs/sdks/typescript/quickstart.md | 48 ++++++++++++++++--------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/docs/docs/sdks/typescript/quickstart.md b/docs/docs/sdks/typescript/quickstart.md index 6d90531779a..d6f73f33cfd 100644 --- a/docs/docs/sdks/typescript/quickstart.md +++ b/docs/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 8d6899768e34d30b6865fbb1f3451228675da1cb 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 128/195] 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/docs/unity/part-4.md | 75 ++++++++++++++++++++++----------------- 1 file changed, 43 insertions(+), 32 deletions(-) diff --git a/docs/docs/unity/part-4.md b/docs/docs/unity/part-4.md index c42b36295e7..26676126469 100644 --- a/docs/docs/unity/part-4.md +++ b/docs/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 b8c8a0f73866df3140b3624ac07fedf2e404a7c1 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 129/195] 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/docs/deploying/maincloud.md | 30 ++++++++++++++++++++++++++++++ docs/docs/nav.js | 2 ++ docs/nav.ts | 3 +++ docs/scripts/checkLinks.ts | 2 +- 4 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 docs/docs/deploying/maincloud.md diff --git a/docs/docs/deploying/maincloud.md b/docs/docs/deploying/maincloud.md new file mode 100644 index 00000000000..ea14ebbd33c --- /dev/null +++ b/docs/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/docs/nav.js b/docs/docs/nav.js index 7c02811ebfc..aed5805323c 100644 --- a/docs/docs/nav.js +++ b/docs/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/docs/nav.ts b/docs/nav.ts index b8e38b162f6..4de5dae3365 100644 --- a/docs/nav.ts +++ b/docs/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/docs/scripts/checkLinks.ts b/docs/scripts/checkLinks.ts index 58c94f476b6..944f67d2760 100644 --- a/docs/scripts/checkLinks.ts +++ b/docs/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 52b80ef0b56ba38466f8197489e3e03bd787d829 Mon Sep 17 00:00:00 2001 From: james gilles Date: Mon, 3 Mar 2025 19:22:40 -0500 Subject: [PATCH 130/195] 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/docs/modules/c-sharp/index.md | 1467 +++++++++++++++++++++++----- 1 file changed, 1221 insertions(+), 246 deletions(-) diff --git a/docs/docs/modules/c-sharp/index.md b/docs/docs/modules/c-sharp/index.md index 40a25e09063..fc2acc9591a 100644 --- a/docs/docs/modules/c-sharp/index.md +++ b/docs/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 68a8daa6e3a1d75bf144777d6d15b086e3b164fa Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Mon, 3 Mar 2025 16:36:56 -0800 Subject: [PATCH 131/195] C# sdk reference (#191) Closes #192. --- docs/docs/index.md | 2 +- docs/docs/sdks/c-sharp/index.md | 1191 +++++++++++++++---------------- 2 files changed, 592 insertions(+), 601 deletions(-) diff --git a/docs/docs/index.md b/docs/docs/index.md index 864f73274eb..9180ff68e76 100644 --- a/docs/docs/index.md +++ b/docs/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/docs/sdks/c-sharp/index.md b/docs/docs/sdks/c-sharp/index.md index e9c5f23a1ad..16fd2068652 100644 --- a/docs/docs/sdks/c-sharp/index.md +++ b/docs/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 973b62308f452c39df24ab6e6bb0783e717512e3 Mon Sep 17 00:00:00 2001 From: Noa Date: Mon, 3 Mar 2025 19:06:25 -0600 Subject: [PATCH 132/195] Describe how a JWT's sub/iss are translated into an Identity (#204) --- docs/docs/index.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/docs/index.md b/docs/docs/index.md index 9180ff68e76..6e4a0b65f67 100644 --- a/docs/docs/index.md +++ b/docs/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 6cce14ef9384123d2b6199f5021e5715cca8b422 Mon Sep 17 00:00:00 2001 From: rekhoff Date: Mon, 3 Mar 2025 18:21:44 -0800 Subject: [PATCH 133/195] 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/docs/unity/part-2.md | 39 ++++++++++++++++++++++++++------------- docs/docs/unity/part-3.md | 27 +++++++++++++-------------- docs/docs/unity/part-4.md | 20 ++++++++++++++------ 3 files changed, 53 insertions(+), 33 deletions(-) diff --git a/docs/docs/unity/part-2.md b/docs/docs/unity/part-2.md index 9e9936c9253..54c1983a7e9 100644 --- a/docs/docs/unity/part-2.md +++ b/docs/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/docs/unity/part-3.md b/docs/docs/unity/part-3.md index f5f49bd414e..52206f253b5 100644 --- a/docs/docs/unity/part-3.md +++ b/docs/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/docs/unity/part-4.md b/docs/docs/unity/part-4.md index 26676126469..e2b58dd5e97 100644 --- a/docs/docs/unity/part-4.md +++ b/docs/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 a97ca0164f18ad78b52859b5b2313ad9d236f516 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 134/195] Bump Typescript SDK Version in Quickstart (#208) We didn't bump this version number Co-authored-by: John Detter --- docs/docs/sdks/typescript/quickstart.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/sdks/typescript/quickstart.md b/docs/docs/sdks/typescript/quickstart.md index d6f73f33cfd..fcd1fc989ff 100644 --- a/docs/docs/sdks/typescript/quickstart.md +++ b/docs/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 d9ef0a64925fb7cfbf677596b2a891ca447143ef Mon Sep 17 00:00:00 2001 From: Steve Biedermann Date: Tue, 4 Mar 2025 19:34:52 +0100 Subject: [PATCH 135/195] Fix code error and wrong code block (#210) --- docs/docs/modules/rust/quickstart.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/docs/modules/rust/quickstart.md b/docs/docs/modules/rust/quickstart.md index 04b7d20650b..0670bb89b31 100644 --- a/docs/docs/modules/rust/quickstart.md +++ b/docs/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 707446a4d284f5f2d21a0ae9c7a521b9f866a705 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 136/195] 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/docs/deploying/spacetimedb-standalone.md | 240 ++++++++++++++++++ docs/docs/nav.js | 1 + docs/nav.ts | 1 + 3 files changed, 242 insertions(+) create mode 100644 docs/docs/deploying/spacetimedb-standalone.md diff --git a/docs/docs/deploying/spacetimedb-standalone.md b/docs/docs/deploying/spacetimedb-standalone.md new file mode 100644 index 00000000000..34cb8ccfb7b --- /dev/null +++ b/docs/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/docs/nav.js b/docs/docs/nav.js index aed5805323c..e7d0b944941 100644 --- a/docs/docs/nav.js +++ b/docs/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/docs/nav.ts b/docs/nav.ts index 4de5dae3365..fb9689282b8 100644 --- a/docs/nav.ts +++ b/docs/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 990fe06153aa4f43cd97aa85e7d3d621de8746bb 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 137/195] Bump typescript version to 1.0.2 (#213) Bump version to 1.0.2 Co-authored-by: John Detter --- docs/docs/sdks/typescript/quickstart.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/sdks/typescript/quickstart.md b/docs/docs/sdks/typescript/quickstart.md index fcd1fc989ff..4978ddbe888 100644 --- a/docs/docs/sdks/typescript/quickstart.md +++ b/docs/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 cfba6bbffdd0d499640a35968302fe241e95d432 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 138/195] 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/docs/cli-reference.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/cli-reference.md b/docs/docs/cli-reference.md index 8396f50a812..24fb2dedbf3 100644 --- a/docs/docs/cli-reference.md +++ b/docs/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 a0e30f30e1fd92d6a6cbddc28e0dc525cb5d97a3 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 139/195] Remove stale references to removed CLI functions (#218) [bfops/outdated-cli]: Remove stale references to removed CLI functions Co-authored-by: Zeke Foppa --- docs/docs/http/database.md | 4 ---- docs/docs/http/identity.md | 4 ---- 2 files changed, 8 deletions(-) diff --git a/docs/docs/http/database.md b/docs/docs/http/database.md index 8a73759c77c..0ac41070bd9 100644 --- a/docs/docs/http/database.md +++ b/docs/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/docs/http/identity.md b/docs/docs/http/identity.md index 3cec4eb9cc9..f3b68b280e6 100644 --- a/docs/docs/http/identity.md +++ b/docs/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 92d22ccbf2ebb3e25190770a8356d13d9fdd736d Mon Sep 17 00:00:00 2001 From: rekhoff Date: Fri, 7 Mar 2025 10:58:06 -0800 Subject: [PATCH 140/195] Updated to correct old timestamp format. (#221) --- docs/docs/unity/part-4.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/unity/part-4.md b/docs/docs/unity/part-4.md index e2b58dd5e97..d4375f1f819 100644 --- a/docs/docs/unity/part-4.md +++ b/docs/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 35c287dafa3ec3cf93a9fe01faf5ec4bec9de7fe Mon Sep 17 00:00:00 2001 From: Noa Date: Fri, 7 Mar 2025 13:10:42 -0600 Subject: [PATCH 141/195] Clarify what is a valid module name (#252) --- docs/docs/cli-reference.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/docs/cli-reference.md b/docs/docs/cli-reference.md index 24fb2dedbf3..69ebbbd5ccd 100644 --- a/docs/docs/cli-reference.md +++ b/docs/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 9a88da8756764887c39b0ee5f086f038fb8db748 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Mon, 10 Mar 2025 12:11:05 -0400 Subject: [PATCH 142/195] Removed double semi-colon (#254) --- docs/docs/unity/part-4.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/unity/part-4.md b/docs/docs/unity/part-4.md index d4375f1f819..ec67f775565 100644 --- a/docs/docs/unity/part-4.md +++ b/docs/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 521da1dc2ff55170bb0aac20b1c629c187f13497 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 143/195] Explain `spacetime login` (#217) [bfops/login-docs]: do thing Co-authored-by: Zeke Foppa --- docs/docs/getting-started.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/docs/getting-started.md b/docs/docs/getting-started.md index 7afeec31912..466d4cc6805 100644 --- a/docs/docs/getting-started.md +++ b/docs/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 efd9d86707ed122ddb3f9308a705a05137f67878 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Tue, 11 Mar 2025 17:24:55 -0700 Subject: [PATCH 144/195] Updated tutorial for Blackholio/pull/19 (#163) Updated tutorial for https://github.com/clockworklabs/Blackholio/pull/19 Co-authored-by: rekhoff --- docs/docs/unity/part-2.md | 2 +- docs/docs/unity/part-3.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/docs/unity/part-2.md b/docs/docs/unity/part-2.md index 54c1983a7e9..3bb4547639e 100644 --- a/docs/docs/unity/part-2.md +++ b/docs/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/docs/unity/part-3.md b/docs/docs/unity/part-3.md index 52206f253b5..e4aef92c7c1 100644 --- a/docs/docs/unity/part-3.md +++ b/docs/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 be4c2ef72c1a53b4f543d94afcd346c5e2bf4bda Mon Sep 17 00:00:00 2001 From: Mazdak Farrokhzad Date: Wed, 12 Mar 2025 14:08:47 +0100 Subject: [PATCH 145/195] Adjust the BSATN doc to fit reality better (#216) adjust BSATN doc to fit reality better --- docs/docs/bsatn.md | 64 +++++++++++++++++++++++++++++++--------------- 1 file changed, 44 insertions(+), 20 deletions(-) diff --git a/docs/docs/bsatn.md b/docs/docs/bsatn.md index 703e210cf51..2e464b51374 100644 --- a/docs/docs/bsatn.md +++ b/docs/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 3887c479e68612fdbd8c93b79b495b9227bffe28 Mon Sep 17 00:00:00 2001 From: Oliver Davies Date: Mon, 24 Mar 2025 20:24:56 -0700 Subject: [PATCH 146/195] 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/docs/unity/part-4.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/docs/unity/part-4.md b/docs/docs/unity/part-4.md index ec67f775565..9ec81bd5873 100644 --- a/docs/docs/unity/part-4.md +++ b/docs/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 8a0fc18afd608c01801b075894832fee234edc01 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Wed, 2 Apr 2025 13:05:15 -0400 Subject: [PATCH 147/195] 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/docs/sdks/typescript/quickstart.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/sdks/typescript/quickstart.md b/docs/docs/sdks/typescript/quickstart.md index 4978ddbe888..53b9ff05341 100644 --- a/docs/docs/sdks/typescript/quickstart.md +++ b/docs/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 fbf1543c29cdb347aab2cd2e2619a090fd1f9aae 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 148/195] Correction on c# server module quickstart (#253) Table name is 'messages' not 'Messages' --- docs/docs/modules/c-sharp/quickstart.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/modules/c-sharp/quickstart.md b/docs/docs/modules/c-sharp/quickstart.md index 86bcf16f1bb..a41dccae953 100644 --- a/docs/docs/modules/c-sharp/quickstart.md +++ b/docs/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 96aeffa375c21db7845520c9702874b8ce413f05 Mon Sep 17 00:00:00 2001 From: Colter Haycock Date: Fri, 4 Apr 2025 13:15:53 -0600 Subject: [PATCH 149/195] 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/docs/unity/part-2.md | 14 +++++++------- docs/docs/unity/part-3.md | 12 ++++++------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/docs/unity/part-2.md b/docs/docs/unity/part-2.md index 3bb4547639e..d1410cfe6c7 100644 --- a/docs/docs/unity/part-2.md +++ b/docs/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/docs/unity/part-3.md b/docs/docs/unity/part-3.md index e4aef92c7c1..e6d423091ef 100644 --- a/docs/docs/unity/part-3.md +++ b/docs/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 0818aff6bc4f687a3b1ac3636985ea5789539880 Mon Sep 17 00:00:00 2001 From: AdielMag Date: Fri, 4 Apr 2025 22:16:29 +0300 Subject: [PATCH 150/195] Small fix on part-3.md (#264) Update part-3.md --- docs/docs/unity/part-3.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/unity/part-3.md b/docs/docs/unity/part-3.md index e6d423091ef..b31cac119fd 100644 --- a/docs/docs/unity/part-3.md +++ b/docs/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 160cb579725bc28e08254c545e79a84d6109be03 Mon Sep 17 00:00:00 2001 From: cjodo Date: Fri, 4 Apr 2025 13:17:09 -0600 Subject: [PATCH 151/195] fix(ts-sdk): correct the function signature of onConnectError (#255) fix(sdk): correct the function signature of onConnectError --- docs/docs/sdks/typescript/quickstart.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/sdks/typescript/quickstart.md b/docs/docs/sdks/typescript/quickstart.md index 53b9ff05341..df4f4d59238 100644 --- a/docs/docs/sdks/typescript/quickstart.md +++ b/docs/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 fade50a5c633354de850286283061b4b2c8ae0fe 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 152/195] Fix typo (#289) --- docs/docs/sdks/typescript/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/sdks/typescript/index.md b/docs/docs/sdks/typescript/index.md index 322443c997a..532fe9510e9 100644 --- a/docs/docs/sdks/typescript/index.md +++ b/docs/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 dc7ae16c42511a9ba05881676031ff7881476103 Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Wed, 9 Apr 2025 11:50:51 -0700 Subject: [PATCH 153/195] Update the timestamp format output by spacetime sql in the quickstart (#288) Fixes #222. --- docs/docs/modules/c-sharp/quickstart.md | 6 +++--- docs/docs/modules/rust/quickstart.md | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/docs/modules/c-sharp/quickstart.md b/docs/docs/modules/c-sharp/quickstart.md index a41dccae953..9bdb78c9bd2 100644 --- a/docs/docs/modules/c-sharp/quickstart.md +++ b/docs/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/docs/modules/rust/quickstart.md b/docs/docs/modules/rust/quickstart.md index 0670bb89b31..9bbb4b3b519 100644 --- a/docs/docs/modules/rust/quickstart.md +++ b/docs/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 6e15325944ba396b8564f8e865056d5b2a04278c 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 154/195] CI - Add release branch check (#270) [bfops/release-branch-check]: Add release branch check Co-authored-by: Zeke Foppa --- docs/.github/workflows/git-tree-checks.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 docs/.github/workflows/git-tree-checks.yml diff --git a/docs/.github/workflows/git-tree-checks.yml b/docs/.github/workflows/git-tree-checks.yml new file mode 100644 index 00000000000..1166e5264c3 --- /dev/null +++ b/docs/.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 c80417e0c1ddedc4b4538965a7f1c0219884a8d8 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Mon, 14 Apr 2025 09:42:14 -0400 Subject: [PATCH 155/195] 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 --- docs/llms.md | 2280 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 2280 insertions(+) create mode 100644 docs/llms.md diff --git a/docs/llms.md b/docs/llms.md new file mode 100644 index 00000000000..bcdf38b4cbb --- /dev/null +++ b/docs/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 dd829e64de2ea54f44b3032f2ec046bc5f6c36e4 Mon Sep 17 00:00:00 2001 From: 8Times Date: Mon, 14 Apr 2025 17:31:16 +0200 Subject: [PATCH 156/195] Typo fix quickstart.md (#294) "SpacetimDB" -> "SpacetimeDB" --- docs/docs/sdks/rust/quickstart.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/sdks/rust/quickstart.md b/docs/docs/sdks/rust/quickstart.md index 888782e69c3..ea1080ac01e 100644 --- a/docs/docs/sdks/rust/quickstart.md +++ b/docs/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 14b34eb7521c5a7c29eff3bee37af0c0fa212694 Mon Sep 17 00:00:00 2001 From: torjusik Date: Mon, 14 Apr 2025 17:32:52 +0200 Subject: [PATCH 157/195] Update quickstart.md (#295) fixed typo: "Next add let's add" -> "Let's add" --- docs/docs/sdks/typescript/quickstart.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/sdks/typescript/quickstart.md b/docs/docs/sdks/typescript/quickstart.md index df4f4d59238..e567dc3d573 100644 --- a/docs/docs/sdks/typescript/quickstart.md +++ b/docs/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 40d6688262b26078c7d6e5cfe2d38224b98b8e23 Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Mon, 14 Apr 2025 17:30:41 -0700 Subject: [PATCH 158/195] Reference docs for row level security (#291) --- docs/docs/nav.js | 2 + docs/docs/rls/index.md | 303 +++++++++++++++++++++++++++++++++++++++++ docs/nav.ts | 3 + 3 files changed, 308 insertions(+) create mode 100644 docs/docs/rls/index.md diff --git a/docs/docs/nav.js b/docs/docs/nav.js index e7d0b944941..9e5af1520c5 100644 --- a/docs/docs/nav.js +++ b/docs/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/docs/rls/index.md b/docs/docs/rls/index.md new file mode 100644 index 00000000000..a357f96b164 --- /dev/null +++ b/docs/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/docs/nav.ts b/docs/nav.ts index fb9689282b8..4ffa931ea1d 100644 --- a/docs/nav.ts +++ b/docs/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 289396f7b55070af104f82a31d6eeb08c50aec2e Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Mon, 14 Apr 2025 20:33:08 -0400 Subject: [PATCH 159/195] Add convention for table names to style guide (#300) --- docs/STYLE.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/STYLE.md b/docs/STYLE.md index 4fe1f6766e8..d8b8be85426 100644 --- a/docs/STYLE.md +++ b/docs/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 0ba2b784422dbca0810b17964ad11e2244f8b2a3 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 160/195] Fix typo in unity/part-2.md (#298) --- docs/docs/unity/part-2.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/unity/part-2.md b/docs/docs/unity/part-2.md index d1410cfe6c7..84e262b5714 100644 --- a/docs/docs/unity/part-2.md +++ b/docs/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 228436cfa87b759ae39908ea250fdbd89a94c9b4 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 161/195] 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/docs/deploying/maincloud.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/docs/deploying/maincloud.md b/docs/docs/deploying/maincloud.md index ea14ebbd33c..8baff4cc72b 100644 --- a/docs/docs/deploying/maincloud.md +++ b/docs/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 e7cb1ee00f9addfee6b776d48009252e11a9990d Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Wed, 16 Apr 2025 09:58:40 -0400 Subject: [PATCH 162/195] 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/docs/modules/c-sharp/index.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/docs/modules/c-sharp/index.md b/docs/docs/modules/c-sharp/index.md index fc2acc9591a..bc6ef893f14 100644 --- a/docs/docs/modules/c-sharp/index.md +++ b/docs/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 1cb61db0a592c668f7284598351cb2cf33640de3 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 163/195] remove specific programming lang for server module code (#312) in unity tutorial text. There was confusion: https://discord.com/channels/1037340874172014652/1037343189339742298/1361811650713227285 --- docs/docs/unity/part-2.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/unity/part-2.md b/docs/docs/unity/part-2.md index 84e262b5714..5a74b8aeae8 100644 --- a/docs/docs/unity/part-2.md +++ b/docs/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 b74b5a6323391697db81301d0262159d016b9e2c 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 164/195] fix sql identifier casing in c# quickstart test query #220 (#309) From 31c9ee5116c46278a88f03db68a8746acc0b7044 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 165/195] 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 --- docs/STYLE.md | 6 ++--- docs/docs/cli-reference.md | 6 ++--- docs/docs/deploying/spacetimedb-standalone.md | 2 +- docs/docs/http/database.md | 2 +- docs/docs/index.md | 6 ++--- docs/docs/modules/c-sharp/index.md | 6 ++--- docs/docs/modules/c-sharp/quickstart.md | 4 ++-- docs/docs/modules/rust/quickstart.md | 10 ++++---- docs/docs/sdks/c-sharp/index.md | 24 +++++++++---------- docs/docs/sdks/c-sharp/quickstart.md | 18 +++++++------- docs/docs/sdks/index.md | 4 ++-- docs/docs/sdks/rust/index.md | 18 +++++++------- docs/docs/sdks/rust/quickstart.md | 6 ++--- docs/docs/sdks/typescript/index.md | 18 +++++++------- docs/docs/sdks/typescript/quickstart.md | 10 ++++---- docs/docs/unity/part-2.md | 20 ++++++++-------- docs/docs/unity/part-3.md | 6 ++--- docs/docs/unity/part-4.md | 10 ++++---- 18 files changed, 88 insertions(+), 88 deletions(-) diff --git a/docs/STYLE.md b/docs/STYLE.md index d8b8be85426..81de954f7c6 100644 --- a/docs/STYLE.md +++ b/docs/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/docs/cli-reference.md b/docs/docs/cli-reference.md index 69ebbbd5ccd..4da30b2c117 100644 --- a/docs/docs/cli-reference.md +++ b/docs/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/docs/deploying/spacetimedb-standalone.md b/docs/docs/deploying/spacetimedb-standalone.md index 34cb8ccfb7b..49b92c27f2f 100644 --- a/docs/docs/deploying/spacetimedb-standalone.md +++ b/docs/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/docs/http/database.md b/docs/docs/http/database.md index 0ac41070bd9..de4955112a1 100644 --- a/docs/docs/http/database.md +++ b/docs/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/docs/index.md b/docs/docs/index.md index 6e4a0b65f67..a8b671c05d7 100644 --- a/docs/docs/index.md +++ b/docs/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/docs/modules/c-sharp/index.md b/docs/docs/modules/c-sharp/index.md index bc6ef893f14..3deeb2b7c3d 100644 --- a/docs/docs/modules/c-sharp/index.md +++ b/docs/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/docs/modules/c-sharp/quickstart.md b/docs/docs/modules/c-sharp/quickstart.md index 9bdb78c9bd2..72d907e30eb 100644 --- a/docs/docs/modules/c-sharp/quickstart.md +++ b/docs/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/docs/modules/rust/quickstart.md b/docs/docs/modules/rust/quickstart.md index 9bbb4b3b519..9572ed0b52e 100644 --- a/docs/docs/modules/rust/quickstart.md +++ b/docs/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/docs/sdks/c-sharp/index.md b/docs/docs/sdks/c-sharp/index.md index 16fd2068652..3fd4c9b0e06 100644 --- a/docs/docs/sdks/c-sharp/index.md +++ b/docs/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/docs/sdks/c-sharp/quickstart.md b/docs/docs/sdks/c-sharp/quickstart.md index aba4b77c396..44065195fdc 100644 --- a/docs/docs/sdks/c-sharp/quickstart.md +++ b/docs/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/docs/sdks/index.md b/docs/docs/sdks/index.md index 46078cb9114..ad9c082b621 100644 --- a/docs/docs/sdks/index.md +++ b/docs/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/docs/sdks/rust/index.md b/docs/docs/sdks/rust/index.md index a6dd23bb71f..4c180f5266e 100644 --- a/docs/docs/sdks/rust/index.md +++ b/docs/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/docs/sdks/rust/quickstart.md b/docs/docs/sdks/rust/quickstart.md index ea1080ac01e..21f4d9471df 100644 --- a/docs/docs/sdks/rust/quickstart.md +++ b/docs/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/docs/sdks/typescript/index.md b/docs/docs/sdks/typescript/index.md index 532fe9510e9..ef55ed1e61b 100644 --- a/docs/docs/sdks/typescript/index.md +++ b/docs/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/docs/sdks/typescript/quickstart.md b/docs/docs/sdks/typescript/quickstart.md index e567dc3d573..1e1151fff07 100644 --- a/docs/docs/sdks/typescript/quickstart.md +++ b/docs/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/docs/unity/part-2.md b/docs/docs/unity/part-2.md index 5a74b8aeae8..ebfc7a695dd 100644 --- a/docs/docs/unity/part-2.md +++ b/docs/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/docs/unity/part-3.md b/docs/docs/unity/part-3.md index b31cac119fd..4dfb8e24224 100644 --- a/docs/docs/unity/part-3.md +++ b/docs/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/docs/unity/part-4.md b/docs/docs/unity/part-4.md index 9ec81bd5873..7e77fc83238 100644 --- a/docs/docs/unity/part-4.md +++ b/docs/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 b1b4724a049aa025cfb1c4704e45e65aea137688 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 166/195] Remove experimental label of C# #246 (#305) --- docs/docs/modules/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/modules/index.md b/docs/docs/modules/index.md index 78d60d9c559..08d72a92605 100644 --- a/docs/docs/modules/index.md +++ b/docs/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 66561205f53eff76992770ee14ca750c7ee5f270 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 167/195] rename http/index.md to "Authorization" #235 (#308) --- docs/docs/http/{index.md => authorization.md} | 0 docs/docs/http/database.md | 18 +++++++++--------- docs/docs/http/identity.md | 6 +++--- docs/docs/nav.js | 2 +- docs/nav.ts | 2 +- 5 files changed, 14 insertions(+), 14 deletions(-) rename docs/docs/http/{index.md => authorization.md} (100%) diff --git a/docs/docs/http/index.md b/docs/docs/http/authorization.md similarity index 100% rename from docs/docs/http/index.md rename to docs/docs/http/authorization.md diff --git a/docs/docs/http/database.md b/docs/docs/http/database.md index de4955112a1..56273f6b816 100644 --- a/docs/docs/http/database.md +++ b/docs/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/docs/http/identity.md b/docs/docs/http/identity.md index f3b68b280e6..222ac1e9756 100644 --- a/docs/docs/http/identity.md +++ b/docs/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/docs/nav.js b/docs/docs/nav.js index 9e5af1520c5..7f8c0e6d963 100644 --- a/docs/docs/nav.js +++ b/docs/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/docs/nav.ts b/docs/nav.ts index 4ffa931ea1d..62745c81f89 100644 --- a/docs/nav.ts +++ b/docs/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 c7f89fa82deffd1ceb4f63996eb074f037516a53 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 168/195] Revert CLI reference to match what the CLI outputs (#317) [bfops/revert-docs]: revert Co-authored-by: Zeke Foppa --- docs/docs/cli-reference.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/docs/cli-reference.md b/docs/docs/cli-reference.md index 4da30b2c117..69ebbbd5ccd 100644 --- a/docs/docs/cli-reference.md +++ b/docs/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 c0334498de6108604d99900e3efbaa4c18af245c 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 169/195] 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 docs/.github/workflows/check-cli-reference.yml diff --git a/docs/.github/workflows/check-cli-reference.yml b/docs/.github/workflows/check-cli-reference.yml new file mode 100644 index 00000000000..de4f8597618 --- /dev/null +++ b/docs/.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 11aeb5efea5ed0abb236581e02686e36533ba889 Mon Sep 17 00:00:00 2001 From: torjusik Date: Mon, 21 Apr 2025 18:26:15 +0200 Subject: [PATCH 170/195] Fixed typo (#320) multple -> multiple --- docs/docs/sql/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/sql/index.md b/docs/docs/sql/index.md index 807af40923d..2abd225a4da 100644 --- a/docs/docs/sql/index.md +++ b/docs/docs/sql/index.md @@ -379,7 +379,7 @@ DIGIT ; ``` -SATS supports multple fixed width integer types. +SATS supports multiple fixed width integer types. The concrete type of a literal is inferred from the context. #### Examples From 4b48e003b1bf7dc4bebff0762effa443798b199d Mon Sep 17 00:00:00 2001 From: Tamaro Skaljic <49238587+tamaro-skaljic@users.noreply.github.com> Date: Mon, 21 Apr 2025 18:35:53 +0200 Subject: [PATCH 171/195] fix links from rls docs to sql docs on website (#323) Co-authored-by: Zeke Foppa <196249+bfops@users.noreply.github.com> --- docs/docs/rls/index.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/rls/index.md b/docs/docs/rls/index.md index a357f96b164..42f6bf763e0 100644 --- a/docs/docs/rls/index.md +++ b/docs/docs/rls/index.md @@ -292,7 +292,7 @@ For example, it is valid for an RLS rule to have more joins than are supported b 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 +[reference docs]: /docs/sql#subscriptions ## Best Practices @@ -300,4 +300,4 @@ However a client will not be able to subscribe to the table for which that rule 2. Follow the [SQL best practices] for optimizing your RLS rules. -[SQL best practices]: /docs/sql/index.md#best-practices-for-performance-and-scalability +[SQL best practices]: /docs/sql#best-practices-for-performance-and-scalability From 81ef6a0adcaec2610efdf85b13d648d28c9e5f53 Mon Sep 17 00:00:00 2001 From: Jeffrey Dallatezza Date: Mon, 21 Apr 2025 10:44:41 -0700 Subject: [PATCH 172/195] Update docs links to remove index.md (#316) These links are broken in the live docs, but removing index.md from the links fixes them for me. Co-authored-by: Zeke Foppa <196249+bfops@users.noreply.github.com> --- docs/docs/rls/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/rls/index.md b/docs/docs/rls/index.md index 42f6bf763e0..1b72343ee8c 100644 --- a/docs/docs/rls/index.md +++ b/docs/docs/rls/index.md @@ -68,7 +68,7 @@ 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 +[Identity]: /docs/#identity ### Semantic Constraints From b27ed08baa639aca345949e8d792e46ab9df1b13 Mon Sep 17 00:00:00 2001 From: Loki McKay Date: Wed, 23 Apr 2025 23:49:25 +1000 Subject: [PATCH 173/195] Fix typo in quickstart.md (#331) --- docs/docs/modules/rust/quickstart.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/modules/rust/quickstart.md b/docs/docs/modules/rust/quickstart.md index 9572ed0b52e..08e9acd42d2 100644 --- a/docs/docs/modules/rust/quickstart.md +++ b/docs/docs/modules/rust/quickstart.md @@ -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 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 SpacetimeDB'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). From a70339488c8f518fb37a2a89a7646db753a9d5d0 Mon Sep 17 00:00:00 2001 From: Aaron Matthis Date: Wed, 23 Apr 2025 19:41:36 +0200 Subject: [PATCH 174/195] rls docs self-join example: undefined 'u' sql name -> 'a' (#330) fix: undefined 'u' sql name -> 'a' --- docs/docs/rls/index.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/rls/index.md b/docs/docs/rls/index.md index 1b72343ee8c..d1458641428 100644 --- a/docs/docs/rls/index.md +++ b/docs/docs/rls/index.md @@ -208,7 +208,7 @@ use spacetimedb::{client_visibility_filter, Filter}; const PLAYER_FILTER: Filter = Filter::Sql(" SELECT q.* FROM account a - JOIN player p ON u.id = p.id + JOIN player p ON a.id = p.id JOIN player q on p.level = q.level WHERE a.identity = :sender "); @@ -227,7 +227,7 @@ public partial class Module public static readonly Filter PLAYER_FILTER = new Filter.Sql(@" SELECT q.* FROM account a - JOIN player p ON u.id = p.id + JOIN player p ON a.id = p.id JOIN player q on p.level = q.level WHERE a.identity = :sender "); From 3d1fa97c63cdb113cd760337d462edd9407ae811 Mon Sep 17 00:00:00 2001 From: Zeke Foppa <196249+bfops@users.noreply.github.com> Date: Thu, 24 Apr 2025 15:03:41 -0700 Subject: [PATCH 175/195] CI - Expand CI-triggering events (#332) * [bfops/ci-on-master]: Run CI on `master` commits * [bfops/ci-on-master]: tweak --------- Co-authored-by: Zeke Foppa --- docs/.github/workflows/check-cli-reference.yml | 4 ++++ docs/.github/workflows/check-links.yml | 4 ++++ docs/.github/workflows/git-tree-checks.yml | 3 ++- docs/.github/workflows/validate-nav-build.yml | 4 ++++ 4 files changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/.github/workflows/check-cli-reference.yml b/docs/.github/workflows/check-cli-reference.yml index de4f8597618..65a7b031b3d 100644 --- a/docs/.github/workflows/check-cli-reference.yml +++ b/docs/.github/workflows/check-cli-reference.yml @@ -1,5 +1,9 @@ on: pull_request: + push: + branches: + - master + merge_group: workflow_dispatch: inputs: ref: diff --git a/docs/.github/workflows/check-links.yml b/docs/.github/workflows/check-links.yml index 1053fe7df84..ee0c4e63afd 100644 --- a/docs/.github/workflows/check-links.yml +++ b/docs/.github/workflows/check-links.yml @@ -4,6 +4,10 @@ on: pull_request: branches: - master + push: + branches: + - master + merge_group: jobs: check-links: diff --git a/docs/.github/workflows/git-tree-checks.yml b/docs/.github/workflows/git-tree-checks.yml index 1166e5264c3..5e797450e71 100644 --- a/docs/.github/workflows/git-tree-checks.yml +++ b/docs/.github/workflows/git-tree-checks.yml @@ -3,7 +3,8 @@ name: Git tree checks on: pull_request: types: [opened, edited, reopened, synchronize] - merge_group: + branches: + - release permissions: read-all jobs: diff --git a/docs/.github/workflows/validate-nav-build.yml b/docs/.github/workflows/validate-nav-build.yml index b76378d657a..b5b1a85961d 100644 --- a/docs/.github/workflows/validate-nav-build.yml +++ b/docs/.github/workflows/validate-nav-build.yml @@ -4,6 +4,10 @@ on: pull_request: branches: - master + push: + branches: + - master + merge_group: jobs: validate-build: From 8508bbf44685f964040d718ef8623ac5ea72241b Mon Sep 17 00:00:00 2001 From: Jeffrey Dallatezza Date: Fri, 25 Apr 2025 10:20:46 -0700 Subject: [PATCH 176/195] Subscribe to all queries together in the ts quickstart. (#297) --- docs/docs/sdks/typescript/quickstart.md | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/docs/docs/sdks/typescript/quickstart.md b/docs/docs/sdks/typescript/quickstart.md index 1e1151fff07..dd79578b697 100644 --- a/docs/docs/sdks/typescript/quickstart.md +++ b/docs/docs/sdks/typescript/quickstart.md @@ -404,18 +404,12 @@ Add the following to your `App` function, just below `const [newMessage, setNewM 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); - } + conn + ?.subscriptionBuilder() + .onApplied(() => { + console.log('SDK client cache initialized.'); + }) + .subscribe(queries); }; const onConnect = ( From 45a83c9b5aee267d63e452d68e8cb9fe9477f21d Mon Sep 17 00:00:00 2001 From: Chip <36650721+Lethalchip@users.noreply.github.com> Date: Mon, 28 Apr 2025 06:42:46 -0700 Subject: [PATCH 177/195] Correcting Reducer Callback Information in llms.md (#335) * update reducer callback with correct info * remove flavor text - not needed --- docs/llms.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/llms.md b/docs/llms.md index bcdf38b4cbb..16ca54f2e47 100644 --- a/docs/llms.md +++ b/docs/llms.md @@ -2159,19 +2159,25 @@ private registerReducerCallbacks() { this.conn.reducers.onSendMessage(this.handleSendMessageResult); // Register other reducer callbacks if needed - // this.conn.reducers.onSetName(this.handleSetNameResult); + // this.conn.reducers.onSetName(handleSetNameResult); // Note: Consider returning a cleanup function to unregister } private handleSendMessageResult(ctx: ReducerEventContext, messageText: string) { - const wasOurCall = ctx.reducerEvent.callerIdentity.isEqual(this.identity); + const wasOurCall = ctx.event.callerIdentity.isEqual(this.identity); if (!wasOurCall) return; // Only care about our own calls here - if (ctx.reducerEvent.status === Status.Committed) { + switch(ctx.event.status.tag) { + case "Committed": console.log(`Our message "${messageText}" sent successfully.`); - } else if (ctx.reducerEvent.status.isFailed()) { - console.error(`Failed to send "${messageText}": ${ctx.reducerEvent.status.getFailedMessage()}`); + break; + case "Failed": + console.error(`Failed to send "${messageText}": ${ctx.event.status.value}`); + break; + case "OutOfEnergy": + console.error(`Failed to send "${messageText}": Out of Energy!`); + break; } } From 53e0b77f8b726922701e006bedddb7c6335ef157 Mon Sep 17 00:00:00 2001 From: Tamaro Skaljic <49238587+tamaro-skaljic@users.noreply.github.com> Date: Tue, 29 Apr 2025 19:49:53 +0200 Subject: [PATCH 178/195] fix wrong quote usage and copy the "start the server" section from c# quickstart to rust quickstart #41 (#310) Co-authored-by: Zeke Foppa <196249+bfops@users.noreply.github.com> --- docs/docs/modules/rust/quickstart.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/docs/modules/rust/quickstart.md b/docs/docs/modules/rust/quickstart.md index 08e9acd42d2..1bf9ae64a10 100644 --- a/docs/docs/modules/rust/quickstart.md +++ b/docs/docs/modules/rust/quickstart.md @@ -224,11 +224,15 @@ pub fn identity_disconnected(ctx: &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 `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: +From the `quickstart-chat` directory, run in another tab: ```bash spacetime publish --project-path server quickstart-chat @@ -239,7 +243,7 @@ spacetime publish --project-path server quickstart-chat You can use the CLI (command line interface) to run reducers. The arguments to the reducer are passed in JSON format. ```bash -spacetime call quickstart-chat 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. From 0cca3d98df2f282fc2aa2047485df6e81db80596 Mon Sep 17 00:00:00 2001 From: Chip <36650721+Lethalchip@users.noreply.github.com> Date: Wed, 30 Apr 2025 09:13:47 -0700 Subject: [PATCH 179/195] updated dotnet add package (#337) * updated dotnet add package * additional reference to spacetimedbsdk dotnet package --- docs/docs/sdks/c-sharp/index.md | 2 +- docs/docs/sdks/c-sharp/quickstart.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/sdks/c-sharp/index.md b/docs/docs/sdks/c-sharp/index.md index 3fd4c9b0e06..d3e421188da 100644 --- a/docs/docs/sdks/c-sharp/index.md +++ b/docs/docs/sdks/c-sharp/index.md @@ -23,7 +23,7 @@ The SpacetimeDB client for C# contains all the tools you need to build native cl If you would like to create a console application using .NET, you can create a new project using `dotnet new console` and add the SpacetimeDB SDK to your dependencies: ```bash -dotnet add package spacetimedbsdk +dotnet add package SpacetimeDB.ClientSDK ``` (See also the [CSharp Quickstart](/docs/modules/c-sharp/quickstart) for an in-depth example of such a console application.) diff --git a/docs/docs/sdks/c-sharp/quickstart.md b/docs/docs/sdks/c-sharp/quickstart.md index 44065195fdc..381b15a56e5 100644 --- a/docs/docs/sdks/c-sharp/quickstart.md +++ b/docs/docs/sdks/c-sharp/quickstart.md @@ -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 or Rider _NuGet Package Manager_ or via the .NET CLI: +Add the `SpacetimeDB.ClientSDK` [NuGet package](https://www.nuget.org/packages/SpacetimeDB.ClientSDK/) using Visual Studio or Rider _NuGet Package Manager_ or via the .NET CLI: ```bash dotnet add package SpacetimeDB.ClientSDK From ec37594e797204841456fa4e27889750c11624a1 Mon Sep 17 00:00:00 2001 From: Zeke Foppa <196249+bfops@users.noreply.github.com> Date: Thu, 1 May 2025 15:26:02 -0700 Subject: [PATCH 180/195] Fix link checking (#340) * [bfops/check-links]: fix link checker * [bfops/check-links]: fix * [bfops/check-links]: fix broken links * [bfops/check-links]: fix logic * [bfops/check-links]: fix site links --------- Co-authored-by: Zeke Foppa --- docs/docs/modules/c-sharp/index.md | 4 +-- docs/docs/sql/index.md | 10 +++--- docs/docs/webassembly-abi/index.md | 4 +-- docs/package.json | 5 ++- docs/scripts/checkLinks.ts | 50 +++++++++++++----------------- 5 files changed, 35 insertions(+), 38 deletions(-) diff --git a/docs/docs/modules/c-sharp/index.md b/docs/docs/modules/c-sharp/index.md index 3deeb2b7c3d..e965ea9dd46 100644 --- a/docs/docs/modules/c-sharp/index.md +++ b/docs/docs/modules/c-sharp/index.md @@ -223,9 +223,9 @@ However: # Tables -Tables are declared using the [`[SpacetimeDB.Table]` attribute](#table-attribute). +Tables are declared using the `[SpacetimeDB.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). +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]`]( #attribute-spacetimedbtype). 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. diff --git a/docs/docs/sql/index.md b/docs/docs/sql/index.md index 2abd225a4da..a138ac8b2b5 100644 --- a/docs/docs/sql/index.md +++ b/docs/docs/sql/index.md @@ -640,9 +640,9 @@ 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 +[sdk]: /docs/sdks/rust#subscribe-to-queries +[http]: /docs/http/database#post-v1databasename_or_identitysql +[cli]: /docs/cli-reference#spacetime-sql -[Identity]: /docs/index.md#identity -[ConnectionId]: /docs/index.md#connectionid +[Identity]: /docs#identity +[ConnectionId]: /docs#connectionid diff --git a/docs/docs/webassembly-abi/index.md b/docs/docs/webassembly-abi/index.md index ceccfbd1772..de24635546b 100644 --- a/docs/docs/webassembly-abi/index.md +++ b/docs/docs/webassembly-abi/index.md @@ -493,7 +493,7 @@ uint16_t _iter_start_filtered( [`bindings_sys::raw`]: https://github.com/clockworklabs/SpacetimeDB/blob/master/crates/bindings-sys/src/lib.rs#L44-L215 [`bindings`]: https://github.com/clockworklabs/SpacetimeDB/blob/master/crates/bindings/src/lib.rs -[module_ref]: /docs/languages/rust/rust-module-reference -[module_quick_start]: /docs/languages/rust/rust-module-quick-start +[module_ref]: /docs/modules/rust +[module_quick_start]: /docs/modules/rust/quickstart [wasm_c_abi]: https://github.com/WebAssembly/tool-conventions/blob/main/BasicCABI.md [c_header]: #appendix-bindingsh diff --git a/docs/package.json b/docs/package.json index c96b785bada..6aa3861d283 100644 --- a/docs/package.json +++ b/docs/package.json @@ -8,8 +8,11 @@ }, "devDependencies": { "@types/node": "^22.10.2", + "remark-parse": "^11.0.0", "tsx": "^4.19.2", - "typescript": "^5.3.2" + "typescript": "^5.3.2", + "unified": "^11.0.5", + "unist-util-visit": "^5.0.0" }, "scripts": { "build": "tsc --project ./tsconfig.json", diff --git a/docs/scripts/checkLinks.ts b/docs/scripts/checkLinks.ts index 944f67d2760..b7d4ead7258 100644 --- a/docs/scripts/checkLinks.ts +++ b/docs/scripts/checkLinks.ts @@ -2,6 +2,9 @@ import fs from 'fs'; import path from 'path'; import nav from '../nav'; // Import the nav object directly import GitHubSlugger from 'github-slugger'; +import { unified } from 'unified'; +import remarkParse from 'remark-parse'; +import { visit } from 'unist-util-visit'; // Function to map slugs to file paths from nav.ts function extractSlugToPathMap(nav: { items: any[] }): Map { @@ -33,34 +36,22 @@ function validatePathsExist(slugToPath: Map): void { // 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; // 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 content = fs.readFileSync(filePath, 'utf-8'); + const tree = unified().use(remarkParse).parse(content); - 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); - } + const results: { link: string; type: 'image' | 'link'; line: number }[] = []; - // Extract standard links - while ((match = linkRegex.exec(lineContent)) !== null) { - const link = match[2]; - linksAndImages.push({ link, type: 'link', line: index + 1 }); + visit(tree, ['link', 'image', 'definition'], (node: any) => { + const link = node.url; + const line = node.position?.start?.line ?? 0; + if (link) { + results.push({ link, type: node.type === 'image' ? 'image' : 'link', line }); } }); - // Filter out links that exist as images - return linksAndImages.filter(item => !(item.type === 'link' && imageSet.has(item.link))); + return results; } + // Function to resolve relative links using slugs function resolveLink(link: string, currentSlug: string): string { if (link.startsWith('#')) { @@ -101,6 +92,9 @@ function checkLinks(): void { // Extract valid slugs const validSlugs = Array.from(slugToPath.keys()); + // Hacky workaround because the slug for the root is /docs/index. No other slugs have a /index at the end. + validSlugs.push('/docs'); + // Reverse map from file path to slug for current file resolution const pathToSlug = new Map(); slugToPath.forEach((filePath, slug) => { @@ -124,11 +118,8 @@ function checkLinks(): void { return; // Skip external links } - const siteLinks = ['/install', '/images', '/profile']; - for (const siteLink of siteLinks) { - if (link.startsWith(siteLink)) { - return; // Skip site links - } + if (!link.startsWith('/docs')) { + return; // Skip site links } // Resolve the link @@ -149,7 +140,10 @@ function checkLinks(): void { } // Split the resolved link into base and fragment - const [baseLink, fragmentRaw] = resolvedLink.split('#'); + let [baseLink, fragmentRaw] = resolvedLink.split('#'); + if (baseLink.endsWith('/')) { + baseLink = baseLink.slice(0, -1); + } const fragment: string | null = fragmentRaw || null; if (fragment) { From 694bc483d96d843fbb0fa9ef12a03de1b6daa455 Mon Sep 17 00:00:00 2001 From: Robin Curbelo Date: Fri, 2 May 2025 13:01:01 -0400 Subject: [PATCH 181/195] fix typo (#339) --- docs/docs/sdks/typescript/quickstart.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/sdks/typescript/quickstart.md b/docs/docs/sdks/typescript/quickstart.md index dd79578b697..af1b0d5e250 100644 --- a/docs/docs/sdks/typescript/quickstart.md +++ b/docs/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` database created in the above module quickstart guides. +By the end of this introduction, 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 From 6b4433ba79a9e7333ed7945442c5ac6bca101c1f Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Mon, 5 May 2025 09:25:57 -0400 Subject: [PATCH 182/195] Add "Subscription Semantics" page (#278) We've gotten several questions in the public Discord about the semantics of subscriptions in the SDKs. I did a brain-dump about them, Tyler fed it into ChatGPT, I touched up the result a bit, and here it is. Some day we probably want to rewrite this to read less like AI slop. But for now it's probably fine. --- docs/docs/nav.js | 1 + docs/docs/subscriptions/semantics.md | 84 ++++++++++++++++++++++++++++ docs/nav.ts | 1 + 3 files changed, 86 insertions(+) create mode 100644 docs/docs/subscriptions/semantics.md diff --git a/docs/docs/nav.js b/docs/docs/nav.js index 7f8c0e6d963..3082ea7a702 100644 --- a/docs/docs/nav.js +++ b/docs/docs/nav.js @@ -39,6 +39,7 @@ const nav = { page('SQL Reference', 'sql', 'sql/index.md'), section('Subscriptions'), page('Subscription Reference', 'subscriptions', 'subscriptions/index.md'), + page('Subscription Semantics', 'subscriptions/semantics', 'subscriptions/semantics.md'), section('Row Level Security'), page('Row Level Security', 'rls', 'rls/index.md'), section('How To'), diff --git a/docs/docs/subscriptions/semantics.md b/docs/docs/subscriptions/semantics.md new file mode 100644 index 00000000000..dd2a17db71d --- /dev/null +++ b/docs/docs/subscriptions/semantics.md @@ -0,0 +1,84 @@ + + +# 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 the 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. **Transaction Results in a State Delta:** + - The result of a transaction is a state delta, i.e. an unordered set of inserted and deleted rows. + +2. **Host Evaluates Queries:** + - Evaluates the QUERIES against the state delta to determine matching altered rows. + +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` callbacks for modified rows. + - Reducer callbacks, if the transaction was the result of a reducer. + > **Note:** No relative ordering guarantees are made regarding the invocation order of these callbacks. + +## 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 + +While processing a `TransactionUpdate` message, callbacks are queued within the SDK 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. diff --git a/docs/nav.ts b/docs/nav.ts index 62745c81f89..7dff8490531 100644 --- a/docs/nav.ts +++ b/docs/nav.ts @@ -89,6 +89,7 @@ const nav: Nav = { section('Subscriptions'), page('Subscription Reference', 'subscriptions', 'subscriptions/index.md'), + page('Subscription Semantics', 'subscriptions/semantics', 'subscriptions/semantics.md'), section('Row Level Security'), page('Row Level Security', 'rls', 'rls/index.md'), From f469676ade5263386a6f15567fa818684373bb8f Mon Sep 17 00:00:00 2001 From: John Detter <4099508+jdetter@users.noreply.github.com> Date: Tue, 6 May 2025 01:19:13 +0100 Subject: [PATCH 183/195] Fix blocking publish route (#338) * Fix blocking publish route * Added common routes * Default to open - which was the previous functionality * Update spacetimedb-standalone.md * Update spacetimedb-standalone.md * Updated with support for the typescript SDK * Updated with known good subscribe route * Updated doc text * Clarified comment * nit --- docs/docs/deploying/spacetimedb-standalone.md | 54 ++++++++++++++++--- 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/docs/docs/deploying/spacetimedb-standalone.md b/docs/docs/deploying/spacetimedb-standalone.md index 49b92c27f2f..db738dd8d73 100644 --- a/docs/docs/deploying/spacetimedb-standalone.md +++ b/docs/docs/deploying/spacetimedb-standalone.md @@ -82,7 +82,24 @@ server { listen 80; server_name example.com; - location / { + ######################################### + # By default SpacetimeDB is completely open so that anyone can publish to it. If you want to block + # users from creating new databases you should keep this section commented out. Otherwise, if you + # want to open it up (probably for dev environments) then you can uncomment this section and then + # also comment out the location / section below. + ######################################### + # 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; + # } + + # Anyone can subscribe to any database. + # Note: This is the only section *required* for the websocket to function properly. Clients will + # be able to create identities, call reducers, and subscribe to tables through this websocket. + location ~ ^/v1/database/[^/]+/subscribe$ { proxy_pass http://localhost:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; @@ -90,21 +107,44 @@ server { 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; + # Uncomment this section to allow all HTTP reducer calls + # location ~ ^/v1/[^/]+/call/[^/]+$ { + # 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; + # } + + # Uncomment this section to allow all HTTP sql requests + # location ~ ^/v1/[^/]+/sql$ { + # 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; + # } + + # NOTE: This is required for the typescript sdk to function, it is optional + # for the rust and the C# SDKs. + location /v1/identity { 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; } + + # Block all other routes explicitly. Only localhost can use these routes. If you want to open your + # server up so that anyone can publish to it you should comment this section out. + location / { + allow 127.0.0.1; + deny all; + } } ``` -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. +This configuration by default blocks all connections other than `/v1/identity` and `/v1/database//subscribe` which only allows the most basic functionality. This will prevent all remote users from publishing to your SpacetimeDB instance. Enable the configuration: From 51883b12d739bed0c9724544a3f409e89f780399 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Tue, 6 May 2025 13:23:58 -0400 Subject: [PATCH 184/195] Add directions for navigating to the dashboard (#326) Update maincloud.md --- docs/docs/deploying/maincloud.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/deploying/maincloud.md b/docs/docs/deploying/maincloud.md index 8baff4cc72b..66130fdcbe2 100644 --- a/docs/docs/deploying/maincloud.md +++ b/docs/docs/deploying/maincloud.md @@ -23,7 +23,7 @@ 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). +1. You should now be able to see your published modules https://spacetimedb.com/profile, or you can navigate to your database directly at https://spacetimedb.com/my-cool-module. --- From ed1808de3eb077b6f8601de220f75efff425ea99 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Tue, 6 May 2025 13:25:08 -0400 Subject: [PATCH 185/195] Fixed a typo with acquainted (#322) --- docs/docs/unity/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/unity/index.md b/docs/docs/unity/index.md index e477c3c3206..d31423fc26a 100644 --- a/docs/docs/unity/index.md +++ b/docs/docs/unity/index.md @@ -2,7 +2,7 @@ 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](https://github.com/ClockworkLabs/Blackholio). +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 acquainted 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. From b230a92a4ccce02b380babe43a57320a7eb9f34b Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Tue, 6 May 2025 13:34:52 -0400 Subject: [PATCH 186/195] Now noting that StdbModule.csproj is important (#327) Update index.md --- docs/docs/modules/c-sharp/index.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/docs/modules/c-sharp/index.md b/docs/docs/modules/c-sharp/index.md index e965ea9dd46..294ec4889fa 100644 --- a/docs/docs/modules/c-sharp/index.md +++ b/docs/docs/modules/c-sharp/index.md @@ -88,6 +88,8 @@ This creates a `dotnet` project in `my-project-directory` with the following `St ``` +> NOTE: It is important to not change the `StdbModule.csproj` name because SpacetimeDB assumes that this will be the name of the file. + 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. From be1c2e100bfdb508e8d5ed1dea238c7d275c2e31 Mon Sep 17 00:00:00 2001 From: Chip <36650721+Lethalchip@users.noreply.github.com> Date: Tue, 6 May 2025 12:23:27 -0700 Subject: [PATCH 187/195] Stronger wording to direct users to working unity versions (#334) * Some wording changes to the required unity versions we've had a few people utilize Unity 2021.2, but have a lot of errors/issues in the console. It's best to direct users to 2022.3.32f1 onward. * Removed recommended verbiage --- docs/docs/unity/index.md | 4 ++-- docs/docs/unity/part-1.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/docs/unity/index.md b/docs/docs/unity/index.md index d31423fc26a..007721bcb26 100644 --- a/docs/docs/unity/index.md +++ b/docs/docs/unity/index.md @@ -12,12 +12,12 @@ Our game, called [Blackhol.io](https://github.com/ClockworkLabs/Blackholio), wil 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. -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. +SpacetimeDB supports Unity version `2022.3.32f1` or later, and this tutorial has been tested with the following Unity versions: - `2022.3.32f1 LTS` - `6000.0.33f1` -Please file an issue [here](https://github.com/clockworklabs/spacetime-docs/issues) 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, but please be aware that the SpacetimeDB team is unable to offer support for issues related to versions of Unity prior to `2022.3.32f1 LTS`. ## Blackhol.io Tutorial - Basic Multiplayer diff --git a/docs/docs/unity/part-1.md b/docs/docs/unity/part-1.md index f19a28bf411..230bb95aed0 100644 --- a/docs/docs/unity/part-1.md +++ b/docs/docs/unity/part-1.md @@ -30,7 +30,7 @@ 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. +SpacetimeDB supports Unity version `2022.3.32f1` or later. 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**. From e8a0dfddb0809be41a119177ffc5c4473613e542 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Saldanha=20Streibel?= Date: Wed, 7 May 2025 18:21:57 -0300 Subject: [PATCH 188/195] Fixed typo in quickstart.md (#344) --- docs/docs/modules/c-sharp/quickstart.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/modules/c-sharp/quickstart.md b/docs/docs/modules/c-sharp/quickstart.md index 72d907e30eb..09213dad980 100644 --- a/docs/docs/modules/c-sharp/quickstart.md +++ b/docs/docs/modules/c-sharp/quickstart.md @@ -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 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 SpacetimeDB'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). From 7528afe3c6ce1f281492511d10ab53bfa631262e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Saldanha=20Streibel?= Date: Fri, 9 May 2025 09:40:49 -0300 Subject: [PATCH 189/195] Fixed DB_NAME name mismatch (#346) The doc text suggested DB_NAME for variable name, but code sample used DBNAME. Changed all to DB_NAME, for consistency. Thanks @gefjon on Issue #345. --- docs/docs/sdks/c-sharp/quickstart.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/sdks/c-sharp/quickstart.md b/docs/docs/sdks/c-sharp/quickstart.md index 381b15a56e5..974eecbb246 100644 --- a/docs/docs/sdks/c-sharp/quickstart.md +++ b/docs/docs/sdks/c-sharp/quickstart.md @@ -141,7 +141,7 @@ To `Program.cs`, add: const string HOST = "http://localhost:3000"; /// The database name we chose when we published our module. -const string DBNAME = "quickstart-chat"; +const string DB_NAME = "quickstart-chat"; /// Load credentials from a file and connect to the database. DbConnection ConnectToDB() @@ -149,7 +149,7 @@ DbConnection ConnectToDB() DbConnection? conn = null; conn = DbConnection.Builder() .WithUri(HOST) - .WithModuleName(DBNAME) + .WithModuleName(DB_NAME) .WithToken(AuthToken.Token) .OnConnect(OnConnected) .OnConnectError(OnConnectError) From 5d4cce6c49daa4866e5d7b1713f011e9d6ef4139 Mon Sep 17 00:00:00 2001 From: Tamaro Skaljic <49238587+tamaro-skaljic@users.noreply.github.com> Date: Fri, 16 May 2025 18:42:10 +0200 Subject: [PATCH 190/195] Document sql reserved identifiers #266 (#302) Co-authored-by: Zeke Foppa <196249+bfops@users.noreply.github.com> --- docs/docs/sql/index.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/docs/sql/index.md b/docs/docs/sql/index.md index a138ac8b2b5..66c3528ba61 100644 --- a/docs/docs/sql/index.md +++ b/docs/docs/sql/index.md @@ -469,6 +469,9 @@ 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. +Because SpacetimeDB uses a postgres compatible parser, identifiers which are +reserved in postgres are automatically reserved in Spacetime SQL. See [SQL Key Words in the PostgreSQL documentation](https://www.postgresql.org/docs/current/sql-keywords-appendix.html). + ### Example ```sql From f3a7e8cc939cec53db5eddac50e9224ef37e6e0b Mon Sep 17 00:00:00 2001 From: Sahil Dawka <37510491+sdawka@users.noreply.github.com> Date: Fri, 16 May 2025 13:00:39 -0400 Subject: [PATCH 191/195] Remove double code-lang buttons (#347) Nested the lang specific text into the overall codeblock instead of duplicating below. --- docs/docs/index.md | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/docs/docs/index.md b/docs/docs/index.md index a8b671c05d7..2d8840b402f 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -188,6 +188,9 @@ pub fn world(ctx: &spacetimedb::ReducerContext) -> Result<(), String> { clear_all_tables(ctx); } ``` +While SpacetimeDB doesn't support nested transactions, +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-csharp ```csharp @@ -207,19 +210,12 @@ public static void World(ReducerContext ctx) // ... } ``` -::: - -:::server-rust -While SpacetimeDB doesn't support nested transactions, -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-csharp While SpacetimeDB doesn't support nested transactions, a reducer can [schedule another reducer](/docs/modules/c-sharp#scheduled-reducers) to run at an interval, 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 [connection id](#connectionid) to identify the connection. After that, it can call [reducers](#reducer) and query public [tables](#table). From 313fdd22a3af1a51dd3aa7cb35bb1c0147b454b9 Mon Sep 17 00:00:00 2001 From: Wes Sleeman Date: Thu, 22 May 2025 06:49:49 -0700 Subject: [PATCH 192/195] RLS Documentation - Invalid SQL Query (#351) Added missing required column identifier to simple query RLS documentation examples. --- docs/docs/rls/index.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/docs/rls/index.md b/docs/docs/rls/index.md index d1458641428..7273d97b9c8 100644 --- a/docs/docs/rls/index.md +++ b/docs/docs/rls/index.md @@ -33,7 +33,7 @@ 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" + "SELECT * FROM account WHERE account.identity = :sender" ); ``` ::: @@ -52,7 +52,7 @@ public partial class Module /// [SpacetimeDB.ClientVisibilityFilter] public static readonly Filter ACCOUNT_FILTER = new Filter.Sql( - "SELECT * FROM account WHERE identity = :sender" + "SELECT * FROM account WHERE account.identity = :sender" ); } ``` @@ -90,7 +90,7 @@ 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" + "SELECT * FROM account WHERE account.identity = :sender" ); /// An admin can see all accounts @@ -113,7 +113,7 @@ public partial class Module /// [SpacetimeDB.ClientVisibilityFilter] public static readonly Filter ACCOUNT_FILTER = new Filter.Sql( - "SELECT * FROM account WHERE identity = :sender" + "SELECT * FROM account WHERE account.identity = :sender" ); /// @@ -141,7 +141,7 @@ 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" + "SELECT * FROM account WHERE account.identity = :sender" ); /// An admin can see all accounts @@ -170,7 +170,7 @@ public partial class Module /// [SpacetimeDB.ClientVisibilityFilter] public static readonly Filter ACCOUNT_FILTER = new Filter.Sql( - "SELECT * FROM account WHERE identity = :sender" + "SELECT * FROM account WHERE account.identity = :sender" ); /// From 0584e75081dbf5d4600e000551fcb82d1a182a18 Mon Sep 17 00:00:00 2001 From: Carlos Cobo <699969+toqueteos@users.noreply.github.com> Date: Tue, 3 Jun 2025 18:31:50 +0200 Subject: [PATCH 193/195] Fix TypeScript Quickstart wrong import (#349) --- docs/docs/sdks/typescript/quickstart.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/sdks/typescript/quickstart.md b/docs/docs/sdks/typescript/quickstart.md index af1b0d5e250..30898706376 100644 --- a/docs/docs/sdks/typescript/quickstart.md +++ b/docs/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, ErrorContext, EventContext, Message, User } from './module_bindings'; +import { DbConnection, type ErrorContext, type EventContext, Message, User } from './module_bindings'; import { Identity } from '@clockworklabs/spacetimedb-sdk'; ``` From 4c470f057954fa46ca6c39627454207b26fdcfe6 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Mon, 14 Jul 2025 15:34:02 -0400 Subject: [PATCH 194/195] Fix example that was still rust-ey in C# ref (#333) * Fix example that was still rust-ey in C# ref This must have gotten missed when porting the Rust ref over to C#. * Read `.Value` Co-authored-by: james gilles --------- Co-authored-by: james gilles --- docs/docs/modules/c-sharp/index.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/docs/modules/c-sharp/index.md b/docs/docs/modules/c-sharp/index.md index 294ec4889fa..209a83e583a 100644 --- a/docs/docs/modules/c-sharp/index.md +++ b/docs/docs/modules/c-sharp/index.md @@ -482,12 +482,12 @@ public static partial class Module { [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 exampleUser = idIndex.Find(357).Value; + exampleUser.DogCount += 5; + idIndex.Update(exampleUser); var usernameIndex = ctx.Db.user.Username; - usernameIndex.delete("Evil Bob"); + usernameIndex.Delete("Evil Bob"); } } ``` From eecc591ef19f4228b30d88e65d8ce675701260a6 Mon Sep 17 00:00:00 2001 From: Kim Altintop Date: Wed, 16 Jul 2025 21:05:38 +0200 Subject: [PATCH 195/195] Document WebSocket config for standalone (#353) --- docs/docs/cli-reference/standalone-config.md | 38 ++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/docs/docs/cli-reference/standalone-config.md b/docs/docs/cli-reference/standalone-config.md index 0ce6350dfe5..0dd0a1502c8 100644 --- a/docs/docs/cli-reference/standalone-config.md +++ b/docs/docs/cli-reference/standalone-config.md @@ -42,3 +42,41 @@ Can be one of `"error"`, `"warn"`, `"info"`, `"debug"`, `"trace"`, or `"off"`, c #### `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. + +### `websocket` + +```toml +[websocket] +ping-interval = "15s" +idle-timeout = "30s" +close-handshake-timeout = "250ms" +incoming-queue-length = 2048 +``` + +#### `websocket.ping-interval` + +Interval at which the server will send `Ping` frames to keep the connection alive. +Should be smaller than `websocket.idle-timeout` to be effective. + +Values are strings of any format the [`humantime`] crate can parse. + +#### `websocket.idle-timeout` + +If the server hasn't received any data from the client (including `Pong` responses to previous `Ping`s it sent), it will consider the client unresponsive and close the connection. +Should be greater than `websocket.ping-interval` to be effective. + +Values are strings of any format the [`humantime`] crate can parse. + +#### `websocket.close-handshake-timeout` + +Time the server waits for the client to respond to a graceful connection close. If the client doesn't respond within this timeout, the connection is dropped. + +Values are strings of any format the [`humantime`] crate can parse. + +#### `websocket.incoming-queue-length` + +Maximum number of client messages the server will queue up in case it is not able to process them quickly enough. When the queue length exceeds this value, the server will start disconnecting clients. +Note that the limit is per client, not across all clients of a particular database. + + +[`humantime`]: https://crates.io/crates/humantime