diff --git a/.cursor/rules/spacetimedb.md b/.cursor/rules/spacetimedb.md new file mode 100644 index 00000000..2bf12e8d --- /dev/null +++ b/.cursor/rules/spacetimedb.md @@ -0,0 +1,4 @@ +- Before implementing SpacetimeDB code, reference the LLMs text and ensure you are using the proper syntax. +- If you aren't certain about the SpacetimeDB implementation, ask the user +- You're executing commands in Powershell, so make sure if you want to execute multiple commands to use ; instead of && +- All your commands that you execute start from the project directory, so make sure you take that into account when you're navigating directories. \ No newline at end of file diff --git a/.github/workflows/check-cli-reference.yml b/.github/workflows/check-cli-reference.yml new file mode 100644 index 00000000..de4f8597 --- /dev/null +++ b/.github/workflows/check-cli-reference.yml @@ -0,0 +1,48 @@ +on: + pull_request: + workflow_dispatch: + inputs: + ref: + description: 'SpacetimeDB ref' + required: false + default: '' +permissions: read-all + +name: Check CLI docs + +jobs: + cli_docs: + runs-on: ubuntu-latest + steps: + - name: Find Git ref + shell: bash + run: | + echo "GIT_REF=${{ github.event.inputs.ref || 'master' }}" >>"$GITHUB_ENV" + - name: Checkout sources + uses: actions/checkout@v4 + with: + repository: clockworklabs/SpacetimeDB + ref: ${{ env.GIT_REF }} + - uses: dsherret/rust-toolchain-file@v1 + - name: Checkout docs + uses: actions/checkout@v4 + with: + path: spacetime-docs + - name: Check for docs change + run: | + cargo run --features markdown-docs -p spacetimedb-cli > ../spacetime-docs/docs/cli-reference.md + cd spacetime-docs + # This is needed because our website doesn't render markdown quite properly. + # See the README in spacetime-docs for more details. + sed -i'' -E 's!^(##) `(.*)`$!\1 \2!' docs/cli-reference.md + sed -i'' -E 's!^(######) \*\*(.*)\*\*$!\1 \2!' docs/cli-reference.md + git status + if git diff --exit-code HEAD; then + echo "No docs changes detected" + else + echo "It looks like the CLI docs have changed." + echo "These docs are expected to match exactly the helptext generated by the CLI in SpacetimeDB (${{env.GIT_REF}})." + echo "Once a corresponding change has merged in SpacetimeDB, re-run this check." + echo "See https://github.com/clockworklabs/spacetime-docs/#cli-reference-section for more info on how to generate these docs from SpacetimeDB." + exit 1 + fi diff --git a/.github/workflows/check-links.yml b/.github/workflows/check-links.yml new file mode 100644 index 00000000..1053fe7d --- /dev/null +++ b/.github/workflows/check-links.yml @@ -0,0 +1,26 @@ +name: Check Link Validity in Documentation + +on: + pull_request: + branches: + - master + +jobs: + check-links: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: '16' # or the version of Node.js you're using + + - name: Install dependencies + run: | + npm install + + - name: Run link check + run: | + npm run check-links diff --git a/.github/workflows/git-tree-checks.yml b/.github/workflows/git-tree-checks.yml new file mode 100644 index 00000000..1166e526 --- /dev/null +++ b/.github/workflows/git-tree-checks.yml @@ -0,0 +1,22 @@ +name: Git tree checks + +on: + pull_request: + types: [opened, edited, reopened, synchronize] + merge_group: +permissions: read-all + +jobs: + check_base_ref: + name: Release branch restriction + runs-on: ubuntu-latest + steps: + - if: | + github.event_name == 'pull_request' && + github.event.pull_request.base.ref == 'release' && + ! startsWith(github.event.pull_request.head.ref, 'release-') + run: | + echo 'Only `release-*` branches are allowed to merge into the release branch `release`.' + echo 'Are you **sure** that you want to merge into release?' + echo 'Is this **definitely** just cherrypicking commits that are already in `master`?' + exit 1 diff --git a/.github/workflows/validate-nav-build.yml b/.github/workflows/validate-nav-build.yml new file mode 100644 index 00000000..b76378d6 --- /dev/null +++ b/.github/workflows/validate-nav-build.yml @@ -0,0 +1,40 @@ +name: Validate nav.ts Matches nav.js + +on: + pull_request: + branches: + - master + +jobs: + validate-build: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: '16' + + - name: Install dependencies + run: | + npm install + + - name: Backup existing nav.js + run: | + mv docs/nav.js docs/nav.js.original + + - name: Build nav.ts + run: | + npm run build + + - name: Compare generated nav.js with original nav.js + run: | + diff -q docs/nav.js docs/nav.js.original || (echo "Generated nav.js differs from committed version. Run 'npm run build' and commit the updated file." && exit 1) + + - name: Restore original nav.js + if: success() || failure() + run: | + mv docs/nav.js.original docs/nav.js diff --git a/.gitignore b/.gitignore index 55f71abd..d839abde 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,4 @@ .idea *.log node_modules -dist \ No newline at end of file +.DS_store diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..2921455b --- /dev/null +++ b/.prettierrc @@ -0,0 +1,11 @@ +{ + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": true, + "arrowParens": "avoid", + "jsxSingleQuote": false, + "trailingComma": "es5", + "endOfLine": "auto", + "printWidth": 80 +} diff --git a/LICENSE.txt b/LICENSE.txt index dd5b3a58..d9a10c0d 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -172,3 +172,5 @@ defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/README.md b/README.md index cfe1e0af..b5c66551 100644 --- a/README.md +++ b/README.md @@ -13,13 +13,14 @@ 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 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 @@ -29,6 +30,22 @@ 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. + +#### 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). + ## License This documentation repository is licensed under Apache 2.0. See LICENSE.txt for more details. diff --git a/STYLE.md b/STYLE.md new file mode 100644 index 00000000..81de954f --- /dev/null +++ b/STYLE.md @@ -0,0 +1,412 @@ +# 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. + +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: +> +> ```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. +> For example: +> +> ```rust +> ctx.db.people().name().find("Billy") +> ``` +> +> 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. + +### 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. + +### 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. + +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. + +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 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 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 + +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 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. + +> 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). + +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 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. + +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). diff --git a/docs/Client SDK Languages/C#/SDK Reference.md b/docs/Client SDK Languages/C#/SDK Reference.md deleted file mode 100644 index 3284e6fe..00000000 --- a/docs/Client SDK Languages/C#/SDK Reference.md +++ /dev/null @@ -1,932 +0,0 @@ -# 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/Client SDK Languages/C#/_category.json b/docs/Client SDK Languages/C#/_category.json deleted file mode 100644 index 60238f8e..00000000 --- a/docs/Client SDK Languages/C#/_category.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "title": "C#", - "disabled": false, - "index": "index.md" -} \ No newline at end of file diff --git a/docs/Client SDK Languages/C#/index.md b/docs/Client SDK Languages/C#/index.md deleted file mode 100644 index b64ca13d..00000000 --- a/docs/Client SDK Languages/C#/index.md +++ /dev/null @@ -1,425 +0,0 @@ -# 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/Client SDK Languages/Python/SDK Reference.md b/docs/Client SDK Languages/Python/SDK Reference.md deleted file mode 100644 index 8cd4b4ca..00000000 --- a/docs/Client SDK Languages/Python/SDK Reference.md +++ /dev/null @@ -1,525 +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 [`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/Client SDK Languages/Python/_category.json b/docs/Client SDK Languages/Python/_category.json deleted file mode 100644 index 4e08cfa1..00000000 --- a/docs/Client SDK Languages/Python/_category.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "title": "Python", - "disabled": false, - "index": "index.md" -} \ No newline at end of file diff --git a/docs/Client SDK Languages/Python/index.md b/docs/Client SDK Languages/Python/index.md deleted file mode 100644 index 52630452..00000000 --- a/docs/Client SDK Languages/Python/index.md +++ /dev/null @@ -1,377 +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/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/Client SDK Languages/Rust/SDK Reference.md b/docs/Client SDK Languages/Rust/SDK Reference.md deleted file mode 100644 index c61a06f3..00000000 --- a/docs/Client SDK Languages/Rust/SDK Reference.md +++ /dev/null @@ -1,1153 +0,0 @@ -# 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/Client SDK Languages/Rust/_category.json b/docs/Client SDK Languages/Rust/_category.json deleted file mode 100644 index 6280366c..00000000 --- a/docs/Client SDK Languages/Rust/_category.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "title": "Rust", - "disabled": false, - "index": "index.md" -} \ No newline at end of file diff --git a/docs/Client SDK Languages/Rust/index.md b/docs/Client SDK Languages/Rust/index.md deleted file mode 100644 index c44ab49d..00000000 --- a/docs/Client SDK Languages/Rust/index.md +++ /dev/null @@ -1,483 +0,0 @@ -# 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/Client SDK Languages/Typescript/SDK Reference.md b/docs/Client SDK Languages/Typescript/SDK Reference.md deleted file mode 100644 index 657115d7..00000000 --- a/docs/Client SDK Languages/Typescript/SDK Reference.md +++ /dev/null @@ -1,805 +0,0 @@ -# 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/Client SDK Languages/Typescript/_category.json b/docs/Client SDK Languages/Typescript/_category.json deleted file mode 100644 index 590d44a2..00000000 --- a/docs/Client SDK Languages/Typescript/_category.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "title": "Typescript", - "disabled": false, - "index": "index.md" -} \ No newline at end of file diff --git a/docs/Client SDK Languages/Typescript/index.md b/docs/Client SDK Languages/Typescript/index.md deleted file mode 100644 index ae893af5..00000000 --- a/docs/Client SDK Languages/Typescript/index.md +++ /dev/null @@ -1,500 +0,0 @@ -# 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/Client SDK Languages/_category.json b/docs/Client SDK Languages/_category.json deleted file mode 100644 index 530c17aa..00000000 --- a/docs/Client SDK Languages/_category.json +++ /dev/null @@ -1 +0,0 @@ -{"title":"Client SDK Languages","disabled":false,"index":"index.md"} \ No newline at end of file diff --git a/docs/Cloud Testnet/_category.json b/docs/Cloud Testnet/_category.json deleted file mode 100644 index e6fa11b9..00000000 --- a/docs/Cloud Testnet/_category.json +++ /dev/null @@ -1 +0,0 @@ -{"title":"Cloud Testnet","disabled":false,"index":"index.md"} \ No newline at end of file diff --git a/docs/Cloud Testnet/index.md b/docs/Cloud Testnet/index.md deleted file mode 100644 index abb90fb8..00000000 --- a/docs/Cloud Testnet/index.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 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/Getting Started/_category.json b/docs/Getting Started/_category.json deleted file mode 100644 index a68dc36c..00000000 --- a/docs/Getting Started/_category.json +++ /dev/null @@ -1 +0,0 @@ -{"title":"Getting Started","disabled":false,"index":"index.md"} \ No newline at end of file diff --git a/docs/Getting Started/index.md b/docs/Getting Started/index.md deleted file mode 100644 index 854d227c..00000000 --- a/docs/Getting Started/index.md +++ /dev/null @@ -1,36 +0,0 @@ -# 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/HTTP API Reference/Databases.md b/docs/HTTP API Reference/Databases.md deleted file mode 100644 index 91e7d0a9..00000000 --- a/docs/HTTP API Reference/Databases.md +++ /dev/null @@ -1,589 +0,0 @@ -# `/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/HTTP API Reference/Energy.md b/docs/HTTP API Reference/Energy.md deleted file mode 100644 index a7b6d05a..00000000 --- a/docs/HTTP API Reference/Energy.md +++ /dev/null @@ -1,76 +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 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/HTTP API Reference/Identities.md b/docs/HTTP API Reference/Identities.md deleted file mode 100644 index 87411759..00000000 --- a/docs/HTTP API Reference/Identities.md +++ /dev/null @@ -1,160 +0,0 @@ -# `/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/HTTP API Reference/_category.json b/docs/HTTP API Reference/_category.json deleted file mode 100644 index c8ad821b..00000000 --- a/docs/HTTP API Reference/_category.json +++ /dev/null @@ -1 +0,0 @@ -{"title":"HTTP API Reference","disabled":false,"index":"index.md"} \ No newline at end of file diff --git a/docs/HTTP API Reference/index.md b/docs/HTTP API Reference/index.md deleted file mode 100644 index 224aaf77..00000000 --- a/docs/HTTP API Reference/index.md +++ /dev/null @@ -1,51 +0,0 @@ -# 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/Module ABI Reference/_category.json b/docs/Module ABI Reference/_category.json deleted file mode 100644 index 7583598d..00000000 --- a/docs/Module ABI Reference/_category.json +++ /dev/null @@ -1 +0,0 @@ -{"title":"Module ABI Reference","disabled":false,"index":"index.md"} \ No newline at end of file diff --git a/docs/Overview/_category.json b/docs/Overview/_category.json deleted file mode 100644 index 35164a50..00000000 --- a/docs/Overview/_category.json +++ /dev/null @@ -1 +0,0 @@ -{"title":"Overview","disabled":false,"index":"index.md"} \ No newline at end of file diff --git a/docs/Overview/index.md b/docs/Overview/index.md deleted file mode 100644 index 2464e6e3..00000000 --- a/docs/Overview/index.md +++ /dev/null @@ -1,114 +0,0 @@ -# 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/SATN Reference/_category.json b/docs/SATN Reference/_category.json deleted file mode 100644 index e26b2f05..00000000 --- a/docs/SATN Reference/_category.json +++ /dev/null @@ -1 +0,0 @@ -{"title":"SATN Reference","disabled":false,"index":"index.md"} \ No newline at end of file diff --git a/docs/SQL Reference/_category.json b/docs/SQL Reference/_category.json deleted file mode 100644 index 73d7df23..00000000 --- a/docs/SQL Reference/_category.json +++ /dev/null @@ -1 +0,0 @@ -{"title":"SQL Reference","disabled":false,"index":"index.md"} \ No newline at end of file diff --git a/docs/SQL Reference/index.md b/docs/SQL Reference/index.md deleted file mode 100644 index 08f9536a..00000000 --- a/docs/SQL Reference/index.md +++ /dev/null @@ -1,407 +0,0 @@ -# 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/Server Module Languages/C#/ModuleReference.md b/docs/Server Module Languages/C#/ModuleReference.md deleted file mode 100644 index 305ea211..00000000 --- a/docs/Server Module Languages/C#/ModuleReference.md +++ /dev/null @@ -1,311 +0,0 @@ -# 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/Server Module Languages/C#/_category.json b/docs/Server Module Languages/C#/_category.json deleted file mode 100644 index 71ae9015..00000000 --- a/docs/Server Module Languages/C#/_category.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "title": "C#", - "disabled": false, - "index": "index.md", - "tag": "Expiremental" -} \ No newline at end of file diff --git a/docs/Server Module Languages/C#/index.md b/docs/Server Module Languages/C#/index.md deleted file mode 100644 index e849002f..00000000 --- a/docs/Server Module Languages/C#/index.md +++ /dev/null @@ -1,292 +0,0 @@ -# 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/Server Module Languages/Rust/ModuleReference.md b/docs/Server Module Languages/Rust/ModuleReference.md deleted file mode 100644 index 05d62bdc..00000000 --- a/docs/Server Module Languages/Rust/ModuleReference.md +++ /dev/null @@ -1,454 +0,0 @@ -# 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/Server Module Languages/Rust/_category.json b/docs/Server Module Languages/Rust/_category.json deleted file mode 100644 index 6280366c..00000000 --- a/docs/Server Module Languages/Rust/_category.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "title": "Rust", - "disabled": false, - "index": "index.md" -} \ No newline at end of file diff --git a/docs/Server Module Languages/Rust/index.md b/docs/Server Module Languages/Rust/index.md deleted file mode 100644 index ed59d8dd..00000000 --- a/docs/Server Module Languages/Rust/index.md +++ /dev/null @@ -1,272 +0,0 @@ -# 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/Server Module Languages/_category.json b/docs/Server Module Languages/_category.json deleted file mode 100644 index 3bfa0e87..00000000 --- a/docs/Server Module Languages/_category.json +++ /dev/null @@ -1 +0,0 @@ -{"title":"Server Module Languages","disabled":false,"index":"index.md"} \ No newline at end of file diff --git a/docs/Server Module Languages/index.md b/docs/Server Module Languages/index.md deleted file mode 100644 index d6668131..00000000 --- a/docs/Server Module Languages/index.md +++ /dev/null @@ -1,30 +0,0 @@ -# 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/Unity Tutorial/Part 1 - Basic Multiplayer.md b/docs/Unity Tutorial/Part 1 - Basic Multiplayer.md deleted file mode 100644 index 92f1a04c..00000000 --- a/docs/Unity Tutorial/Part 1 - Basic Multiplayer.md +++ /dev/null @@ -1,917 +0,0 @@ -# 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/Unity Tutorial/Part 2 - Resources And Scheduling.md b/docs/Unity Tutorial/Part 2 - Resources And Scheduling.md deleted file mode 100644 index 5cd205ef..00000000 --- a/docs/Unity Tutorial/Part 2 - Resources And Scheduling.md +++ /dev/null @@ -1,255 +0,0 @@ -# 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/Unity Tutorial/Part 3 - BitCraft Mini.md b/docs/Unity Tutorial/Part 3 - BitCraft Mini.md deleted file mode 100644 index e1f5e3eb..00000000 --- a/docs/Unity Tutorial/Part 3 - BitCraft Mini.md +++ /dev/null @@ -1,102 +0,0 @@ -# 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/Unity Tutorial/_category.json b/docs/Unity Tutorial/_category.json deleted file mode 100644 index a3c837ad..00000000 --- a/docs/Unity Tutorial/_category.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "title": "Unity Tutorial", - "disabled": false, - "index": "Part 1 - Basic Multiplayer.md" -} \ No newline at end of file diff --git a/docs/WebSocket API Reference/_category.json b/docs/WebSocket API Reference/_category.json deleted file mode 100644 index d2797306..00000000 --- a/docs/WebSocket API Reference/_category.json +++ /dev/null @@ -1 +0,0 @@ -{"title":"WebSocket API Reference","disabled":false,"index":"index.md"} \ No newline at end of file diff --git a/docs/WebSocket API Reference/index.md b/docs/WebSocket API Reference/index.md deleted file mode 100644 index dd8fbc39..00000000 --- a/docs/WebSocket API Reference/index.md +++ /dev/null @@ -1,322 +0,0 @@ -# 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/appendix.md b/docs/appendix.md new file mode 100644 index 00000000..bc184c24 --- /dev/null +++ b/docs/appendix.md @@ -0,0 +1,61 @@ +# Appendix + +## SEQUENCE + +For each table containing an `#[auto_inc]` column, SpacetimeDB creates a sequence number generator behind the scenes, which functions similarly to `postgres`'s `SEQUENCE`. + +### How It Works + +* Sequences in SpacetimeDB use Rust’s `i128` integer type. +* The field type marked with `#[auto_inc]` is cast to `i128` and increments by `1` for each new row. +* Sequences are pre-allocated in chunks of `4096` to speed up number generation, and then are only persisted to disk when the pre-allocated chunk is exhausted. + +> **⚠ Warning:** Sequence number generation is not transactional. + +* Numbers are incremented even if a transaction is later rolled back. +* Unused numbers are not reclaimed, meaning sequences may have *gaps*. +* If the server restarts or a transaction rolls back, the sequence continues from the next pre-allocated chunk + `1`: + +**Example:** + +```rust +#[spacetimedb::table(name = users, public)] +struct Users { + #[auto_inc] + user_id: u64, + name: String, +} + +#[spacetimedb::reducer] +pub fn insert_user(ctx: &ReducerContext, count: u8) { + for i in 0..count { + let name = format!("User {}", i); + ctx.db.users().insert(Users { user_id: 0, name }); + } + // Query the table to see the effect of the `[auto_inc]` attribute: + for user in ctx.db.users().iter() { + log::info!("User: {:?}", user); + } +} +``` + +Then: + +```bash +❯ cargo run --bin spacetimedb-cli call sample insert_user 3 + +❯ spacetimedb-cli logs sample +... +.. User: Users { user_id: 1, name: "User 0" } +.. User: Users { user_id: 2, name: "User 1" } +.. User: Users { user_id: 3, name: "User 2" } + +# Database restart, then + +❯ cargo run --bin spacetimedb-cli call sample insert_user 1 + +❯ spacetimedb-cli logs sample +... +.. User: Users { user_id: 3, name: "User 2" } +.. User: Users { user_id: 4098, name: "User 0" } +``` \ No newline at end of file diff --git a/docs/SATN Reference/Binary Format.md b/docs/bsatn.md similarity index 54% rename from docs/SATN Reference/Binary Format.md rename to docs/bsatn.md index 0da55ce7..2e464b51 100644 --- a/docs/SATN Reference/Binary Format.md +++ b/docs/bsatn.md @@ -1,4 +1,4 @@ -# SATN Binary Format (BSATN) +# Binary SATN Format (BSATN) The Spacetime Algebraic Type Notation binary (BSATN) format defines how Spacetime `AlgebraicValue`s and friends are encoded as byte strings. @@ -24,34 +24,44 @@ 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. | ### `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 [`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 @@ -60,56 +70,64 @@ 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 [`BuiltinType`](#builtintype). -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(Map) +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) -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` +- `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 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/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/cli-reference.md b/docs/cli-reference.md new file mode 100644 index 00000000..69ebbbd5 --- /dev/null +++ b/docs/cli-reference.md @@ -0,0 +1,592 @@ +# 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. + + Database names must match the regex `/^[a-z0-9]+(-[a-z0-9]+)*$/`, + i.e. only lowercase ASCII letters and numbers, separated by dashes. + +###### 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='--lint-dir=' + + 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='--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). + + + +## 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/cli-reference/standalone-config.md b/docs/cli-reference/standalone-config.md new file mode 100644 index 00000000..0ce6350d --- /dev/null +++ b/docs/cli-reference/standalone-config.md @@ -0,0 +1,44 @@ +# `spacetimedb-standalone` configuration + +A local database instance (as started by `spacetime start`) can be configured in `{data-dir}/config.toml`, where `{data-dir}` is the database's data directory. This directory is printed when you run `spacetime start`: + + +
spacetimedb-standalone version: 1.0.0
+spacetimedb-standalone path: /home/user/.local/share/spacetime/bin/1.0.0/spacetimedb-standalone
+database running in data directory /home/user/.local/share/spacetime/data
+ +On Linux and macOS, this directory is by default `~/.local/share/spacetime/data`. On Windows, it's `%LOCALAPPDATA%\SpacetimeDB\data`. + +## `config.toml` + +- [`certificate-authority`](#certificate-authority) +- [`logs`](#logs) + +### `certificate-authority` + +```toml +[certificate-authority] +jwt-priv-key-path = "/path/to/id_ecdsas" +jwt-pub-key-path = "/path/to/id_ecdsas.pub" +``` + +The `certificate-authority` table lets you configure the public and private keys used by the database to sign tokens. + +### `logs` + +```toml +[logs] +level = "error" +directives = [ + "spacetimedb=warn", + "spacetimedb_standalone=info", +] +``` + +#### `logs.level` + +Can be one of `"error"`, `"warn"`, `"info"`, `"debug"`, `"trace"`, or `"off"`, case-insensitive. Only log messages of the specified level or higher will be output; e.g. if set to `warn`, only `error` and `warn`-level messages will be logged. + +#### `logs.directives` + +A list of filtering directives controlling what messages get logged, which overwrite the global [`logs.level`](#logslevel). See [`tracing documentation`](https://docs.rs/tracing-subscriber/0.3/tracing_subscriber/filter/struct.EnvFilter.html#directives) for syntax. Note that this is primarily intended as a debugging tool, and log message fields and targets are not considered stable. diff --git a/docs/deploying/maincloud.md b/docs/deploying/maincloud.md new file mode 100644 index 00000000..8baff4cc --- /dev/null +++ b/docs/deploying/maincloud.md @@ -0,0 +1,51 @@ +# 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. + +# 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') +``` diff --git a/docs/deploying/spacetimedb-standalone.md b/docs/deploying/spacetimedb-standalone.md new file mode 100644 index 00000000..49b92c27 --- /dev/null +++ b/docs/deploying/spacetimedb-standalone.md @@ -0,0 +1,240 @@ +# Self Hosting SpacetimeDB + +This tutorial will guide you through setting up SpacetimeDB on an Ubuntu 24.04 server, securing it with HTTPS using Nginx and Let's Encrypt, and configuring a systemd service to keep it running. + +## Prerequisites +- A fresh Ubuntu 24.04 server (VM or cloud instance of your choice) +- A domain name (e.g., `example.com`) +- `sudo` privileges on the server + +## Step 1: Create a Dedicated User for SpacetimeDB +For security purposes, create a dedicated `spacetimedb` user to run SpacetimeDB: + +```sh +sudo mkdir /stdb +sudo useradd --system spacetimedb +sudo chown -R spacetimedb:spacetimedb /stdb +``` + +Install SpacetimeDB as the new user: + +```sh +sudo -u spacetimedb bash -c 'curl -sSf https://install.spacetimedb.com | sh -s -- --root-dir /stdb --yes' +``` + +## Step 2: Create a Systemd Service for SpacetimeDB +To ensure SpacetimeDB runs on startup, create a systemd service file: + +```sh +sudo nano /etc/systemd/system/spacetimedb.service +``` + +Add the following content: + +```ini +[Unit] +Description=SpacetimeDB Server +After=network.target + +[Service] +ExecStart=/stdb/spacetime --root-dir=/stdb start --listen-addr='127.0.0.1:3000' +Restart=always +User=spacetimedb +WorkingDirectory=/stdb + +[Install] +WantedBy=multi-user.target +``` + +Enable and start the service: + +```sh +sudo systemctl enable spacetimedb +sudo systemctl start spacetimedb +``` + +Check the status: + +```sh +sudo systemctl status spacetimedb +``` + +## Step 3: Install and Configure Nginx + +### Install Nginx + +```sh +sudo apt update +sudo apt install nginx -y +``` + +### Configure Nginx Reverse Proxy +Create a new Nginx configuration file: + +```sh +sudo nano /etc/nginx/sites-available/spacetimedb +``` + +Add the following configuration, remember to change `example.com` to your own domain: + +```nginx +server { + listen 80; + server_name example.com; + + location / { + proxy_pass http://localhost:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_set_header Host $host; + } + + # This restricts who can publish new databases to your SpacetimeDB instance. We recommend + # restricting this ability to local connections. + location /v1/publish { + allow 127.0.0.1; + deny all; + proxy_pass http://localhost:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_set_header Host $host; + } +} +``` + +This configuration contains a restriction to the `/v1/publish` route. This restriction makes it so that you can only publish to the database if you're publishing from a local connection on the host. + +Enable the configuration: + +```sh +sudo ln -s /etc/nginx/sites-available/spacetimedb /etc/nginx/sites-enabled/ +``` + +Restart Nginx: + +```sh +sudo systemctl restart nginx +``` + +### Configure Firewall +Ensure your firewall allows HTTPS traffic: + +```sh +sudo ufw allow 'Nginx Full' +sudo ufw reload +``` + +## Step 4: Secure with Let's Encrypt + +### Install Certbot + +```sh +sudo apt install certbot python3-certbot-nginx -y +``` + +### Obtain an SSL Certificate + +Run this command to request a new SSL cert from Let's Encrypt. Remember to replace `example.com` with your own domain: + +```sh +sudo certbot --nginx -d example.com +``` + +Certbot will automatically configure SSL for Nginx. Restart Nginx to apply changes: + +```sh +sudo systemctl restart nginx +``` + +### Auto-Renew SSL Certificates +Certbot automatically installs a renewal timer. Verify that it is active: + +```sh +sudo systemctl status certbot.timer +``` + +## Step 5: Verify Installation + +On your local machine, add this new server to your CLI config. Make sure to replace `example.com` with your own domain: + +```bash +spacetime server add self-hosted --url https://example.com +``` + +If you have uncommented the `/v1/publish` restriction in Step 3 then you won't be able to publish to this instance unless you copy your module to the host first and then publish. We recommend something like this: + +```bash +spacetime build +scp target/wasm32-unknown-unknown/release/spacetime_module.wasm ubuntu@:/home/ubuntu/ +ssh ubuntu@ spacetime publish -s local --bin-path spacetime_module.wasm +``` + +You could put the above commands into a shell script to make publishing to your server easier and faster. It's also possible to integrate a script like this into Github Actions to publish on some event (like a PR merging into master). + +## Step 6: Updating SpacetimeDB Version +To update SpacetimeDB to the latest version, first stop the service: + +```sh +sudo systemctl stop spacetimedb +``` + +Then upgrade SpacetimeDB: + +```sh +sudo -u spacetimedb -i -- spacetime --root-dir=/stdb version upgrade +``` + +To install a specific version, use: + +```sh +sudo -u spacetimedb -i -- spacetime --root-dir=/stdb install +``` + +Finally, restart the service: + +```sh +sudo systemctl start spacetimedb +``` + +## Step 7: Troubleshooting + +### SpacetimeDB Service Fails to Start +Check the logs for errors: + +```sh +sudo journalctl -u spacetimedb --no-pager | tail -20 +``` + +Verify that the `spacetimedb` user has the correct permissions: + +```sh +sudo ls -lah /stdb/spacetime +``` + +If needed, add the executable permission: + +```sh +sudo chmod +x /stdb/spacetime +``` + +### Let's Encrypt Certificate Renewal Issues +Manually renew the certificate and check for errors: + +```sh +sudo certbot renew --dry-run +``` + +### Nginx Fails to Start +Test the configuration: + +```sh +sudo nginx -t +``` + +If errors are found, check the logs: + +```sh +sudo journalctl -u nginx --no-pager | tail -20 +``` diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 00000000..466d4cc6 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,43 @@ +# Getting Started + +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: + +```bash +spacetime start +``` + +The server listens on port `3000` by default, customized via `--listen-addr`. + +💡 Standalone mode will run in the foreground. +⚠️ SSL is not supported in standalone mode. + +## What's Next? + +### 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) + +- [Rust](/docs/modules/rust/quickstart) +- [C#](/docs/modules/c-sharp/quickstart) + +⚡**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# (Standalone)](/docs/sdks/c-sharp/quickstart) +- [C# (Unity)](/docs/unity/part-1) +- [Typescript](/docs/sdks/typescript/quickstart) diff --git a/docs/how-to/incremental-migrations.md b/docs/how-to/incremental-migrations.md new file mode 100644 index 00000000..3f9106b1 --- /dev/null +++ b/docs/how-to/incremental-migrations.md @@ -0,0 +1,369 @@ +# Incremental Migrations + +SpacetimeDB does not provide built-in support for general schema-modifying migrations. It does, however, allow adding new tables, and changing reducers' definitions in arbitrary ways. It's possible to run general migrations using an external tool, but this is tedious, necessitates downtime, and imposes the requirement that you update all your clients at the same time as publishing your new module version. + +Our friends at [Lightfox Games](https://www.lightfoxgames.com/) taught us a pattern they call "incremental migrations," which mitigates all these problems, and works perfectly with SpacetimeDB's capabilities. The short version is that, instead of altering an existing table, you add a new table with the desired new schema. Whenever your module wants to access a row from that table, it first checks the new table. If the row is present in the new table, then you've already migrated, so do whatever you want to do. If the new table doesn't have the row, instead look it up in the old table, compute and insert a row for the new table, and use that. (If the row isn't present in either the old or new table, it's just not present.) If possible, you should also update the row in the old table to match any mutations that happen in the new table, so that outdated clients can still function. + +This has several advantages: +- SpacetimeDB's module hotswapping makes this a zero-downtime update. Write your new module, `spacetime publish` it, and watch the new table populate as it's used. +- It amortizes the cost of transforming rows or computing new columns across many transactions. Rows will only be added to the new table when they're needed. +- In many cases, old clients from before the update can coexist with new clients that use the new table. You can publish the updated module without disconnecting your clients, roll out the client update through normal channels, and allow your users to update at their own pace. + +For example, imagine we have a table `player` which stores information about our players: + + + +```rust +#[spacetimedb::table(name = character, public)] +pub struct Character { + #[primary_key] + player_id: Identity, + #[unique] + nickname: String, + level: u32, + class: Class, +} + +#[derive(SpacetimeType, Debug, Copy, Clone)] +pub enum Class { + Fighter, + Caster, + Medic, +} +``` + +We'll write a few helper functions and some simple reducers: + +```rust +#[spacetimedb::reducer] +fn create_character(ctx: &ReducerContext, class: Class, nickname: String) { + log::info!( + "Creating new level 1 {class:?} named {nickname}", + ); + ctx.db.character().insert(Character { + player_id: ctx.sender, + nickname, + level: 1, + class, + }); +} + +fn find_character_for_player(ctx: &ReducerContext) -> Character { + ctx.db + .character() + .player_id() + .find(ctx.sender) + .expect("Player has not created a character") +} + +fn update_character(ctx: &ReducerContext, character: Character) { + ctx.db.character().player_id().update(character); +} + +#[spacetimedb::reducer] +fn rename_character(ctx: &ReducerContext, new_name: String) { + let character = find_character_for_player(ctx); + log::info!( + "Renaming {} to {}", + character.nickname, + new_name, + ); + update_character( + ctx, + Character { + nickname: new_name, + ..character + }, + ); +} + +#[spacetimedb::reducer] +fn level_up_character(ctx: &ReducerContext) { + let character = find_character_for_player(ctx); + log::info!( + "Leveling up {} from {} to {}", + character.nickname, + character.level, + character.level + 1, + ); + update_character( + ctx, + Character { + level: character.level + 1, + ..character + }, + ); +} +``` + +We'll play around a bit with `spacetime call` to set up a character: + +```sh +$ spacetime logs incr-migration-demo -f & + +$ spacetime call incr-migration-demo create_character '{ "Fighter": {} }' "Phoebe" + +2025-01-07T15:32:57.447286Z INFO: src/lib.rs:21: Creating new level 1 Fighter named Phoebe + +$ spacetime call -s local incr-migration-demo rename_character "Gefjon" + +2025-01-07T15:33:48.966134Z INFO: src/lib.rs:48: Renaming Phoebe to Gefjon + +$ spacetime call -s local incr-migration-demo level_up_character + +2025-01-07T15:34:01.437495Z INFO: src/lib.rs:66: Leveling up Gefjon from 1 to 2 + +$ spacetime sql incr-migration-demo 'SELECT * FROM character' + + player_id | nickname | level | class +-----------+----------+-------+---------------- + | "Gefjon" | 2 | (Fighter = ()) +``` + +See [the SATS JSON reference](/docs/sats-json) for more on the encoding of arguments to `spacetime call`. + +Now we want to add a new feature: each player should be able to align themselves with the forces of good or evil, so we can get some healthy competition going between our players. We'll start each character off with `Alliance::Neutral`, and then offer them a reducer `choose_alliance` to set it to either `Alliance::Good` or `Alliance::Evil`. Our first attempt will be to add a new column to the type `Character`: + +```rust +#[spacetimedb::table(name = character, public)] +struct Character { + #[primary_key] + player_id: Identity, + nickname: String, + level: u32, + class: Class, + alliance: Alliance, +} + +#[derive(SpacetimeType, Debug, Copy, Clone)] +enum Alliance { + Good, + Neutral, + Evil, +} + +#[spacetimedb::reducer] +fn choose_alliance(ctx: &ReducerContext, alliance: Alliance) { + let character = find_character_for_player(ctx); + log::info!( + "Setting {}'s alliance to {:?} for player {}", + character.nickname, + alliance, + ctx.sender, + ); + update_character( + ctx, + Character { + alliance, + ..character + }, + ); +} +``` + +But that will fail, since SpacetimeDB doesn't know how to update our existing `character` rows with the new column: + +``` +Error: Database update rejected: Errors occurred: +Adding a column alliance to table character requires a manual migration +``` + +Instead, we'll add a new table, `character_v2`, which will coexist with our original `character` table: + +```rust +#[spacetimedb::table(name = character_v2, public)] +struct CharacterV2 { + #[primary_key] + player_id: Identity, + nickname: String, + level: u32, + class: Class, + alliance: Alliance, +} +``` + +When a new player creates a character, we'll make rows in both tables for them. This way, any old clients that are still subscribing to the original `character` table will continue to work, though of course they won't know about the character's alliance. + +```rust +#[spacetimedb::reducer] +fn create_character(ctx: &ReducerContext, class: Class, nickname: String) { + log::info!( + "Creating new level 1 {class:?} named {nickname} for player {}", + ctx.sender, + ); + + ctx.db.character().insert(Character { + player_id: ctx.sender, + nickname: nickname.clone(), + level: 1, + class, + }); + + ctx.db.character_v2().insert(CharacterV2 { + player_id: ctx.sender, + nickname, + level: 1, + class, + alliance: Alliance::Neutral, + }); +} +``` + +We'll update our helper functions so that they operate on `character_v2` rows. In `find_character_for_player`, if we don't see the player's row in `character_v2`, we'll migrate it from `character` on the fly. In this case, we'll make the player neutral, since they haven't chosen an alliance yet. + +```rust +fn find_character_for_player(ctx: &ReducerContext) -> CharacterV2 { + if let Some(character) = ctx.db.character_v2().player_id().find(ctx.sender) { + // Already migrated; just return the new player. + return character; + } + + // Not yet migrated; look up an old character and update it. + let old_character = ctx + .db + .character() + .player_id() + .find(ctx.sender) + .expect("Player has not created a character"); + + ctx.db.character_v2().insert(CharacterV2 { + player_id: old_character.player_id, + nickname: old_character.nickname, + level: old_character.level, + class: old_character.class, + alliance: Alliance::Neutral, + }) +} +``` + +Just like when creating a new character, when we update a `character_v2` row, we'll also update the old `character` row, so that outdated clients can continue to function. It's very important that we perform the same translation between `character` and `character_v2` rows here as in `create_character` and `find_character_for_player`. + +```rust +fn update_character(ctx: &ReducerContext, character: CharacterV2) { + ctx.db.character().player_id().update(Character { + player_id: character.player_id, + nickname: character.nickname.clone(), + level: character.level, + class: character.class, + }); + ctx.db.character_v2().player_id().update(character); +} +``` + +Then we can make trivial modifications to the callers of `update_character` so that they pass in `CharacterV2` instances: + +```rust +#[spacetimedb::reducer] +fn rename_character(ctx: &ReducerContext, new_name: String) { + let character = find_character_for_player(ctx); + log::info!( + "Renaming {} to {}", + character.nickname, + new_name, + ); + update_character( + ctx, + CharacterV2 { + nickname: new_name, + ..character + }, + ); +} + +#[spacetimedb::reducer] +fn level_up_character(ctx: &ReducerContext) { + let character = find_character_for_player(ctx); + log::info!( + "Leveling up {} from {} to {}", + character.nickname, + character.level, + character.level + 1, + ); + update_character( + ctx, + CharacterV2 { + level: character.level + 1, + ..character + }, + ); +} +``` + +And finally, we can define our new `choose_alliance` reducer: + +```rust +#[spacetimedb::reducer] +fn choose_alliance(ctx: &ReducerContext, alliance: Alliance) { + let character = find_character_for_player(ctx); + log::info!( + "Setting alliance of {} to {:?}", + character.nickname, + alliance, + ); + update_character( + ctx, + CharacterV2 { + alliance, + ..character + }, + ); +} +``` + +A bit more playing around with the CLI will show us that everything works as intended: + +```sh +# Our row in `character` still exists: +$ spacetime sql incr-migration-demo 'SELECT * FROM character' + + player_id | nickname | level | class +-----------+----------+-------+---------------- + | "Gefjon" | 2 | (Fighter = ()) + +# We haven't triggered the "Gefjon" row to migrate yet, so `character_v2` is empty: +$ spacetime sql -s local incr-migration-demo 'SELECT * FROM character_v2' + + player_id | nickname | level | class | alliance +-----------+----------+-------+-------+---------- + +# Accessing our character, e.g. by leveling up, will cause it to migrate into `character_v2`: +$ spacetime call incr-migration-demo level_up_character + +2025-01-07T16:00:20.500600Z INFO: src/lib.rs:110: Leveling up Gefjon from 2 to 3 + +# Now `character_v2` is populated: +$ spacetime sql incr-migration-demo 'SELECT * FROM character_v2' + + player_id | nickname | level | class | alliance +-----------+----------+-------+----------------+---------------- + | "Gefjon" | 3 | (Fighter = ()) | (Neutral = ()) + +# The original row in `character` still got updated by `level_up_character`, +# so outdated clients can continue to function: +$ spacetime sql incr-migration-demo 'SELECT * FROM character' + + player_id | nickname | level | class +-----------+----------+-------+---------------- + | "Gefjon" | 3 | (Fighter = ()) + +# We can set our alliance: +$ spacetime call incr-migration-demo choose_alliance '{ "Good": {} }' + +2025-01-07T16:13:53.816501Z INFO: src/lib.rs:129: Setting alliance of Gefjon to Good + +# And that change shows up in `character_v2`: +$ spacetime sql incr-migration-demo 'SELECT * FROM character_v2' + + player_id | nickname | level | class | alliance +-----------+----------+-------+----------------+------------- + | "Gefjon" | 3 | (Fighter = ()) | (Good = ()) + +# But `character` is not changed, since it doesn't know about alliances: +$ spacetime sql incr-migration-demo 'SELECT * FROM character' + + player_id | nickname | level | class +-----------+----------+-------+---------------- + | "Gefjon" | 3 | (Fighter = ()) +``` + +Now that we know how to define incremental migrations, we can add new features that would seem to require breaking schema changes without cumbersome external migration tools and while maintaining compatibility of outdated clients! The complete for this tutorial is on GitHub in the `clockworklabs/incr-migration-demo` repository, in branches [`v1`](https://github.com/clockworklabs/incr-migration-demo/tree/v1), [`fails-publish`](https://github.com/clockworklabs/incr-migration-demo/tree/fails-publish) and [`v2`](https://github.com/clockworklabs/incr-migration-demo/tree/v2). diff --git a/docs/http/authorization.md b/docs/http/authorization.md new file mode 100644 index 00000000..4f0973dc --- /dev/null +++ b/docs/http/authorization.md @@ -0,0 +1,23 @@ +# SpacetimeDB HTTP Authorization + +### 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/). + +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. + +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. + +### `Authorization` headers + +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). + +# Top level routes + +| Route | Description | +| ----------------------------- | ------------------------------------------------------ | +| [`GET /v1/ping`](#get-v1ping) | No-op. Used to determine whether a client can connect. | + +## `GET /v1/ping` + +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/http/database.md b/docs/http/database.md new file mode 100644 index 00000000..56273f6b --- /dev/null +++ b/docs/http/database.md @@ -0,0 +1,447 @@ +# `/v1/database` HTTP API + +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 | +| -------------------------------------------------------------------------------------------------- | ------------------------------------------------- | +| [`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 publish`. + +#### Required Headers + +| Name | Value | +| --------------- | --------------------------------------------------------------------- | +| `Authorization` | A Spacetime token [as Bearer auth](/docs/http/authorization#authorization-headers). | + +#### 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": { + "database_identity": string, + "op": "created" | "updated" +} } +``` + +## `POST /v1/database/:name_or_identity` + +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 publish`. + +#### Query Parameters + +| Name | Value | +| ------- | --------------------------------------------------------------------------------- | +| `clear` | A boolean; whether to clear any existing data when updating an existing database. | + +#### Required Headers + +| Name | Value | +| --------------- | --------------------------------------------------------------------- | +| `Authorization` | A Spacetime token [as Bearer auth](/docs/http/authorization#authorization-headers). | + +#### 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, + "database_identity": string, + "op": "created" | "updated" +} } +``` + +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 +{ "PermissionDenied": { + "name": string +} } +``` + +## `GET /v1/database/:name_or_identity` + +Get a database's identity, owner identity, host type, number of replicas and a hash of its WASM module. + +#### Returns + +Returns JSON in the form: + +```typescript +{ + "database_identity": string, + "owner_identity": string, + "host_type": "wasm", + "initial_program": string +} +``` + +| 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. | + +## `DELETE /v1/database/:name_or_identity` + +Delete a database. + +Accessible through the CLI as `spacetime delete `. + +#### Required Headers + +| Name | Value | +| --------------- | --------------------------------------------------------------------- | +| `Authorization` | A Spacetime token [as Bearer auth](/docs/http/authorization#authorization-headers). | + +## `GET /v1/database/:name_or_identity/names` + +Get the names this database can be identified by. + +#### 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. + +## `POST /v1/database/:name_or_identity/names` + +Add a new name for this database. + +#### Required Headers + +| Name | Value | +| --------------- | --------------------------------------------------------------------- | +| `Authorization` | A Spacetime token [as Bearer auth](/docs/http/authorization#authorization-headers). | + +#### Data + +Takes as the request body a string containing the new name of the database. + +#### Returns + +If the name was successfully set, returns JSON in the form: + +```typescript +{ "Success": { + "domain": string, + "database_result": string +} } +``` + +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 +{ "PermissionDenied": { + "domain": string +} } +``` + +## `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#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 +{ "Success": null } +``` + +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: + +```typescript +{ "PermissionDenied": null } +``` + +## `GET /v1/database/:name_or_identity/identity` + +Get the identity of a database. + +#### Returns + +Returns a hex string of the specified database's identity. + +## `GET /v1/database/:name_or_identity/subscribe` + +Begin a WebSocket connection with a 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.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.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.json.spacetimedb`, encodes messages according to the [SATS-JSON format](/docs/sats-json). + +#### Optional Headers + +| Name | Value | +| --------------- | --------------------------------------------------------------------- | +| `Authorization` | A Spacetime token [as Bearer auth](/docs/http/authorization#authorization-headers). | + +## `POST /v1/database/:name_or_identity/call/:reducer` + +Invoke a reducer in a database. + +#### Path parameters + +| Name | Value | +| ---------- | ------------------------ | +| `:reducer` | The name of the reducer. | + +#### Required Headers + +| Name | Value | +| --------------- | --------------------------------------------------------------------- | +| `Authorization` | A Spacetime token [as Bearer auth](/docs/http/authorization#authorization-headers). | + +#### Data + +A JSON array of arguments to the reducer. + +## `GET /v1/database/:name_or_identity/schema` + +Get a schema for a database. + +Accessible through the CLI as `spacetime describe `. + +#### Query Parameters + +| Name | Value | +| --------- | ------------------------------------------------ | +| `version` | The version of `RawModuleDef` to return, e.g. 9. | + +#### Returns + +Returns a `RawModuleDef` in JSON form. + +
+Example response from `/schema?version=9` for the default module generated by `spacetime init` + +```json +{ + "typespace": { + "types": [ + { + "Product": { + "elements": [ + { + "name": { + "some": "name" + }, + "algebraic_type": { + "String": [] + } + } + ] + } + } + ] + }, + "tables": [ + { + "name": "person", + "product_type_ref": 0, + "primary_key": [], + "indexes": [], + "constraints": [], + "sequences": [], + "schedule": { + "none": [] + }, + "table_type": { + "User": [] + }, + "table_access": { + "Private": [] + } + } + ], + "reducers": [ + { + "name": "add", + "params": { + "elements": [ + { + "name": { + "some": "name" + }, + "algebraic_type": { + "String": [] + } + } + ] + }, + "lifecycle": { + "none": [] + } + }, + { + "name": "identity_connected", + "params": { + "elements": [] + }, + "lifecycle": { + "some": { + "OnConnect": [] + } + } + }, + { + "name": "identity_disconnected", + "params": { + "elements": [] + }, + "lifecycle": { + "some": { + "OnDisconnect": [] + } + } + }, + { + "name": "init", + "params": { + "elements": [] + }, + "lifecycle": { + "some": { + "Init": [] + } + } + }, + { + "name": "say_hello", + "params": { + "elements": [] + }, + "lifecycle": { + "none": [] + } + } + ], + "types": [ + { + "name": { + "scope": [], + "name": "Person" + }, + "ty": 0, + "custom_ordering": true + } + ], + "misc_exports": [], + "row_level_security": [] +} +``` + +
+ +## `GET /v1/database/:name_or_identity/logs` + +Retrieve logs from a database. + +Accessible through the CLI as `spacetime logs `. + +#### 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 [as Bearer auth](/docs/http/authorization#authorization-headers). | + +#### Returns + +Text, or streaming text if `follow` is supplied, containing log lines. + +## `POST /v1/database/:name_or_identity/sql` + +Run a SQL query against a database. + +Accessible through the CLI as `spacetime sql `. + +#### Required Headers + +| Name | Value | +| --------------- | --------------------------------------------------------------------- | +| `Authorization` | A Spacetime token [as Bearer auth](/docs/http/authorization#authorization-headers). | + +#### 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/sats-json) describing the type of the returned rows. + +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/http/identity.md b/docs/http/identity.md new file mode 100644 index 00000000..222ac1e9 --- /dev/null +++ b/docs/http/identity.md @@ -0,0 +1,128 @@ +# `/v1/identity` HTTP API + +The HTTP endpoints in `/v1/identity` allow clients to generate and manage Spacetime public identities and private tokens. + +## At a glance + +| 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. | + +## `POST /v1/identity` + +Create a new identity. + +#### Returns + +Returns JSON in the form: + +```typescript +{ + "identity": string, + "token": string +} +``` + +## `POST /v1/identity/websocket-token` + +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/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). + +## `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. + +#### 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/authorization). | + +## `GET /v1/identity/:identity/databases` + +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. + +## `GET /v1/identity/:identity/verify` + +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/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/index.md b/docs/index.md new file mode 100644 index 00000000..a8b671c0 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,288 @@ +# 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? + +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. + +
+ SpacetimeDB Architecture +
+ SpacetimeDB application architecture + (elements in white are provided by SpacetimeDB) +
+
+ +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 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. + +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. + +## 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 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 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 + +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) + +### Client-side SDKs + +**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) +- [TypeScript](/docs/sdks/typescript) - [(Quickstart)](/docs/sdks/typescript/quickstart) + +### 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 server that hosts [databases](#database). You can run your own host, or use the SpacetimeDB maincloud. Many databases can run on a single host. + +### Database +A SpacetimeDB **database** is an application that runs on a [host](#host). + +A database exports [tables](#table), which store data, and [reducers](#reducer), which allow [clients](#client) to make requests. + +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 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)] +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 [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 +[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 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); + } +} + +#[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); + } +} + +[SpacetimeDB.Reducer] +public static void World(ReducerContext ctx) +{ + ClearAllTables(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). + +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 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 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. + +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. 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 + +A `ConnectionId` identifies client connections to a SpacetimeDB database. + +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. + + + +## FAQ + +1. What is SpacetimeDB? + 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 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. + +1. How do I create a new database with SpacetimeDB? + Follow our [Quick Start](/docs/getting-started) guide! + +5. How do I create a Unity game with SpacetimeDB? + Follow our [Unity Tutorial](/docs/unity) guide! diff --git a/docs/modules/c-sharp/index.md b/docs/modules/c-sharp/index.md new file mode 100644 index 00000000..3deeb2b7 --- /dev/null +++ b/docs/modules/c-sharp/index.md @@ -0,0 +1,1405 @@ +# 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 │ │ +│ └─────────────────┘ │ │ └─────────────────┘ │ +│ │ │ │ +└───────────────────────┘ └───────────────────────┘ +``` + +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. + +(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.) + +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). + +# Overview + +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. + +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(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.AutoInc] + [SpacetimeDB.PrimaryKey] + public int Id; + public string Name; + public int Age; + } + + [SpacetimeDB.Reducer] + public static void Add(ReducerContext ctx, string name, int age) + { + 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(ReducerContext ctx) + { + foreach (var person in ctx.Db.Person.Iter()) + { + Log.Info($"Hello, {person.Name}!"); + } + 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); + } +} +``` + +(See [reducers](#reducers) for more information on declaring reducers.) + +This library generates a custom API for each table, depending on the table's name and structure. + +All tables support getting a handle implementing the [`ITableView`](#interface-itableview) interface from a [`ReducerContext`](#class-reducercontext), using: + +```text +ctx.Db.{table_name} +``` + +For example, + +```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 +namespace SpacetimeDB.Internal; + +public interface ITableView + where Row : IStructuralReadWrite, new() +{ + /* ... */ +} +``` + + +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. + +Inserting a duplicate row in a table is a no-op, +as SpacetimeDB is a set-semantic database. + +### Method `ITableView.Delete` + +```csharp +bool Delete(Row row); +``` + +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. + +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. + +Throws an exception if deleting the row would violate any constraints. + +### Method `ITableView.Iter` + +```csharp +IEnumerable Iter(); +``` + +Iterate over all rows of the table. + +(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.) + +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. + +### Property `ITableView.Count` + +```csharp +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; +} +``` + +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 +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 +{ + /* ... */ +} +``` + + +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"); + } +} +``` + +| 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 | + + + +### Method `UniqueIndex.Find` + +```csharp +Row? Find(Column key); +``` + +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. + +### Method `UniqueIndex.Update` + +```csharp +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. + +[`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 +{ + [SpacetimeDB.Table(Name = "example")] + public partial struct Example + { + [SpacetimeDB.AutoInc] + public int Field; + } + + [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. + +## Indexes + +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; +} +``` + +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; +} +``` + + +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 +public abstract class IndexBase + where Row : IStructuralReadWrite, new() +{ + // ... +} +``` + +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 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 +{ + [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; + } + + [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". + } + } +} +``` + +### Method `Index.Delete` + +```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); +// ... +``` + +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. + +# Reducers + +Reducers are declared using the `[SpacetimeDB.Reducer]` attribute. + +`[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`. + +```csharp +public static partial class Module { + [SpacetimeDB.Reducer] + public static void GivePlayerItem( + ReducerContext context, + ulong PlayerId, + ulong ItemId + ) + { + // ... + } +} +``` + +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 +public sealed record ReducerContext : DbContext, Internal.IReducerContext +{ + // ... +} +``` + +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. + +[`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. + +| 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 | + +### Property `ReducerContext.Db` + +```csharp +DbView Db; +``` + +Allows accessing the local database attached to a module. + +The `[Table]` attribute generates a field of this property. + +For a table named *table*, use `ctx.Db.{table}` to get a [table view](#interface-itableview). +For example, `ctx.Db.users`. + +You can also use `ctx.Db.{table}.{index}` to get an [index](#class-index) or [unique index](#class-uniqueindex). + +### Property `ReducerContext.Sender` + +```csharp +Identity Sender; +``` + +The [`Identity`](#struct-identity) of the client that invoked the reducer. + +### Property `ReducerContext.ConnectionId` + +```csharp +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; +``` + +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 + +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 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 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. + + +## 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 +using SpacetimeDB; + +public static partial class Module +{ + + // First, we declare the table with scheduling information. + + [Table(Name = "send_message_schedule", Scheduled = nameof(SendMessage), ScheduledAt = nameof(ScheduledAt))] + public partial struct SendMessageSchedule + { + + // 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. + + [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. + + [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; + + ctx.Db.send_message_schedule.Insert(new() + { + Id = 0, // Have [AutoInc] assign an Id. + ScheduledAt = new ScheduleAt.Time(futureTimestamp), + Message = "I'm a bot sending a message one time!" + }); + + ctx.Db.send_message_schedule.Insert(new() + { + Id = 0, // Have [AutoInc] assign an Id. + ScheduledAt = new ScheduleAt.Interval(tenSeconds), + Message = "I'm a bot sending a message every ten seconds!" + }); + } +} +``` + +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 +[Reducer] +public static void SendMessage(ReducerContext ctx, SendMessageSchedule schedule) +{ + if (ctx.Sender != ctx.Identity) + { + 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: + +- ❌ **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. + +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); + } +} +``` + +Methods for writing to a private debug log. Log messages will include file and line numbers. + +Log outputs of a running database can be inspected using the `spacetime logs` command: + +```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 +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 +{ + 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. + + + +## Struct `ConnectionId` + +```csharp +namespace SpacetimeDB; + +public readonly record struct ConnectionId +{ + 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. + +## Struct `Timestamp` + +```csharp +namespace SpacetimeDB; + +public record struct Timestamp(long MicrosecondsSinceUnixEpoch) + : IStructuralReadWrite, + IComparable +{ + // ... +} +``` + +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 }; +``` + +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) +{ + 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. + +[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 diff --git a/docs/modules/c-sharp/quickstart.md b/docs/modules/c-sharp/quickstart.md new file mode 100644 index 00000000..72d907e3 --- /dev/null +++ b/docs/modules/c-sharp/quickstart.md @@ -0,0 +1,316 @@ +# 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. +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. + +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 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. + +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 + +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` 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 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 SpacetimeDB; +``` + +We also need to create our static module class which all of the module code will live in. In `server/Lib.cs`, add: + +```csharp +public 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 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 +[Table(Name = "user", Public = true)] +public partial class User +{ + [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: + +```csharp +[Table(Name = "message", Public = true)] +public partial class Message +{ + public Identity Sender; + public Timestamp 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 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 +[Reducer] +public static void SetName(ReducerContext ctx, string name) +{ + name = ValidateName(name); + + var user = ctx.Db.user.Identity.Find(ctx.Sender); + if (user is not null) + { + user.Name = name; + ctx.Db.user.Identity.Update(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: + +```csharp +/// Takes a name and checks if it's acceptable as a user's name. +private 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 `ReducerContext`. + +In `server/Lib.cs`, add to the `Module` class: + +```csharp +[Reducer] +public static void SendMessage(ReducerContext ctx, string text) +{ + text = ValidateMessage(text); + Log.Info(text); + ctx.Db.message.Insert( + new Message + { + Sender = ctx.Sender, + Text = text, + Sent = ctx.Timestamp, + } + ); +} +``` + +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: + +```csharp +/// Takes a message's text and checks if it's acceptable to send. +private 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 `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 `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 +[Reducer(ReducerKind.ClientConnected)] +public static void ClientConnected(ReducerContext ctx) +{ + 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; + 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( + new User + { + Name = null, + Identity = ctx.Sender, + Online = true, + } + ); + } +} +``` + +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: + +```csharp +[Reducer(ReducerKind.ClientDisconnected)] +public static void ClientDisconnected(ReducerContext ctx) +{ + 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); + } + else + { + // User does not exist, log warning + Log.Warn("Warning: No user found for disconnected client."); + } +} +``` + +## 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. 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 quickstart-chat +``` + +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 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 quickstart-chat +``` + +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 quickstart-chat "SELECT * FROM message" +``` + +```bash + sender | sent | text +--------------------------------------------------------------------+----------------------------------+----------------- + 0x93dda09db9a56d8fa6c024d843e805d8262191db3b4ba84c5efcd1ad451fed4e | 2025-04-08T15:47:46.935402+00:00 | "Hello, world!" +``` + +## What's next? + +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). + +If you are planning to use SpacetimeDB with the Unity game engine, you can skip right to the [Unity Comprehensive Tutorial](/docs/unity/part-1). diff --git a/docs/modules/index.md b/docs/modules/index.md new file mode 100644 index 00000000..08d72a92 --- /dev/null +++ b/docs/modules/index.md @@ -0,0 +1,21 @@ +# 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 + +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) + +### C# + +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) diff --git a/docs/modules/rust/index.md b/docs/modules/rust/index.md new file mode 100644 index 00000000..a8681954 --- /dev/null +++ b/docs/modules/rust/index.md @@ -0,0 +1,4 @@ +# Rust Module SDK Reference + +The Rust Module SDK docs are [hosted on docs.rs](https://docs.rs/spacetimedb/latest/spacetimedb/). + diff --git a/docs/modules/rust/quickstart.md b/docs/modules/rust/quickstart.md new file mode 100644 index 00000000..9572ed0b --- /dev/null +++ b/docs/modules/rust/quickstart.md @@ -0,0 +1,280 @@ +# 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 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 `#[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 `#[table(name = table_name, public)]` macro makes a table public. **Public** tables are readable by all users but can still only be modified by your server module code. + +A reducer is a function that traverses and updates the database. Each reducer call runs in its own transaction, and its updates to the database are only committed if the reducer returns successfully. 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 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: + +```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 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}; +``` + +From `spacetimedb`, we import: + +- `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. + +## 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 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`: + +```rust +#[table(name = user, public)] +pub struct User { + #[primary_key] + 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 +#[table(name = message, public)] +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 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. + +To `server/src/lib.rs`, add: + +```rust +#[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) = 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()) + } +} +``` + +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 `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 +#[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); + ctx.db.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 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(..)`. + +To `server/src/lib.rs`, add the definition of the connect reducer: + +```rust +#[reducer(client_connected)] +// 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`, + // set `online: true`, but leave `name` and `identity` unchanged. + 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. + ctx.db.user().insert(User { + name: None, + identity: ctx.sender, + online: true, + }); + } +} +``` + +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 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 }); + } 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 `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 quickstart-chat +``` + +## 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 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 quickstart-chat +``` + +You should now see the output that your module printed in the database. + +```bash + INFO: spacetimedb: Creating table `message` + INFO: spacetimedb: Creating table `user` + INFO: spacetimedb: Database initialized + INFO: src/lib.rs:43: 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 quickstart-chat "SELECT * FROM message" +``` + +```bash + sender | sent | text +--------------------------------------------------------------------+----------------------------------+----------------- + 0x93dda09db9a56d8fa6c024d843e805d8262191db3b4ba84c5efcd1ad451fed4e | 2025-04-08T15:47:46.935402+00:00 | "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 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/nav.js b/docs/nav.js new file mode 100644 index 00000000..7f8c0e6d --- /dev/null +++ b/docs/nav.js @@ -0,0 +1,58 @@ +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'), // 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'), + 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'), + 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'), + 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'), + 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('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('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'), + page('Authorization', 'http/authorization', 'http/authorization.md'), + page('`/identity`', 'http/identity', 'http/identity.md'), + page('`/database`', 'http/database', 'http/database.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'), + ], +}; +export default nav; diff --git a/docs/rls/index.md b/docs/rls/index.md new file mode 100644 index 00000000..a357f96b --- /dev/null +++ b/docs/rls/index.md @@ -0,0 +1,303 @@ +# Row Level Security (RLS) + +Row Level Security (RLS) allows module authors to restrict which rows of a public table each client can access. +These access rules are expressed in SQL and evaluated automatically for queries and subscriptions. + +## Enabling RLS + +RLS is currently **experimental** and must be explicitly enabled in your module. + +:::server-rust +To enable RLS, activate the `unstable` feature in your project's `Cargo.toml`: + +```toml +spacetimedb = { version = "...", features = ["unstable"] } +``` +::: +:::server-csharp +To enable RLS, include the following preprocessor directive at the top of your module files: + +```cs +#pragma warning disable STDB_UNSTABLE +``` +::: + +## How It Works + +:::server-rust +RLS rules are expressed in SQL and declared as constants of type `Filter`. + +```rust +use spacetimedb::{client_visibility_filter, Filter}; + +/// A client can only see their account +#[client_visibility_filter] +const ACCOUNT_FILTER: Filter = Filter::Sql( + "SELECT * FROM account WHERE identity = :sender" +); +``` +::: +:::server-csharp +RLS rules are expressed in SQL and declared as public static readonly fields of type `Filter`. + +```cs +using SpacetimeDB; + +#pragma warning disable STDB_UNSTABLE + +public partial class Module +{ + /// + /// A client can only see their account. + /// + [SpacetimeDB.ClientVisibilityFilter] + public static readonly Filter ACCOUNT_FILTER = new Filter.Sql( + "SELECT * FROM account WHERE identity = :sender" + ); +} +``` +::: + +A module will fail to publish if any of its RLS rules are invalid or malformed. + +### `:sender` + +You can use the special `:sender` parameter in your rules for user specific access control. +This parameter is automatically bound to the requesting client's [Identity]. + +Note that module owners have unrestricted access to all tables regardless of RLS. + + +[Identity]: /docs/index.md#identity + +### Semantic Constraints + +RLS rules are similar to subscriptions in that logically they act as filters on a particular table. +Also like subscriptions, arbitrary column projections are **not** allowed. +Joins **are** allowed, but each rule must return rows from one and only one table. + +### Multiple Rules Per Table + +Multiple rules may be declared for the same table and will be evaluated as a logical `OR`. +This means clients will be able to see to any row that matches at least one of the rules. + +#### Example + +:::server-rust +```rust +use spacetimedb::{client_visibility_filter, Filter}; + +/// A client can only see their account +#[client_visibility_filter] +const ACCOUNT_FILTER: Filter = Filter::Sql( + "SELECT * FROM account WHERE identity = :sender" +); + +/// An admin can see all accounts +#[client_visibility_filter] +const ACCOUNT_FILTER_FOR_ADMINS: Filter = Filter::Sql( + "SELECT account.* FROM account JOIN admin WHERE admin.identity = :sender" +); +``` +::: +:::server-csharp +```cs +using SpacetimeDB; + +#pragma warning disable STDB_UNSTABLE + +public partial class Module +{ + /// + /// A client can only see their account. + /// + [SpacetimeDB.ClientVisibilityFilter] + public static readonly Filter ACCOUNT_FILTER = new Filter.Sql( + "SELECT * FROM account WHERE identity = :sender" + ); + + /// + /// An admin can see all accounts. + /// + [SpacetimeDB.ClientVisibilityFilter] + public static readonly Filter ACCOUNT_FILTER_FOR_ADMINS = new Filter.Sql( + "SELECT account.* FROM account JOIN admin WHERE admin.identity = :sender" + ); +} +``` +::: + +### Recursive Application + +RLS rules can reference other tables with RLS rules, and they will be applied recursively. +This ensures that data is never leaked through indirect access patterns. + +#### Example + +:::server-rust +```rust +use spacetimedb::{client_visibility_filter, Filter}; + +/// A client can only see their account +#[client_visibility_filter] +const ACCOUNT_FILTER: Filter = Filter::Sql( + "SELECT * FROM account WHERE identity = :sender" +); + +/// An admin can see all accounts +#[client_visibility_filter] +const ACCOUNT_FILTER_FOR_ADMINS: Filter = Filter::Sql( + "SELECT account.* FROM account JOIN admin WHERE admin.identity = :sender" +); + +/// Explicitly filtering by client identity in this rule is not necessary, +/// since the above RLS rules on `account` will be applied automatically. +/// Hence a client can only see their player, but an admin can see all players. +#[client_visibility_filter] +const PLAYER_FILTER: Filter = Filter::Sql( + "SELECT p.* FROM account a JOIN player p ON a.id = p.id" +); +``` +::: +:::server-csharp +```cs +using SpacetimeDB; + +public partial class Module +{ + /// + /// A client can only see their account. + /// + [SpacetimeDB.ClientVisibilityFilter] + public static readonly Filter ACCOUNT_FILTER = new Filter.Sql( + "SELECT * FROM account WHERE identity = :sender" + ); + + /// + /// An admin can see all accounts. + /// + [SpacetimeDB.ClientVisibilityFilter] + public static readonly Filter ACCOUNT_FILTER_FOR_ADMINS = new Filter.Sql( + "SELECT account.* FROM account JOIN admin WHERE admin.identity = :sender" + ); + + /// + /// Explicitly filtering by client identity in this rule is not necessary, + /// since the above RLS rules on `account` will be applied automatically. + /// Hence a client can only see their player, but an admin can see all players. + /// + [SpacetimeDB.ClientVisibilityFilter] + public static readonly Filter PLAYER_FILTER = new Filter.Sql( + "SELECT p.* FROM account a JOIN player p ON a.id = p.id" + ); +} +``` +::: + +And while self-joins are allowed, in general RLS rules cannot be self-referential, +as this would result in infinite recursion. + +#### Example: Self-Join + +:::server-rust +```rust +use spacetimedb::{client_visibility_filter, Filter}; + +/// A client can only see players on their same level +#[client_visibility_filter] +const PLAYER_FILTER: Filter = Filter::Sql(" + SELECT q.* + FROM account a + JOIN player p ON u.id = p.id + JOIN player q on p.level = q.level + WHERE a.identity = :sender +"); +``` +::: +:::server-csharp +```cs +using SpacetimeDB; + +public partial class Module +{ + /// + /// A client can only see players on their same level. + /// + [SpacetimeDB.ClientVisibilityFilter] + public static readonly Filter PLAYER_FILTER = new Filter.Sql(@" + SELECT q.* + FROM account a + JOIN player p ON u.id = p.id + JOIN player q on p.level = q.level + WHERE a.identity = :sender + "); +} +``` +::: + +#### Example: Recursive Rules + +This module will fail to publish because each rule depends on the other one. + +:::server-rust +```rust +use spacetimedb::{client_visibility_filter, Filter}; + +/// An account must have a corresponding player +#[client_visibility_filter] +const ACCOUNT_FILTER: Filter = Filter::Sql( + "SELECT a.* FROM account a JOIN player p ON a.id = p.id WHERE a.identity = :sender" +); + +/// A player must have a corresponding account +#[client_visibility_filter] +const PLAYER_FILTER: Filter = Filter::Sql( + "SELECT p.* FROM account a JOIN player p ON a.id = p.id WHERE a.identity = :sender" +); +``` +::: +:::server-csharp +```cs +using SpacetimeDB; + +public partial class Module +{ + /// + /// An account must have a corresponding player. + /// + [SpacetimeDB.ClientVisibilityFilter] + public static readonly Filter ACCOUNT_FILTER = new Filter.Sql( + "SELECT a.* FROM account a JOIN player p ON a.id = p.id WHERE a.identity = :sender" + ); + + /// + /// A player must have a corresponding account. + /// + [SpacetimeDB.ClientVisibilityFilter] + public static readonly Filter ACCOUNT_FILTER = new Filter.Sql( + "SELECT p.* FROM account a JOIN player p ON a.id = p.id WHERE a.identity = :sender" + ); +} +``` +::: + +## Usage in Subscriptions + +RLS rules automatically apply to subscriptions so that if a client subscribes to a table with RLS filters, +the subscription will only return rows that the client is allowed to see. + +While the contraints and limitations outlined in the [reference docs] do not apply to RLS rules, +they do apply to the subscriptions that use them. +For example, it is valid for an RLS rule to have more joins than are supported by subscriptions. +However a client will not be able to subscribe to the table for which that rule is defined. + + +[reference docs]: /docs/sql/index.md#subscriptions + +## Best Practices + +1. Use `:sender` for client specific filtering. +2. Follow the [SQL best practices] for optimizing your RLS rules. + + +[SQL best practices]: /docs/sql/index.md#best-practices-for-performance-and-scalability diff --git a/docs/SATN Reference/index.md b/docs/sats-json.md similarity index 86% rename from docs/SATN Reference/index.md rename to docs/sats-json.md index cedc496a..38f08756 100644 --- a/docs/SATN Reference/index.md +++ b/docs/sats-json.md @@ -1,6 +1,6 @@ -# SATN JSON Format +# SATS-JSON Format -The Spacetime Algebraic Type Notation JSON format defines how Spacetime `AlgebraicType`s and `AlgebraicValue`s are encoded as JSON. Algebraic types and values are JSON-encoded for transport via the [HTTP Databases API](/docs/http-api-reference/databases) and the [WebSocket text protocol](/docs/websocket-api-reference#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,14 +32,20 @@ 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`](#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 ``` +`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`](#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. | @@ -160,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-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 [`GET /v1/database/:name_or_identity/schema` HTTP endpoint](/docs/http/database#get-v1databasename_or_identityschema). diff --git a/docs/sdks/c-sharp/index.md b/docs/sdks/c-sharp/index.md new file mode 100644 index 00000000..3fd4c9b0 --- /dev/null +++ b/docs/sdks/c-sharp/index.md @@ -0,0 +1,924 @@ +# The SpacetimeDB C# client 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 + +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](/docs/modules/c-sharp/quickstart) 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](https://github.com/clockworklabs/com.clockworklabs.spacetimedbsdk/releases/latest), packaged as a `.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. + +(See also the [Unity Tutorial](/docs/unity/part-1)) + +## 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. + +## Type `DbConnection` + +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. + +| Name | Description | +|------------------------------------------------------------------------|-------------------------------------------------------------------------------| +| [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 database + +```csharp +class DbConnection +{ + public static DbConnectionBuilder Builder(); +} +``` + +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 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 open the connection. | + +### Method `WithUri` + +```csharp +class DbConnectionBuilder +{ + public DbConnectionBuilder WithUri(Uri uri); +} +``` + +Configure the URI of the SpacetimeDB instance or cluster which hosts the remote module and database. + +### Method `WithModuleName` + +```csharp +class DbConnectionBuilder +{ + public DbConnectionBuilder WithModuleName(string nameOrIdentity); +} +``` + +Configure the SpacetimeDB domain name or `Identity` of the remote database 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 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` + +```csharp +class DbConnectionBuilder +{ + public DbConnectionBuilder OnConnectError(Action callback); +} +``` + +Chain a call to `.OnConnectError(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. [`OnDisconnect`](#callback-ondisconnect) callbacks are invoked instead. + +### Callback `OnDisconnect` + +```csharp +class DbConnectionBuilder +{ + public DbConnectionBuilder OnDisconnect(Action callback); +} +``` + +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` + +```csharp +class DbConnectionBuilder +{ + public DbConnectionBuilder WithToken(string token = null); +} +``` + +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. + +### Method `Build` + +```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(); +} +``` + +`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. + +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. + +(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`.) + +## Access tables and reducers + +### Property `Db` + +```csharp +class DbConnection +{ + public RemoteTables Db; + /* other members */ +} +``` + +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). + +### Property `Reducers` + +```csharp +class DbConnection +{ + public RemoteReducers Reducers; + /* other members */ +} +``` + +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` + +```csharp +interface IDbContext +{ + /* methods */ +} +``` + +[`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. + +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. + +| 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` | + +### Interface `IRemoteDbContext` + +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`. + +### Method `Db` + +```csharp +interface IRemoteDbContext +{ + public DbView Db { get; } +} +``` + +`Db` will have methods to access each table defined by the module. + +#### Example + +```csharp +var conn = ConnectToDB(); + +// Get a handle to the User table +var tableHandle = conn.Db.User; +``` + +### Method `Reducers` + +```csharp +interface IRemoteDbContext +{ + public RemoteReducers Reducers { get; } +} +``` + +`Reducers` will have methods to invoke each reducer defined by the module, +plus methods for adding and removing callbacks on each of those reducers. + +#### Example + +```csharp +var conn = ConnectToDB(); + +// Register a callback to be run every time the SendMessage reducer is invoked +conn.Reducers.OnSendMessage += Reducer_OnSendMessageEvent; +``` + +### Method `Disconnect` + +```csharp +interface IRemoteDbContext +{ + public void Disconnect(); +} +``` + +Gracefully close the `DbConnection`. Throws an error if the connection is already closed. + +### Subscribe to queries + +| Name | Description | +|---------------------------------------------------------|-------------------------------------------------------------| +| [`SubscriptionBuilder` type](#type-subscriptionbuilder) | Builder-pattern constructor to register subscribed queries. | +| [`SubscriptionHandle` type](#type-subscriptionhandle) | Manage an active subscripion. | + +#### Type `SubscriptionBuilder` + +| 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. | + +##### Constructor `ctx.SubscriptionBuilder()` + +```csharp +interface IRemoteDbContext +{ + public SubscriptionBuilder SubscriptionBuilder(); +} +``` + +Subscribe to queries by calling `ctx.SubscriptionBuilder()` and chaining configuration methods, then calling `.Subscribe(queries)`. + +##### Callback `OnApplied` + +```csharp +class SubscriptionBuilder +{ + public SubscriptionBuilder OnApplied(Action callback); +} +``` + +Register a callback to run when the subscription is applied and the matching rows are inserted into the client cache. + +##### Callback `OnError` + +```csharp +class SubscriptionBuilder +{ + public SubscriptionBuilder OnError(Action callback); +} +``` + +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` + +```csharp +class SubscriptionBuilder +{ + public SubscriptionHandle Subscribe(string[] querySqls); +} +``` + +Subscribe to a set of queries. `queries` should be an array of SQL query strings. + +See [the SpacetimeDB SQL Reference](/docs/sql#subscriptions) for information on the queries SpacetimeDB supports as subscriptions. + +##### Method `SubscribeToAllTables` + +```csharp +class SubscriptionBuilder +{ + public void SubscribeToAllTables(); +} +``` + +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. + +#### Type `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`](#property-db). See [Access the client cache](#access-the-client-cache). + +| 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. | + +##### Property `IsEnded` + +```csharp +class SubscriptionHandle +{ + public bool IsEnded; +} +``` + +True if this subscription has been terminated due to an unsubscribe call or an error. + +##### Property `IsActive` + +```csharp +class SubscriptionHandle +{ + public bool IsActive; +} +``` + +True if this subscription has been applied and has not yet been unsubscribed. + +##### Method `Unsubscribe` + +```csharp +class SubscriptionHandle +{ + public void Unsubscribe(); +} +``` + +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. + +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 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. + +##### Method `UnsubscribeThen` + +```csharp +class SubscriptionHandle +{ + public void UnsubscribeThen(Action? onEnded); +} +``` + +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. + +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. + +### Read connection metadata + +#### Property `Identity` + +```csharp +interface IDbContext +{ + public Identity? Identity { get; } +} +``` + +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. + +#### Property `ConnectionId` + +```csharp +interface IDbContext +{ + public ConnectionId ConnectionId { get; } +} +``` + +Get the [`ConnectionId`](#type-connectionid) with which SpacetimeDB identifies the connection. + +#### Property `IsActive` + +```csharp +interface IDbContext +{ + public bool IsActive { get; } +} +``` + +`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. + +## Type `EventContext` + +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). + +| 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. | + +### Property `Event` + +```csharp +class EventContext { + public readonly Event Event; + /* other fields */ +} +``` + +The [`Event`](#record-event) contained in the `EventContext` describes what happened to cause the current row callback to be invoked. + +### Property `Db` + +```csharp +class EventContext { + public RemoteTables Db; + /* other fields */ +} +``` + +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` + +```csharp +class EventContext { + public RemoteReducers Reducers; + /* other fields */ +} +``` + +The `Reducers` property of the context provides access to reducers exposed by the remote module. See [Observe and invoke reducers](#observe-and-invoke-reducers). + +### Record `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` 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. | + +#### Variant `Reducer` + +```csharp +record Event +{ + public record Reducer(ReducerEvent ReducerEvent) : Event; +} +``` + +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. + +#### Variant `SubscribeApplied` + +```csharp +record Event +{ + public record SubscribeApplied : Event; +} +``` + +Event when our subscription is applied and its rows are inserted into the client cache. + +This event is passed to [row `OnInsert` callbacks](#callback-oninsert) resulting from the new subscription. + +#### Variant `UnsubscribeApplied` + +```csharp +record Event +{ + public record UnsubscribeApplied : Event; +} +``` + +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. + +This event is passed to [row `OnDelete` callbacks](#callback-ondelete) resulting from the subscription ending. + +#### Variant `SubscribeError` + +```csharp +record Event +{ + public record SubscribeError(Exception Exception) : Event; +} +``` + +Event when a subscription ends unexpectedly due to an error. + +This event is passed to [row `OnDelete` callbacks](#callback-ondelete) resulting from the subscription ending. + +#### Variant `UnknownTransaction` + +```csharp +record Event +{ + public record UnknownTransaction : Event; +} +``` + +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. + +### Record `ReducerEvent` + +```csharp +record ReducerEvent( + Timestamp Timestamp, + Status Status, + Identity CallerIdentity, + ConnectionId? CallerConnectionId, + U128? EnergyConsumed, + R Reducer +) +``` + +A `ReducerEvent` contains metadata about a reducer run. + +### Record `Status` + +```csharp +record Status : TaggedEnum<( + Unit Committed, + string Failed, + Unit OutOfEnergy +)>; +``` + + + +| 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` + +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` + +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. + +#### Variant `OutOfEnergy` + +The reducer was aborted due to insufficient energy balance of the module owner. + +### Record `Reducer` + +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. + +## Type `ReducerEventContext` + +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). + +| 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 */ +} +``` + +The [`ReducerEvent`](#record-reducerevent) contained in the `ReducerEventContext` has metadata about the reducer which ran. + +### Property `Db` + +```csharp +class ReducerEventContext { + public RemoteTables Db; + /* other fields */ +} +``` + +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). + +### Property `Reducers` + +```csharp +class ReducerEventContext { + public RemoteReducers Reducers; + /* other fields */ +} +``` + +The `Reducers` property 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 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 */ +} +``` + +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). + +### Property `Reducers` + +```csharp +class SubscriptionEventContext { + public RemoteReducers Reducers; + /* other fields */ +} +``` + +The `Reducers` property 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 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. + +| 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. | + + +### Property `Event` + +```csharp +class SubscriptionEventContext { + public readonly Exception Event; + /* other fields */ +} +``` + +### Property `Db` + +```csharp +class ErrorContext { + public RemoteTables Db; + /* other fields */ +} +``` + +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). + +### Property `Reducers` + +```csharp +class ErrorContext { + public RemoteReducers Reducers; + /* other fields */ +} +``` + +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 + +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. + +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. + +| 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. | + +### Type `RemoteTableHandle` + +Implemented by all table handles. + +| 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. | + +#### Type `Row` + +```csharp +class RemoteTableHandle +{ + /* members */ +} +``` + +The type of rows in the table. + +#### Property `Count` + +```csharp +class RemoteTableHandle +{ + public int Count; +} +``` + +The number of rows of this table resident in the client cache, i.e. the total number which match any subscribed query. + +#### Method `Iter` + +```csharp +class RemoteTableHandle +{ + public IEnumerable Iter(); +} +``` + +An iterator over all the subscribed rows in the client cache, i.e. those which match any subscribed query. + +#### Callback `OnInsert` + +```csharp +class RemoteTableHandle +{ + public delegate void RowEventHandler(EventContext context, Row row); + public event RowEventHandler? OnInsert; +} +``` + +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. + +#### Callback `OnDelete` + +```csharp +class RemoteTableHandle +{ + public delegate void RowEventHandler(EventContext context, Row row); + public event RowEventHandler? OnDelete; +} +``` + +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. + +#### Callback `OnUpdate` + +```csharp +class RemoteTableHandle +{ + public delegate void RowEventHandler(EventContext context, Row row); + public event RowEventHandler? OnUpdate; +} +``` + +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. + +See [the quickstart](/docs/sdks/c-sharp/quickstart#register-callbacks) for examples of regstering and unregistering row callbacks. + +### Unique constraint index access + +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. + + +#### Example + +Given the following module-side `User` definition: +```csharp +[Table(Name = "User", Public = true)] +public partial class User +{ + [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); +``` + +### BTree index access + +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. + +#### Example + +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; + .. +} +``` + +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(); +``` + +## Observe and invoke reducers + +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. + +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` + +A [tagged union](https://en.wikipedia.org/wiki/Tagged_union) type. +See the [module docs](/docs/modules/c-sharp#record-taggedenum) for more details. diff --git a/docs/sdks/c-sharp/quickstart.md b/docs/sdks/c-sharp/quickstart.md new file mode 100644 index 00000000..44065195 --- /dev/null +++ b/docs/sdks/c-sharp/quickstart.md @@ -0,0 +1,563 @@ +# C# Client SDK Quick Start + +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) or [C# Module](../../modules/c-sharp/quickstart) Quickstart guides. Ensure you followed one of these guides before continuing. + +## Project structure + +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 +``` + +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 +``` + +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: + +```bash +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. + +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 three folders and nine files: + +``` +module_bindings +├── 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 + +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. 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: + +```csharp +// our local client SpacetimeDB identity +Identity? local_identity = null; + +// declare a thread safe queue to store commands +var input_queue = new ConcurrentQueue<(string Command, string Args)>(); +``` + +## 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. 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 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. + +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(); +} +``` + +## Connect to database + +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 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. +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 database and module. +const string HOST = "http://localhost:3000"; + +/// 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. +DbConnection ConnectToDB() +{ + DbConnection? conn = null; + conn = DbConnection.Builder() + .WithUri(HOST) + .WithModuleName(DBNAME) + .WithToken(AuthToken.Token) + .OnConnect(OnConnected) + .OnConnectError(OnConnectError) + .OnDisconnect(OnDisconnected) + .Build(); + return conn; +} +``` + +### 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 + +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. + +To `Program.cs`, add: + +```csharp +/// Our `OnConnectError` callback: print the error, then exit the process. +void OnConnectError(Exception e) +{ + Console.Write($"Error while connecting: {e}"); +} +``` + +### Disconnect callback + +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. + +To `Program.cs`, add: + +```csharp +/// Our `OnDisconnect` callback: print a note, then exit the process. +void OnDisconnected(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; +} +``` + +### 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: 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 +/// 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]; + +/// Our `User.OnInsert` callback: if the user is online, print a notification. +void User_OnInsert(EventContext ctx, User insertedValue) +{ + 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.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 `EventContext`. + +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. + +To `Program.cs`, add: + +```csharp +/// 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) + { + 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.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 +/// 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); + } +} + +void PrintMessage(RemoteTables tables, Message message) +{ + var sender = tables.User.Identity.Find(message.Sender); + var senderName = "unknown"; + if (sender != null) + { + senderName = UserNameOrIdentity(sender); + } + + Console.WriteLine($"{senderName}: {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 `OnReducerEvent` method of the `Reducer` namespace, which is automatically implemented for each reducer by `spacetime generate`. + +Each reducer callback takes one fixed argument: + +The `ReducerEventContext` of the callback, which contains an `Event` that contains several fields. The ones we care about are: + +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. 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. + +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. + +To `Program.cs`, add: + +```csharp +/// Our `OnSetNameEvent` callback: print a warning if the reducer failed. +void Reducer_OnSetNameEvent(ReducerEventContext ctx, string name) +{ + var e = ctx.Event; + if (e.CallerIdentity == local_identity && e.Status is Status.Failed(var error)) + { + Console.Write($"Failed to change name to {name}: {error}"); + } +} +``` + +### 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 `Program.cs`, add: + +```csharp +/// Our `OnSendMessageEvent` callback: print a warning if the reducer failed. +void Reducer_OnSendMessageEvent(ReducerEventContext ctx, string text) +{ + var e = ctx.Event; + if (e.CallerIdentity == local_identity && e.Status is Status.Failed(var error)) + { + Console.Write($"Failed to send message {text}: {error}"); + } +} +``` + +## 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 using `SubscribeToAllTables`. + +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. + +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. + +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 +/// 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(); +} +``` + +## 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. + +To `Program.cs`, add: + +```csharp +/// Our `OnSubscriptionApplied` callback: +/// sort all past messages and print them in timestamp order. +void OnSubscriptionApplied(SubscriptionEventContext ctx) +{ + Console.WriteLine("Connected"); + PrintMessagesInOrder(ctx.Db); +} + +void PrintMessagesInOrder(RemoteTables tables) +{ + 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 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 database. + +To `Program.cs`, add: + +```csharp +/// 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) +{ + try + { + // loop until cancellation token + while (!ct.IsCancellationRequested) + { + conn.FrameTick(); + + ProcessCommands(conn.Reducers); + + Thread.Sleep(100); + } + } + finally + { + conn.Disconnect(); + } +} +``` + +## 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. + +Supported Commands: + +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 database 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) + { + var input = Console.ReadLine(); + if (input == null) + { + break; + } + + if (input.StartsWith("/name ")) + { + input_queue.Enqueue(("name", input[6..])); + continue; + } + else + { + input_queue.Enqueue(("message", input)); + } + } +} + +void ProcessCommands(RemoteReducers reducers) +{ + // process input queue commands + while (input_queue.TryDequeue(out var command)) + { + switch (command.Command) + { + case "message": + reducers.SendMessage(command.Args); + break; + case "name": + reducers.SetName(command.Args); + break; + } + } +} +``` + +## Run the client + +Finally, we just need to add a call to `Main`. + +To `Program.cs`, add: + +```csharp +Main(); +``` + +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 +``` + +## What's next? + +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-chat/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. diff --git a/docs/Client SDK Languages/index.md b/docs/sdks/index.md similarity index 76% rename from docs/Client SDK Languages/index.md rename to docs/sdks/index.md index 27c9284f..ad9c082b 100644 --- a/docs/Client SDK Languages/index.md +++ b/docs/sdks/index.md @@ -1,11 +1,10 @@ -# 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 -- [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) ## Key Features @@ -13,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 @@ -33,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 @@ -55,7 +54,7 @@ The familiarity of your development team with a particular language can greatly ### Application Type -Different languages are often better suited to different types of applications. For instance, if you are developing a web-based application, you might opt for TypeScript due to its seamless integration with web technologies. On the other hand, if you're developing a desktop application, you might choose C# or Python, depending on your requirements and platform. Python is also very useful for utility scripts and tools. +Different languages are often better suited to different types of applications. For instance, if you are developing a web-based application, you might opt for TypeScript due to its seamless integration with web technologies. On the other hand, if you're developing a desktop application, you might choose C#, depending on your requirements and platform. ### Performance @@ -71,4 +70,4 @@ Each language has its own ecosystem of libraries and tools that can help in deve Remember, the best language to use is the one that best fits your use case and the one you and your team are most comfortable with. It's worth noting that due to the consistent functionality across different SDKs, transitioning from one language to another should you need to in the future will primarily involve syntax changes rather than changes in the application's logic. -You may want to use multiple languages in your application. For instance, you might want to use C# in Unity for your game logic, TypeScript for a web-based administration panel, and Python for utility scripts. This is perfectly fine, as the SpacetimeDB server is completely client-agnostic. +You may want to use multiple languages in your application. For instance, you might want to use C# in Unity for your game logic and TypeScript for a web-based administration panel. This is perfectly fine, as the SpacetimeDB server is completely client-agnostic. diff --git a/docs/sdks/rust/index.md b/docs/sdks/rust/index.md new file mode 100644 index 00000000..4c180f52 --- /dev/null +++ b/docs/sdks/rust/index.md @@ -0,0 +1,937 @@ +# 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. + +| 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: + +```bash +cargo add 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 `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; +``` + +## Type `DbConnection` + +```rust +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. + +| Name | Description | +|------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------| +| [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 database + +```rust +impl DbConnection { + fn builder() -> DbConnectionBuilder; +} +``` + +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 | +|-----------------------------------------------------------|--------------------------------------------------------------------------------------| +| [`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` + +```rust +impl DbConnectionBuilder { + fn with_uri(self, uri: impl TryInto) -> Self; +} +``` + +Configure the URI of the SpacetimeDB instance or cluster which hosts the remote database containing the module. + +#### Method `with_module_name` + +```rust +impl DbConnectionBuilder { + fn with_module_name(self, name_or_identity: impl ToString) -> Self; +} +``` + +Configure the SpacetimeDB domain name or `Identity` of the remote database which identifies it within the SpacetimeDB instance or cluster. + +#### Callback `on_connect` + +```rust +impl DbConnectionBuilder { + fn on_connect(self, callback: impl FnOnce(&DbConnection, Identity, &str)) -> 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 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. + +#### Callback `on_connect_error` + +```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(&ErrorContext, Option), + ) -> DbConnectionBuilder; +} +``` + +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` + +```rust +impl DbConnectionBuilder { + fn with_token(self, token: Option>) -> Self; +} +``` + +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. + + +#### Method `build` + +```rust +impl DbConnectionBuilder { + fn build(self) -> Result; +} +``` + +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 | +|-----------------------------------------------|-------------------------------------------------------| +| [`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 { + fn run_threaded(&self) -> std::thread::JoinHandle<()>; +} +``` + +`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). + +#### Method `run_async` + +```rust +impl DbConnection { + 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). + +#### Method `frame_tick` + +```rust +impl DbConnection { + 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` + +```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) -> spacetimedb_sdk::Result<()>; +} +``` + +Gracefully close the `DbConnection`. Returns an `Err` if the connection is already disconnected. + +### Subscribe to queries + +| Name | Description | +|---------------------------------------------------------|-------------------------------------------------------------| +| [`SubscriptionBuilder` type](#type-subscriptionbuilder) | Builder-pattern constructor to register subscribed queries. | +| [`SubscriptionHandle` type](#type-subscriptionhandle) | Manage an active subscripion. | + +#### 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 { + fn subscription_builder(&self) -> SubscriptionBuilder; +} +``` + +Subscribe to queries by calling `ctx.subscription_builder()` and chaining configuration methods, then calling `.subscribe(queries)`. + +##### 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_error(self, callback: impl FnOnce(&ErrorContext, spacetimedb_sdk::Error)) -> Self; +} +``` + +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` + +```rust +impl SubscriptionBuilder { + fn subscribe(self, queries: impl IntoQueries) -> SubscriptionHandle; +} +``` + +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. + +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. + +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` + +```rust +trait DbContext { + fn identity(&self) -> Identity; +} +``` + +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. + +#### Method `try_identity` + +```rust +trait DbContext { + fn try_identity(&self) -> Option; +} +``` + +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 +trait DbContext { + fn is_active(&self) -> bool; +} +``` + +`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. + +## Type `EventContext` + +```rust +module_bindings::EventContext +``` + +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` + +```rust +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 +spacetimedb_sdk::Event::Reducer(spacetimedb_sdk::ReducerEvent) +``` + +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. + +#### Variant `SubscribeApplied` + +```rust +spacetimedb_sdk::Event::SubscribeApplied +``` + +Event when our subscription is applied and its rows are inserted into the client cache. + +This event is passed to [row `on_insert` callbacks](#callback-on_insert) resulting from the new subscription. + +#### Variant `UnsubscribeApplied` + +```rust +spacetimedb_sdk::Event::UnsubscribeApplied +``` + +Event when our subscription is removed after a call to [`SubscriptionHandle::unsubscribe`](#method-unsubscribe) or [`SubscriptionHandle::unsubscribe_then`](#method-unsubscribe_then) and its matching rows are deleted from the client cache. + +This event is passed to [row `on_delete` callbacks](#callback-on_delete) resulting from the subscription ending. + +#### Variant `SubscribeError` + +```rust +spacetimedb_sdk::Event::SubscribeError(spacetimedb_sdk::Error) +``` + +Event when a subscription ends unexpectedly due to an error. + +This event is passed to [row `on_delete` callbacks](#callback-on_delete) resulting from the subscription ending. + +#### Variant `UnknownTransaction` + +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. + +### Struct `ReducerEvent` + +```rust +spacetimedb_sdk::ReducerEvent +``` + +A `ReducerEvent` contains metadata about a reducer run. + +```rust +struct spacetimedb_sdk::ReducerEvent { + /// The time at which the reducer was invoked. + timestamp: SystemTime, + + /// Whether the reducer committed, was aborted due to insufficient energy, or failed with an error message. + status: Status, + + /// The `Identity` of the SpacetimeDB actor which invoked the reducer. + caller_identity: Identity, + + /// 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.) + /// + /// May be `None` if the module is configured not to broadcast energy consumed. + energy_consumed: Option, + + /// The `Reducer` enum defined by the `module_bindings`, which encodes which reducer ran and its arguments. + reducer: R, + + // ...private fields +} +``` + +### Enum `Status` + +```rust +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 +spacetimedb_sdk::Status::Committed +``` + +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). + +#### Variant `Failed` + +```rust +spacetimedb_sdk::Status::Failed(Box) +``` + +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. + +#### Variant `OutOfEnergy` + +The reducer was aborted due to insufficient energy balance of the module owner. + +### Enum `Reducer` + +```rust +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 + +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 +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 +trait spacetimedb_sdk::Table { + type Table::Row; +} +``` + +The type of rows in the table. + +#### Method `count` + +```rust +trait spacetimedb_sdk::Table { + fn count(&self) -> u64; +} +``` + +Returns the number of rows of this table resident in the client cache, i.e. the total number which match any subscribed query. + +#### Method `iter` + +```rust +trait spacetimedb_sdk::Table { + fn iter(&self) -> impl Iterator; +} +``` + +An iterator over all the subscribed rows in the client cache, i.e. those which match any subscribed query. + +#### Callback `on_insert` + +```rust +trait spacetimedb_sdk::Table { + type InsertCallbackId; + + fn on_insert(&self, callback: impl FnMut(&EventContext, &Self::Row)) -> Self::InsertCallbackId; + + fn remove_on_insert(&self, callback: Self::InsertCallbackId); +} +``` + +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. + +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. + +#### Callback `on_delete` + +```rust +trait spacetimedb_sdk::Table { + type DeleteCallbackId; + + fn on_delete(&self, callback: impl FnMut(&EventContext, &Self::Row)) -> Self::DeleteCallbackId; + + fn remove_on_delete(&self, callback: Self::DeleteCallbackId); +} +``` + +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::TableWithPrimaryKey +``` + +Implemented for table handles whose tables have a primary key. + +| 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 { + type UpdateCallbackId; + + fn on_update(&self, callback: impl FnMut(&EventContext, &Self::Row, &Self::Row)) -> Self::UpdateCallbackId; + + fn remove_on_update(&self, callback: Self::UpdateCallbackId); +} +``` + +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. + +### Unique constraint index access + +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. + +### BTree index access + +The SpacetimeDB Rust client SDK does not support non-unique BTree indexes. + +## Observe and invoke reducers + +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, 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` + +```rust +spacetimedb_sdk::Identity +``` + +A unique public identifier for a client connected to a database. + +### Type `ConnectionId` + +```rust +spacetimedb_sdk::ConnectionId +``` + +An opaque identifier for a client connection to a database, intended to differentiate between connections from the same [`Identity`](#type-identity). diff --git a/docs/sdks/rust/quickstart.md b/docs/sdks/rust/quickstart.md new file mode 100644 index 00000000..21f4d947 --- /dev/null +++ b/docs/sdks/rust/quickstart.md @@ -0,0 +1,518 @@ +# Rust Client SDK Quick Start + +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. + +## Project structure + +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 +``` + +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 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: + +```toml +spacetimedb-sdk = "1.0" +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 referenced by tables or reducers 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 a few files: + +``` +module_bindings/ +├── identity_connected_reducer.rs +├── identity_disconnected_reducer.rs +├── message_table.rs +├── message_type.rs +├── mod.rs +├── send_message_reducer.rs +├── set_name_reducer.rs +├── user_table.rs +└── user_type.rs +``` + +To use these, we'll declare the module in our client crate and import its definitions. + +To `client/src/main.rs`, add: + +```rust +mod module_bindings; +use module_bindings::*; +``` + +## Add more imports + +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::{credentials, DbContext, Error, Event, Identity, Status, Table, TableWithPrimaryKey}; +``` + +## Define the main function + +Our `main` function will do the following: +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 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(&ctx); +} +``` + +## Connect to the database + +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. 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 database is running. + +To `client/src/main.rs`, add: + +```rust +/// 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. +const DB_NAME: &str = "quickstart-chat"; + +/// 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 + +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. + +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(_ctx: &DbConnection, _identity: Identity, token: &str) { + if let Err(e) = creds_store().save(token) { + eprintln!("Failed to save credentials: {:?}", e); + } +} +``` + +### Handle errors and disconnections + +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(_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(_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 + +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 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: 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. + +To `client/src/main.rs`, add: + +```rust +/// Our `User::on_insert` callback: +/// if the user is online, print a notification. +fn on_user_inserted(_ctx: &EventContext, user: &User) { + 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(|| user.identity.to_hex().to_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. + +`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: + +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(_ctx: &EventContext, old: &User, new: &User) { + 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 `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. + +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`. + +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: + +```rust +/// Our `Message::on_insert` callback: print new messages. +fn on_message_inserted(ctx: &EventContext, message: &Message) { + if let Event::Reducer(_) = ctx.event { + print_message(ctx, message) + } +} + +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); +} +``` + +### 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 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: + +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 `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. + + +To `client/src/main.rs`, add: + +```rust +/// Our `on_set_name` callback: print a warning if the reducer failed. +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: &ReducerEventContext, text: &String) { + if let Status::Failed(err) = &ctx.event.status { + eprintln!("Failed to send message {:?}: {}", text, err); + } +} +``` + +## 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. + +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 +/// 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"]); +} +``` + +### 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_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!"); +} +``` + +### Notify about failed subscriptions + +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. + +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 +/// 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); +} +``` + +## Handle user input + +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. + +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(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 ") { + ctx.reducers.set_name(name.to_string()).unwrap(); + } else { + ctx.reducers.send_message(line).unwrap(); + } + } +} +``` + +## Run it + +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 +cargo run +``` + +You should see something like: + +``` +User d9e25c51996dea2f connected. +``` + +Now try sending a message by typing `Hello, world!` and pressing enter. You should see: + +``` +d9e25c51996dea2f: Hello, world! +``` + +Next, set your name by typing `/name `, replacing `` with your desired username. You should see: + +``` +User d9e25c51996dea2f renamed to . +``` + +Then, send another message: + +``` +: Hello after naming myself. +``` + +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. +: Hello, world! +: Hello after naming myself. +``` + +## What's next? + +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 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. + +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. diff --git a/docs/sdks/typescript/index.md b/docs/sdks/typescript/index.md new file mode 100644 index 00000000..ef55ed1e --- /dev/null +++ b/docs/sdks/typescript/index.md @@ -0,0 +1,884 @@ +# 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. + +| 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: + +```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 PATH-TO-MODULE-DIRECTORY +``` + +Import the `module_bindings` in your client's _main_ file: + +```typescript +import * as moduleBindings from './module_bindings/index'; +``` + +You may also need to import some definitions from the SDK library: + +```typescript +import { + Identity, ConnectionId, Event, ReducerEvent +} from '@clockworklabs/spacetimedb-sdk'; +``` + +## Type `DbConnection` + +```typescript +DbConnection +``` + +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. + +| Name | Description | +|-----------------------------------------------------------|--------------------------------------------------------------------------------------------------| +| [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 database + +```typescript +class DbConnection { + public static builder(): DbConnectionBuilder +} +``` + +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 | +|-------------------------------------------------------|--------------------------------------------------------------------------------------| +| [`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. | + +#### Method `withUri` + +```typescript +class DbConnectionBuilder { + public withUri(uri: string): DbConnectionBuilder +} +``` + +Configure the URI of the SpacetimeDB instance or cluster which hosts the remote database. + +#### Method `withModuleName` + +```typescript +class DbConnectionBuilder { + public withModuleName(name_or_identity: string): DbConnectionBuilder +} + +``` + +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` + +```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 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` + +```typescript +class DbConnectionBuilder { + public onConnectError( + callback: (ctx: ErrorContext, error: Error) => void + ): DbConnectionBuilder +} +``` + +Chain a call to `.onConnectError(callback)` to your builder to register a callback to run when your connection fails. + +#### Callback `onDisconnect` + +```typescript +class DbConnectionBuilder { + public onDisconnect( + callback: (ctx: ErrorContext, error: Error | null) => void + ): DbConnectionBuilder +} +``` + +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` + +```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. + + +#### Method `build` + +```typescript +class DbConnectionBuilder { + public build(): DbConnection +} +``` + +After configuring the connection and registering callbacks, attempt to open the connection. + +### Access tables and reducers + +#### Field `db` + +```typescript +class DbConnection { + public db: RemoteTables +} +``` + +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` + +```typescript +class DbConnection { + public reducers: RemoteReducers +} +``` + +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 `DbContext` + +```typescript +interface DbContext< + DbView, + Reducers, +> +``` + +[`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. + +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. + +| 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` | + +#### Field `db` + +```typescript +interface DbContext { + db: DbView +} +``` + +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). + +#### Field `reducers` + +```typescript +interface DbContext { + reducers: Reducers +} +``` + +The `reducers` field of a `DbContext` provides access to reducers exposed by the remote module. See [Observe and invoke reducers](#observe-and-invoke-reducers). + +### Method `disconnect` + +```typescript +interface DbContext { + disconnect(): void +} +``` + +Gracefully close the `DbConnection`. Throws an error if the connection is already disconnected. + +### Subscribe to queries + +| Name | Description | +|---------------------------------------------------------|-------------------------------------------------------------| +| [`SubscriptionBuilder` type](#type-subscriptionbuilder) | Builder-pattern constructor to register subscribed queries. | +| [`SubscriptionHandle` type](#type-subscriptionhandle) | Manage an active subscripion. | + +#### Type `SubscriptionBuilder` + +```typescript +SubscriptionBuilder +``` + +| 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. | + +##### Constructor `ctx.subscriptionBuilder()` + +```typescript +interface DbContext { + subscriptionBuilder(): SubscriptionBuilder +} +``` + +Subscribe to queries by calling `ctx.subscription_builder()` and chaining configuration methods, then calling `.subscribe(queries)`. + +##### Callback `onApplied` + +```typescript +class SubscriptionBuilder { + public onApplied( + callback: (ctx: SubscriptionEventContext) => void + ): SubscriptionBuilder +} +``` + +Register a callback to run when the subscription is applied and the matching rows are inserted into the client cache. + +##### Callback `onError` + +```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). + + +##### Method `subscribe` + +```typescript +class SubscriptionBuilder { + subscribe(queries: string | string[]): SubscriptionHandle +} +``` + +Subscribe to a set of queries. + +See [the SpacetimeDB SQL Reference](/docs/sql#subscriptions) for information on the queries SpacetimeDB supports as subscriptions. + +##### Method `subscribeToAllTables` + +```typescript +class SubscriptionBuilder { + subscribeToAllTables(): void +} +``` + +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. + +#### Type `SubscriptionHandle` + +```typescript +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 | +|-----------------------------------------------------|------------------------------------------------------------------------------------------------------------------| +| [`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. | + +##### Method `isEnded` + +```typescript +class SubscriptionHandle { + public isEnded(): bool +} +``` + +Returns true if this subscription has been terminated due to an unsubscribe call or an error. + +##### Method `isActive` + +```typescript +class SubscriptionHandle { + public isActive(): bool +} +``` + +Returns true if this subscription has been applied and has not yet been unsubscribed. + +##### Method `unsubscribe` + +```typescript +class SubscriptionHandle { + public unsubscribe(): void +} +``` + +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. + +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. + +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. + +##### Method `unsubscribeThen` + +```typescript +class SubscriptionHandle { + public unsubscribeThen( + on_end: (ctx: SubscriptionEventContext) => void + ): void +} +``` + +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. + +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. + +### Read connection metadata + +#### Field `isActive` + +```typescript +interface DbContext { + isActive: bool +} +``` + +`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. + +## Type `EventContext` + +```typescript +EventContext +``` + +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 | 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. | + +### Field `event` + +```typescript +class EventContext { + public event: Event +} +/* other fields */ + +``` + +The [`Event`](#type-event) contained in the `EventContext` describes what happened to cause the current row callback to be invoked. + +### Field `db` + +```typescript +class EventContext { + public db: RemoteTables +} +``` + +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` + +```typescript +class EventContext { + public reducers: RemoteReducers +} +``` + +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 `Event` + +```rust +type Event = + | { tag: 'Reducer'; value: ReducerEvent } + | { tag: 'SubscribeApplied' } + | { tag: 'UnsubscribeApplied' } + | { tag: 'Error'; value: Error } + | { tag: 'UnknownTransaction' }; +``` + +| 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. | + +#### Variant `Reducer` + +```typescript +{ tag: 'Reducer'; value: ReducerEvent } +``` + +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. + +#### Variant `SubscribeApplied` + +```typescript +{ tag: 'SubscribeApplied' } +``` + +Event when our subscription is applied and its rows are inserted into the client cache. + +This event is passed to [row `onInsert` callbacks](#callback-oninsert) resulting from the new subscription. + +#### Variant `UnsubscribeApplied` + +```typescript +{ tag: 'UnsubscribeApplied' } +``` + +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. + +#### Variant `Error` + +```typescript +{ tag: 'Error'; value: Error } + +``` + +Event when a subscription ends unexpectedly due to an error. + +This event is passed to [row `onDelete` callbacks](#callback-ondelete) resulting from the subscription ending. + +#### Variant `UnknownTransaction` + +```typescript +{ tag: 'UnknownTransaction' } +``` + +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. + +### Type `ReducerEvent` + +A `ReducerEvent` contains metadata about a reducer run. + +```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` + +```typescript +type UpdateStatus = + | { tag: 'Committed'; value: __DatabaseUpdate } + | { tag: 'Failed'; value: string } + | { tag: 'OutOfEnergy' }; +``` + +| 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` + +```typescript +{ tag: 'Committed' } +``` + +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). + +#### Variant `Failed` + +```typescript +{ tag: 'Failed'; value: string } +``` + +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. + +#### Variant `OutOfEnergy` + +```typescript +{ tag: 'OutOfEnergy' } +``` + +The reducer was aborted due to insufficient energy balance of the module owner. + +### Type `Reducer` + +```rust +type Reducer = + | { name: 'ReducerA'; args: ReducerA } + | { name: 'ReducerB'; args: ReducerB } +``` + +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. + +## Type `ReducerEventContext` + +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). + +| 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. | + +### Field `event` + +```typescript +class ReducerEventContext { + public event: ReducerEvent +} +``` + +The [`ReducerEvent`](#type-reducerevent) contained in the `ReducerEventContext` has metadata about the reducer which ran. + +### Field `db` + +```typescript +class ReducerEventContext { + public db: RemoteTables +} +``` + +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` + +```typescript +class ReducerEventContext { + public reducers: RemoteReducers +} +``` + +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`](#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. + +| 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` + +```typescript +class SubscriptionEventContext { + public db: RemoteTables +} +``` + +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` + +```typescript +class SubscriptionEventContext { + public reducers: RemoteReducers +} +``` + +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`](#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. | + + +### Field `event` + +```typescript +class ErrorContext { + public event: Error +} +``` + +### Field `db` + +```typescript +class ErrorContext { + public db: RemoteTables +} +``` + +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` + +```typescript +class ErrorContext { + public reducers: RemoteReducers +} +``` + +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 + +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. + +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 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. | + +### Accessing rows + +#### Method `count` + +```typescript +class TableHandle { + public count(): number +} +``` + +Returns the number of rows of this table resident in the client cache, i.e. the total number which match any subscribed query. + +#### Method `iter` + +```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. + +The `Row` type will be an autogenerated type which matches the row type defined by the module. + +### Callback `onInsert` + +```typescript +class TableHandle { + public onInsert( + callback: (ctx: EventContext, row: Row) => void + ): void; + + public removeOnInsert( + callback: (ctx: EventContext, row: Row) => void + ): 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. + +`removeOnInsert` may be used to un-register a previously-registered `onInsert` callback. + +### Callback `onDelete` + +```typescript +class TableHandle { + public onDelete( + callback: (ctx: EventContext, row: Row) => void + ): void; + + public removeOnDelete( + callback: (ctx: EventContext, row: Row) => void + ): void; +} +``` + +The `onDelete` callback runs whenever a previously-resident row is deleted from the client cache. + +The `Row` type will be an autogenerated type which matches the row type defined by the module. + +`removeOnDelete` may be used to un-register a previously-registered `onDelete` callback. + +### Callback `onUpdate` + +```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; +} +``` + +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. + +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. + +The `Row` type will be an autogenerated type which matches the row type defined by the module. + +`removeOnUpdate` may be used to un-register a previously-registered `onUpdate` callback. + +### Unique constraint index access + +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. + +### BTree index access + +The SpacetimeDB TypeScript client SDK does not support non-unique BTree indexes. + +## Observe and invoke reducers + +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. + +Each reducer defined by the module has three methods on the `.reducers`: + +- 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. + +## Identify a client + +### Type `Identity` + +```rust +Identity +``` + +A unique public identifier for a client connected to a database. + +### Type `ConnectionId` + +```rust +ConnectionId +``` + +An opaque identifier for a client connection to a database, intended to differentiate between connections from the same [`Identity`](#type-identity). diff --git a/docs/sdks/typescript/quickstart.md b/docs/sdks/typescript/quickstart.md new file mode 100644 index 00000000..1e1151ff --- /dev/null +++ b/docs/sdks/typescript/quickstart.md @@ -0,0 +1,675 @@ +# TypeScript Client SDK Quickstart + +In this guide, you'll learn how to use TypeScript to create a SpacetimeDB client application. + +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` database created in the above module quickstart guides. + +## Project structure + +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 +``` + +Within it, create a `client` React app: + +```bash +pnpm create vite@latest client -- --template react-ts +cd client +pnpm install +``` + +We also need to install the `spacetime-client-sdk` package: + +```bash +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. + +You can now `pnpm run dev` to see the Vite template app running at `http://localhost:5173`. + +## Basic layout + +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. + +Replace the entire contents of `client/src/App.tsx` with the following: + +```tsx +import React, { useEffect, useState } from 'react'; +import './App.css'; + +export type PrettyMessage = { + senderName: string; + text: string; +}; + +function App() { + const [newName, setNewName] = useState(''); + const [settingName, setSettingName] = useState(false); + const [systemMessage, setSystemMessage] = useState(''); + const [newMessage, setNewMessage] = useState(''); + + const prettyMessages: PrettyMessage[] = []; + + const name = ''; + + const onSubmitNewName = (e: React.FormEvent) => { + e.preventDefault(); + setSettingName(false); + // TODO: Call `setName` reducer + }; + + const onMessageSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setNewMessage(''); + // TODO: Call `sendMessage` reducer + }; + + return ( +
+
+

Profile

+ {!settingName ? ( + <> +

{name}

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

Messages

+ {prettyMessages.length < 1 &&

No messages

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

+ {message.senderName} +

+

{message.text}

+
+ ))} +
+
+
+

System

+
+

{systemMessage}

+
+
+
+
+

New Message

+ + + +
+
+ ); +} + +export default App; +``` + +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; +} + +/* ----- 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); +} + +.profile h1 { + margin-right: auto; /* pushes name/edit form to the right */ +} + +.profile form { + display: flex; + flex-grow: 1; + align-items: center; + gap: 0.5rem; + max-width: 300px; +} + +.profile form input { + background-color: var(--textbox-color); +} + +/* ----- 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; +} + +.message h1 { + margin-right: 0.5rem; +} + +/* ----- 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; +} + +/* ----- 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); +} + +.new-message form { + display: flex; + flex-direction: column; + gap: 0.75rem; + width: 100%; + max-width: 600px; +} + +.new-message form h3 { + margin-bottom: 0.25rem; +} + +/* 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); +} + +@media (prefers-color-scheme: dark) { + .new-message form textarea { + box-shadow: 0 0 0 1px #17492b; + } +} +``` + +Next we need to replace the global styles in `client/src/index.css` as well: + +```css +/* ----- CSS Reset & Global Settings ----- */ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +/* ----- Color Variables ----- */ +:root { + --theme-color: #3dc373; + --theme-color-contrast: #08180e; + --textbox-color: #edfef4; + color-scheme: light dark; +} + +@media (prefers-color-scheme: dark) { + :root { + --theme-color: #4cf490; + --theme-color-contrast: #132219; + --textbox-color: #0f311d; + } +} + +/* ----- Page Setup ----- */ +html, +body, +#root { + height: 100%; + margin: 0; +} + +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; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', + monospace; +} + +/* ----- 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; +} + +/* ----- 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; +} + +input:focus, +textarea:focus { + outline: none; + box-shadow: 0 0 0 2px var(--theme-color); +} +``` + +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. + +## 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 +``` + +> 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. + +Take a look inside `client/src/module_bindings`. The CLI should have generated several files: + +``` +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 +``` + +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 { 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 database. + +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 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, + identity: Identity, + token: string + ) => { + setIdentity(identity); + setConnected(true); + localStorage.setItem('auth_token', token); + console.log( + 'Connected to SpacetimeDB with identity:', + identity.toHexString() + ); + conn.reducers.onSendMessage(() => { + console.log('Message sent.'); + }); + + subscribeToQueries(conn, ['SELECT * FROM message', 'SELECT * FROM user']); + }; + + const onDisconnect = () => { + console.log('Disconnected from SpacetimeDB'); + setConnected(false); + }; + + const onConnectError = (_ctx: ErrorContext, err: Error) => { + console.log('Error connecting to SpacetimeDB:', err); + }; + + setConn( + DbConnection.builder() + .withUri('ws://localhost:3000') + .withModuleName('quickstart-chat') + .withToken(localStorage.getItem('auth_token') || '') + .onConnect(onConnect) + .onDisconnect(onDisconnect) + .onConnectError(onConnectError) + .build() + ); + }, []); +``` + +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 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. + +### 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. + +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[] { + const [messages, setMessages] = useState([]); + + 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); + + return () => { + conn.db.message.removeOnInsert(onInsert); + conn.db.message.removeOnDelete(onDelete); + }; + }, [conn]); + + return messages; +} + +function useUsers(conn: DbConnection | null): Map { + const [users, setUsers] = useState>(new Map()); + + 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); + + 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); + + const onDelete = (_ctx: EventContext, user: User) => { + setUsers(prev => { + prev.delete(user.identity.toHexString()); + return new Map(prev); + }); + }; + conn.db.user.onDelete(onDelete); + + return () => { + conn.db.user.removeOnInsert(onInsert); + conn.db.user.removeOnUpdate(onUpdate); + conn.db.user.removeOnDelete(onDelete); + }; + }, [conn]); + + return users; +} +``` + +These custom React hooks update the React state anytime a row in our tables change, causing React to rerender. + +> 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. + +Let's add these hooks to our `App` component just below our connection setup: + +```tsx + const messages = useMessages(conn); + const users = useUsers(conn); +``` + +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, + })); +``` + +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. + +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: + +```tsx + if (!conn || !connected || !identity) { + return ( +
+

Connecting...

+
+ ); + } +``` + +Finally, let's also compute the name of the user from the `Identity` in our `name` variable. Replace `const name = '';` with the following: + +```tsx + const name = + users.get(identity?.toHexString())?.name || + identity?.toHexString().substring(0, 8) || + 'unknown'; +``` + +### Calling Reducers + +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. + +Modify the `onSubmitNewName` callback by adding a call to the `setName` reducer: + +```tsx + const onSubmitNewName = (e: React.FormEvent) => { + e.preventDefault(); + setSettingName(false); + conn.reducers.setName(newName); + }; +``` + +Next modify the `onSubmitMessage` callback by adding a call to the `sendMessage` reducer: + +```tsx + const onMessageSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setNewMessage(""); + conn.reducers.sendMessage(newMessage); + }; +``` + +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. + +Let's try out our app to see the result of these changes. + +```sh +cd client +pnpm run dev +``` + +> Don't forget! You may need to publish your server module if you haven't yet. + +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. + +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 database. + +Note that 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. + +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]); +``` + +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. + +## Conclusion + +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. + +## 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. diff --git a/docs/sql/index.md b/docs/sql/index.md new file mode 100644 index 00000000..807af409 --- /dev/null +++ b/docs/sql/index.md @@ -0,0 +1,648 @@ +# SQL Support + +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. + +## Subscriptions + +```ebnf +SELECT projection FROM relation [ WHERE predicate ] +``` + +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. + +There is no context for manually updating this view. +Hence data manipulation commands like `INSERT` and `DELETE` are not supported. + +> 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. + +### SELECT + +```ebnf +SELECT ( '*' | 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. + +A `*` projection is allowed when the table is unambiguous, +otherwise it must be qualified with the appropriate table name. + +#### Examples + +```sql +-- Subscribe to all rows of a table +SELECT * FROM Inventory + +-- Qualify the `*` projection with the table +SELECT item.* from Inventory item + +-- 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 + +-- 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 +``` + +### FROM + +```ebnf +FROM table [ [AS] alias ] [ [INNER] JOIN table [ [AS] alias ] ON column '=' column ] +``` + +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. + +Subscriptions do not support joins of more than two tables. + +For any column referenced in `ON` clause of a `JOIN`, +it must be qualified with the appropriate table name or alias. + +In order for a `JOIN` to be evaluated efficiently, +subscriptions require an index to be defined on both join columns. + +#### Example + +```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 +``` + +### WHERE + +```ebnf +predicate + = expr + | predicate AND predicate + | predicate OR predicate + ; + +expr + = literal + | column + | expr op expr + ; + +op + = '=' + | '<' + | '>' + | '<' '=' + | '>' '=' + | '!' '=' + | '<' '>' + ; + +literal + = INTEGER + | STRING + | HEX + | TRUE + | FALSE + ; +``` + +While the `SELECT` clause determines the table, +the `WHERE` clause determines the rows in the subscription. + +Arithmetic expressions are not supported. + +#### Examples + +```sql +-- Find products that sell for more than $X +SELECT * FROM Inventory WHERE price > {X} + +-- 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} +``` + +## Query and DML (Data Manipulation Language) + +### Statements + +- [SELECT](#select-1) +- [INSERT](#insert) +- [DELETE](#delete) +- [UPDATE](#update) +- [SET](#set) +- [SHOW](#show) + +### SELECT + +```ebnf +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 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, +but the query language has no such constraints or limitations. + +#### SELECT Clause + +```ebnf +projection + = '*' + | table '.' '*' + | projExpr { ',' projExpr } + | aggExpr + ; + +projExpr + = column [ [ AS ] alias ] + ; + +aggExpr + = COUNT '(' '*' ')' [AS] alias + ; +``` + +The `SELECT` clause determines the columns that are returned. + +##### Examples + +```sql +-- Select the items in my inventory +SELECT * FROM Inventory; + +-- Select the names and prices of the items in my 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 +FROM table [ [AS] alias ] { [INNER] JOIN table [ [AS] alias ] ON predicate } +``` + +Unlike [subscriptions](#from), the query api supports joining more than two tables. + +##### Examples + +```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} +``` + +#### WHERE Clause + +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 +INSERT INTO table [ '(' column { ',' column } ')' ] VALUES '(' literal { ',' literal } ')' +``` + +#### Examples + +```sql +-- Inserting one row +INSERT INTO Inventory (item_id, item_name) VALUES (1, 'health1'); + +-- Inserting two rows +INSERT INTO Inventory (item_id, item_name) VALUES (1, 'health1'), (2, 'health2'); +``` + +### DELETE + +```ebnf +DELETE FROM table [ WHERE predicate ] +``` + +Deletes all rows from a table. +If `WHERE` is specified, only the matching rows are deleted. + +`DELETE` does not support joins. + +#### Examples + +```sql +-- Delete all rows +DELETE FROM Inventory; + +-- Delete all rows with a specific item_id +DELETE FROM Inventory WHERE item_id = 1; +``` + +### UPDATE + +```ebnf +UPDATE table SET [ '(' assignment { ',' assignment } ')' ] [ WHERE predicate ] +``` + +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. + +`UPDATE` does not support joins. + +#### Examples + +```sql +-- Update the item_name for all rows with a specific item_id +UPDATE Inventory SET item_name = 'new name' WHERE item_id = 1; +``` + +### SET + +> WARNING: The `SET` statement is experimental. +> Compatibility with future versions of SpacetimeDB is not guaranteed. + +```ebnf +SET var ( TO | '=' ) literal +``` + +Updates the value of a system variable. + +### SHOW + +> WARNING: The `SHOW` statement is experimental. +> Compatibility with future versions of SpacetimeDB is not guaranteed. + +```ebnf +SHOW var +``` + +Returns the value of a system variable. + +## System Variables + +> WARNING: System variables are experimental. +> Compatibility with future versions of SpacetimeDB is not guaranteed. + +- `row_limit` + + ```sql + -- Reject queries that scan more than 10K rows + SET row_limit = 10000 + ``` + +## Data types + +The set of data types that SpacetimeDB supports is defined by SATS, +the Spacetime Algebraic Type System. + +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. + +## Literals + +```ebnf +literal = INTEGER | FLOAT | STRING | HEX | TRUE | FALSE ; +``` + +The following describes how to construct literal values for SATS data types in Spacetime SQL. + +### Booleans + +Booleans are represented using the canonical atoms `true` or `false`. + +### Integers + +```ebnf +INTEGER + = [ '+' | '-' ] NUM + | [ '+' | '-' ] NUM 'E' [ '+' ] NUM + ; + +NUM + = DIGIT { DIGIT } + ; + +DIGIT + = 0..9 + ; +``` + +SATS supports multple fixed width integer types. +The concrete type of a literal is inferred from the context. + +#### Examples + +```sql +-- 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 +``` + +### Floats + +```ebnf +FLOAT + = [ '+' | '-' ] [ NUM ] '.' NUM + | [ '+' | '-' ] [ NUM ] '.' NUM 'E' [ '+' | '-' ] NUM + ; +``` + +SATS supports both 32 and 64 bit floating point types. +The concrete type of a literal is inferred from the context. + +#### Examples + +```sql +-- 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 +``` + +### Strings + +```ebnf +STRING + = "'" { "''" | CHAR } "'" + ; +``` + +`CHAR` is defined as a `utf-8` encoded unicode character. + +#### Examples + +```sql +SELECT * FROM Customers WHERE first_name = 'John' +``` + +### Hex + +```ebnf +HEX + = 'X' "'" { HEXIT } "'" + | '0' 'x' { HEXIT } + ; + +HEXIT + = DIGIT | a..f | A..F + ; +``` + +Hex literals can represent [Identity], [ConnectionId], or binary types. +The type is ultimately inferred from the context. + +#### Examples + +```sql +SELECT * FROM Program WHERE hash_value = 0xABCD1234 +``` + +## Identifiers + +```ebnf +identifier + = LATIN { LATIN | DIGIT | '_' } + | '"' { '""' | CHAR } '"' + ; + +LATIN + = a..z | A..Z + ; +``` + +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. + +### Example + +```sql +-- `ORDER` is a sql keyword and therefore needs to be quoted +SELECT * FROM "Order" + +-- A table containing `$` needs to be quoted as well +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. + +```ebnf +table + = identifier + ; + +alias + = identifier + ; + +var + = identifier + ; + +column + = identifier + | identifier '.' identifier + ; +``` + + +[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 +[ConnectionId]: /docs/index.md#connectionid diff --git a/docs/subscriptions/index.md b/docs/subscriptions/index.md new file mode 100644 index 00000000..a896f6a6 --- /dev/null +++ b/docs/subscriptions/index.md @@ -0,0 +1,446 @@ +# The SpacetimeDB Subscription API + +The subscription API allows a client to replicate a subset of a database. +It does so by registering SQL queries, which we call subscriptions, through a database connection. +A client will only receive updates for rows that match the subscriptions it has registered. + +For more information on syntax and requirements see the [SQL docs](/docs/sql#subscriptions). + +This guide describes the two main interfaces that comprise the API - `SubscriptionBuilder` and `SubscriptionHandle`. +By using these interfaces, you can create efficient and responsive client applications that only receive the data they need. + +## SubscriptionBuilder + +:::server-rust +```rust +pub struct SubscriptionBuilder { /* private fields */ } + +impl SubscriptionBuilder { + /// Register a callback that runs when the subscription has been applied. + /// This callback receives a context containing the current state of the subscription. + pub fn on_applied(mut self, callback: impl FnOnce(&M::SubscriptionEventContext) + Send + 'static); + + /// Register a callback to run when the subscription fails. + /// + /// Note that this callback may run either when attempting to apply the subscription, + /// in which case [`Self::on_applied`] will never run, + /// or later during the subscription's lifetime if the module's interface changes, + /// in which case [`Self::on_applied`] may have already run. + pub fn on_error(mut self, callback: impl FnOnce(&M::ErrorContext, crate::Error) + Send + 'static); + + /// Subscribe to a subset of the database via a set of SQL queries. + /// Returns a handle which you can use to monitor or drop the subscription later. + pub fn subscribe(self, query_sql: Queries) -> M::SubscriptionHandle; + + /// Subscribe to all rows from all tables. + /// + /// This method is intended as a convenience + /// for applications where client-side memory use and network bandwidth are not concerns. + /// Applications where these resources are a constraint + /// should register more precise queries via [`Self::subscribe`] + /// in order to replicate only the subset of data which the client needs to function. + pub fn subscribe_to_all_tables(self); +} + +/// Types which specify a list of query strings. +pub trait IntoQueries { + fn into_queries(self) -> Box<[Box]>; +} +``` +::: +:::server-csharp +```cs +public sealed class SubscriptionBuilder +{ + /// + /// Register a callback to run when the subscription is applied. + /// + public SubscriptionBuilder OnApplied( + Action callback + ); + + /// + /// Register a callback to run when the subscription fails. + /// + /// Note that this callback may run either when attempting to apply the subscription, + /// in which case Self::on_applied will never run, + /// or later during the subscription's lifetime if the module's interface changes, + /// in which case Self::on_applied may have already run. + /// + public SubscriptionBuilder OnError( + Action callback + ); + + /// + /// Subscribe to the following SQL queries. + /// + /// This method returns immediately, with the data not yet added to the DbConnection. + /// The provided callbacks will be invoked once the data is returned from the remote server. + /// Data from all the provided queries will be returned at the same time. + /// + /// See the SpacetimeDB SQL docs for more information on SQL syntax: + /// https://spacetimedb.com/docs/sql + /// + public SubscriptionHandle Subscribe( + string[] querySqls + ); + + /// + /// Subscribe to all rows from all tables. + /// + /// This method is intended as a convenience + /// for applications where client-side memory use and network bandwidth are not concerns. + /// Applications where these resources are a constraint + /// should register more precise queries via Self.Subscribe + /// in order to replicate only the subset of data which the client needs to function. + /// + public void SubscribeToAllTables(); +} +``` +::: + +A `SubscriptionBuilder` provides an interface for registering subscription queries with a database. +It allows you to register callbacks that run when the subscription is successfully applied or when an error occurs. +Once applied, a client will start receiving row updates to its client cache. +A client can react to these updates by registering row callbacks for the appropriate table. + +### Example Usage + +:::server-rust +```rust +// Establish a database connection +let conn: DbConnection = connect_to_db(); + +// Register a subscription with the database +let subscription_handle = conn + .subscription_builder() + .on_applied(|ctx| { /* handle applied state */ }) + .on_error(|error_ctx, error| { /* handle error */ }) + .subscribe(["SELECT * FROM user", "SELECT * FROM message"]); +``` +::: +:::server-csharp +```cs +// Establish a database connection +var conn = ConnectToDB(); + +// Register a subscription with the database +var userSubscription = conn + .SubscriptionBuilder() + .OnApplied((ctx) => { /* handle applied state */ }) + .OnError((errorCtx, error) => { /* handle error */ }) + .Subscribe(new string[] { "SELECT * FROM user", "SELECT * FROM message" }); +``` +::: + +## SubscriptionHandle + +:::server-rust +```rust +pub trait SubscriptionHandle: InModule + Clone + Send + 'static +where + Self::Module: SpacetimeModule, +{ + /// Returns `true` if the subscription has been ended. + /// That is, if it has been unsubscribed or terminated due to an error. + fn is_ended(&self) -> bool; + + /// Returns `true` if the subscription is currently active. + fn is_active(&self) -> bool; + + /// Unsubscribe from the query controlled by this `SubscriptionHandle`, + /// then run `on_end` when its rows are removed from the client cache. + /// Returns an error if the subscription is already ended, + /// or if unsubscribe has already been called. + fn unsubscribe_then(self, on_end: OnEndedCallback) -> crate::Result<()>; + + /// Unsubscribe from the query controlled by this `SubscriptionHandle`. + /// Returns an error if the subscription is already ended, + /// or if unsubscribe has already been called. + fn unsubscribe(self) -> crate::Result<()>; +} +``` +::: +:::server-csharp +```cs + public class SubscriptionHandle : ISubscriptionHandle + where SubscriptionEventContext : ISubscriptionEventContext + where ErrorContext : IErrorContext + { + /// + /// Whether the subscription has ended. + /// + public bool IsEnded; + + /// + /// Whether the subscription is active. + /// + public bool IsActive; + + /// + /// Unsubscribe from the query controlled by this subscription handle. + /// + /// Calling this more than once will result in an exception. + /// + public void Unsubscribe(); + + /// + /// Unsubscribe from the query controlled by this subscription handle, + /// and call onEnded when its rows are removed from the client cache. + /// + public void UnsubscribeThen(Action? onEnded); + } +``` +::: + +When you register a subscription, you receive a `SubscriptionHandle`. +A `SubscriptionHandle` manages the lifecycle of each subscription you register. +In particular, it provides methods to check the status of the subscription and to unsubscribe if necessary. +Because each subscription has its own independently managed lifetime, +clients can dynamically subscribe to different subsets of the database as their application requires. + +### Example Usage + +:::server-rust +Consider a game client that displays shop items and discounts based on a player's level. +You subscribe to `shop_items` and `shop_discounts` when a player is at level 5: + +```rust +let conn: DbConnection = connect_to_db(); + +let shop_items_subscription = conn + .subscription_builder() + .on_applied(|ctx| { /* handle applied state */ }) + .on_error(|error_ctx, error| { /* handle error */ }) + .subscribe([ + "SELECT * FROM shop_items WHERE required_level <= 5", + "SELECT * FROM shop_discounts WHERE required_level <= 5", + ]); +``` + +Later, when the player reaches level 6 and new items become available, +you can subscribe to the new queries and unsubscribe from the old ones: + +```rust +let new_shop_items_subscription = conn + .subscription_builder() + .on_applied(|ctx| { /* handle applied state */ }) + .on_error(|error_ctx, error| { /* handle error */ }) + .subscribe([ + "SELECT * FROM shop_items WHERE required_level <= 6", + "SELECT * FROM shop_discounts WHERE required_level <= 6", + ]); + +if shop_items_subscription.is_active() { + shop_items_subscription + .unsubscribe() + .expect("Unsubscribing from shop_items failed"); +} +``` + +All other subscriptions continue to remain in effect. +::: +:::server-csharp +Consider a game client that displays shop items and discounts based on a player's level. +You subscribe to `shop_items` and `shop_discounts` when a player is at level 5: + +```cs +var conn = ConnectToDB(); + +var shopItemsSubscription = conn + .SubscriptionBuilder() + .OnApplied((ctx) => { /* handle applied state */ }) + .OnError((errorCtx, error) => { /* handle error */ }) + .Subscribe(new string[] { + "SELECT * FROM shop_items WHERE required_level <= 5", + "SELECT * FROM shop_discounts WHERE required_level <= 5", + }); +``` + +Later, when the player reaches level 6 and new items become available, +you can subscribe to the new queries and unsubscribe from the old ones: + +```cs +var newShopItemsSubscription = conn + .SubscriptionBuilder() + .OnApplied((ctx) => { /* handle applied state */ }) + .OnError((errorCtx, error) => { /* handle error */ }) + .Subscribe(new string[] { + "SELECT * FROM shop_items WHERE required_level <= 6", + "SELECT * FROM shop_discounts WHERE required_level <= 6", + }); + +if (shopItemsSubscription.IsActive) +{ + shopItemsSubscription.Unsubscribe(); +} +``` + +All other subscriptions continue to remain in effect. +::: + +## Best Practices for Optimizing Server Compute and Reducing Serialization Overhead + +### 1. Writing Efficient SQL Queries + +For writing efficient SQL queries, see our [SQL Best Practices Guide](/docs/sql#best-practices-for-performance-and-scalability). + +### 2. Group Subscriptions with the Same Lifetime Together + +Subscriptions with the same lifetime should be grouped together. + +For example, you may have certain data that is required for the lifetime of your application, +but you may have other data that is only sometimes required by your application. + +By managing these sets as two independent subscriptions, +your application can subscribe and unsubscribe from the latter, +without needlessly unsubscribing and resubscribing to the former. + +This will improve throughput by reducing the amount of data transferred from the database to your application. + +#### Example + +:::server-rust +```rust +let conn: DbConnection = connect_to_db(); + +// Never need to unsubscribe from global subscriptions +let global_subscriptions = conn + .subscription_builder() + .subscribe([ + // Global messages the client should always display + "SELECT * FROM announcements", + // A description of rewards for in-game achievements + "SELECT * FROM badges", + ]); + +// May unsubscribe to shop_items as player advances +let shop_subscription = conn + .subscription_builder() + .subscribe([ + "SELECT * FROM shop_items WHERE required_level <= 5", + ]); +``` +::: +:::server-csharp +```cs +var conn = ConnectToDB(); + +// Never need to unsubscribe from global subscriptions +var globalSubscriptions = conn + .SubscriptionBuilder() + .Subscribe(new string[] { + // Global messages the client should always display + "SELECT * FROM announcements", + // A description of rewards for in-game achievements + "SELECT * FROM badges", + }); + +// May unsubscribe to shop_items as player advances +var shopSubscription = conn + .SubscriptionBuilder() + .Subscribe(new string[] { + "SELECT * FROM shop_items WHERE required_level <= 5" + }); +``` +::: + +### 3. Subscribe Before Unsubscribing + +If you want to update or modify a subscription by dropping it and subscribing to a new set, +you should subscribe to the new set before unsubscribing from the old one. + +This is because SpacetimeDB subscriptions are zero-copy. +Subscribing to the same query more than once doesn't incur additional processing or serialization overhead. +Likewise, if a query is subscribed to more than once, +unsubscribing from it does not result in any server processing or data serializtion. + +#### Example + +:::server-rust +```rust +let conn: DbConnection = connect_to_db(); + +// Initial subscription: player at level 5. +let shop_subscription = conn + .subscription_builder() + .subscribe([ + // For displaying the price of shop items in the player's currency of choice + "SELECT * FROM exchange_rates", + "SELECT * FROM shop_items WHERE required_level <= 5", + ]); + +// New subscription: player now at level 6, which overlaps with the previous query. +let new_shop_subscription = conn + .subscription_builder() + .subscribe([ + // For displaying the price of shop items in the player's currency of choice + "SELECT * FROM exchange_rates", + "SELECT * FROM shop_items WHERE required_level <= 6", + ]); + +// Unsubscribe from the old subscription once the new one is active. +if shop_subscription.is_active() { + shop_subscription.unsubscribe(); +} +``` +::: +:::server-csharp +```cs +var conn = ConnectToDB(); + +// Initial subscription: player at level 5. +var shopSubscription = conn + .SubscriptionBuilder() + .Subscribe(new string[] { + // For displaying the price of shop items in the player's currency of choice + "SELECT * FROM exchange_rates", + "SELECT * FROM shop_items WHERE required_level <= 5" + }); + +// New subscription: player now at level 6, which overlaps with the previous query. +var newShopSubscription = conn + .SubscriptionBuilder() + .Subscribe(new string[] { + // For displaying the price of shop items in the player's currency of choice + "SELECT * FROM exchange_rates", + "SELECT * FROM shop_items WHERE required_level <= 6" + }); + +// Unsubscribe from the old subscription once the new one is in place. +if (shopSubscription.IsActive) +{ + shopSubscription.Unsubscribe(); +} +``` +::: + +### 4. Avoid Overlapping Queries + +This refers to distinct queries that return intersecting data sets, +which can result in the server processing and serializing the same row multiple times. +While SpacetimeDB can manage this redundancy, it may lead to unnecessary inefficiencies. + +Consider the following two queries: +```sql +SELECT * FROM User +SELECT * FROM User WHERE id = 5 +``` + +If `User.id` is a unique or primary key column, +the cost of subscribing to both queries is minimal. +This is because the server will use an index when processing the 2nd query, +and it will only serialize a single row for the 2nd query. + +In contrast, consider these two queries: +```sql +SELECT * FROM User +SELECT * FROM User WHERE id != 5 +``` + +The server must now process each row of the `User` table twice, +since the 2nd query cannot be processed using an index. +It must also serialize all but one row of the `User` table twice, +due to the significant overlap between the two queries. + +By following these best practices, you can optimize your data replication strategy and ensure your application remains efficient and responsive. diff --git a/docs/unity/index.md b/docs/unity/index.md new file mode 100644 index 00000000..e477c3c3 --- /dev/null +++ b/docs/unity/index.md @@ -0,0 +1,35 @@ +# Unity Tutorial - Overview + +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). + +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](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 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. + +- `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. + +## Blackhol.io Tutorial - Basic Multiplayer + +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) +- [Part 3 - Gameplay](/docs/unity/part-3) +- [Part 4 - Moving and Colliding](/docs/unity/part-4) + +## Blackhol.io Tutorial - Advanced + +If you already have a good understanding of the SpacetimeDB client and server, check out our completed tutorial project! + +https://github.com/ClockworkLabs/Blackholio diff --git a/docs/unity/part-1-hero-image.png b/docs/unity/part-1-hero-image.png new file mode 100644 index 00000000..b37d9690 Binary files /dev/null and b/docs/unity/part-1-hero-image.png differ diff --git a/docs/unity/part-1-unity-hub-new-project.jpg b/docs/unity/part-1-unity-hub-new-project.jpg new file mode 100644 index 00000000..7f23fdb8 Binary files /dev/null and b/docs/unity/part-1-unity-hub-new-project.jpg differ diff --git a/docs/unity/part-1-universal-2d-template.png b/docs/unity/part-1-universal-2d-template.png new file mode 100644 index 00000000..414fae5b Binary files /dev/null and b/docs/unity/part-1-universal-2d-template.png differ diff --git a/docs/unity/part-1.md b/docs/unity/part-1.md new file mode 100644 index 00000000..f19a28bf --- /dev/null +++ b/docs/unity/part-1.md @@ -0,0 +1,90 @@ +# Unity Tutorial - Part 1 - Setup + + + +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 subdirectories; + +1. Server (module) code +2. Client code + +First, we'll create a project root directory (you can choose the name): + +```bash +mkdir blackholio +cd blackholio +``` + +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 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 + +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**. + + + +**⚠️ Important: Choose the `Universal 2D`** template to select a template which uses the Unity Universal Render Pipeline. + +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. + + + +Click "Create" to generate the blank project. + +### Import the SpacetimeDB Unity SDK + +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 +``` + +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. + +### Create the GameManager Script + +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`. + +The `GameManager` script will be where we will put the high level initialization and coordination logic for our game. + +### Add the GameManager to the Scene + +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**. + +2. **Rename the GameObject**: + - In the **Inspector**, click on the GameObject’s name at the top and rename it to `GameManager`. + +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. + +### Add the SpacetimeDB Network Manager + +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. + +When you build a new connection to SpacetimeDB, that connection will be added to and managed by the `SpacetimeDBNetworkManager` automatically. + +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. + +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 + +We've now got the very basics set up. In [part 2](part-2) you'll learn the basics of how how to create a SpacetimeDB server module and how to connect to it from your client. \ No newline at end of file diff --git a/docs/unity/part-2.md b/docs/unity/part-2.md new file mode 100644 index 00000000..ebfc7a69 --- /dev/null +++ b/docs/unity/part-2.md @@ -0,0 +1,620 @@ +# 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 + +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 your desired 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-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`. + +```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. + +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 will 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 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:** + +```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, +} +``` +::: +:::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 SpacetimeDB.Timestamp 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`. + +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. + +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. + +:::server-rust +```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`. +::: +:::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 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 an 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. + +:::server-rust +```rust +#[spacetimedb::reducer] +pub fn debug(ctx: &ReducerContext) -> Result<(), String> { + log::debug!("This reducer was called by {}.", ctx.sender); + 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.Sender}"); +} +``` +::: + +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 +``` + +:::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 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: + +``` +Build finished successfully. +Uploading to local => http://127.0.0.1:3000 +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. + +:::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: + +```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 + +:::server-rust +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)] +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 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 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 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)] +public static void Connect(ReducerContext ctx) +{ + 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 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 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: + +```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. + +:::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-unity/Assets/autogen # you can call this anything, I have chosen `autogen` +``` + +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. + +``` +├── 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/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. +> +> ```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 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: + +```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 (AuthToken.Token != "") + { + builder = builder.WithToken(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) + .SubscribeToAllTables(); + } + + 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(SubscriptionEventContext 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 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. + +--- + +**SDK Client Cache** + +The "SDK client cache" is a client-side view of the database defined by the supplied queries to the `Subscribe` function. SpacetimeDB ensures that the results of subscription queries are automatically updated and pushed to the client cache as they change which allows efficient access without unnecessary server queries. + +--- + +Now we're ready to connect the client and server. Press the play button in Unity. + +If all went well you should see the below output in your Unity logs. + +``` +SpacetimeDBClient: Connecting to ws://127.0.0.1:3000 blackholio +Connected. +Subscription applied! +``` + +Subscription applied indicates that the SpacetimeDB SDK has evaluated your subscription queries and synchronized your local cache with your database's tables. + +We can also see that the server has logged the connection as well. + +```sh +spacetime logs blackholio +... +2025-01-10T03:51:02.078700Z DEBUG: src/lib.rs:63: c200fb5be9524bfb8289c351516a1d9ea800f70a17a9a6937f11c0ed3854087d just connected. +``` + +### Next Steps + +You've learned how to setup a Unity project with the SpacetimeDB SDK, write a basic SpacetimeDB server module, and how to connect your Unity client to SpacetimeDB. That's pretty much all there is to the setup. You're now ready to start building the game. + +In the [next part](/docs/unity/part-3), we'll build out the functionality of the game and you'll learn how to access your table data and call reducers in Unity. + diff --git a/docs/unity/part-3-player-on-screen.png b/docs/unity/part-3-player-on-screen.png new file mode 100644 index 00000000..16c23dd0 Binary files /dev/null and b/docs/unity/part-3-player-on-screen.png differ diff --git a/docs/unity/part-3.md b/docs/unity/part-3.md new file mode 100644 index 00000000..4dfb8e24 --- /dev/null +++ b/docs/unity/part-3.md @@ -0,0 +1,1215 @@ +# Unity Tutorial - Part 3 - Gameplay + +Need help with the tutorial? [Join our Discord server](https://discord.gg/spacetimedb)! + +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 database before any clients connect. + +Add this new reducer above our `connect` reducer. + +```rust +// Note the `init` parameter passed to the reducer macro. +// That indicates to SpacetimeDB that it should be called +// once upon database creation. +#[spacetimedb::reducer(init)] +pub fn init(ctx: &ReducerContext) -> Result<(), String> { + log::info!("Initializing..."); + ctx.db.config().try_insert(Config { + id: 0, + world_size: 1000, + })?; + Ok(()) +} +``` + +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. + +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. + +```rust +const FOOD_MASS_MIN: u32 = 2; +const FOOD_MASS_MAX: u32 = 4; +const TARGET_FOOD_COUNT: usize = 600; + +fn mass_to_radius(mass: u32) -> f32 { + (mass as f32).sqrt() +} + +#[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(()) +} +``` +::: +:::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 database 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 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))] +pub struct SpawnFoodTimer { + #[primary_key] + #[auto_inc] + scheduled_id: u64, + scheduled_at: spacetimedb::ScheduleAt, +} +``` + +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 +#[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).into()), + })?; + Ok(()) +} +``` + +> 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 + +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: + +:::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)] +#[derive(Debug, Clone)] +pub struct Player { + #[primary_key] + identity: Identity, + #[unique] + #[auto_inc] + player_id: u32, + 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. + +> 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. + +:::server-rust +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() + .identity() + .delete(&player.identity); + } else { + ctx.db.player().try_insert(Player { + identity: ctx.sender, + player_id: 0, + name: String::new(), + })?; + } + Ok(()) +} + +#[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); + + 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.Sender); + 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.Sender, + name = "", + }); + } +} + +[Reducer(ReducerKind.ClientDisconnected)] +public static void Disconnect(ReducerContext ctx) +{ + 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); +} +``` +::: + +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. + +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. + +> 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. + +### Spawning Player Circles + +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 +const START_PLAYER_MASS: u32 = 15; + +#[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)?; + + 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) +} +``` + +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.Sender) ?? 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, SpacetimeDB.Timestamp 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 = timestamp, + }); + 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 database server. + +:::server-rust +```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); + } + + Ok(()) +} +``` +::: +:::server-csharp +```csharp +[Reducer(ReducerKind.ClientDisconnected)] +public static void Disconnect(ReducerContext ctx) +{ + 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)) + { + 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: + +```sh +spacetime publish --server local blackholio --delete-data +``` + +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 + +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. + +Start by adding `SetupArena` and `CreateBorderCube` methods to your `GameManager` class: + +```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 + } + + 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; + } +``` + +In your `HandleSubscriptionApplied` let's now call `SetupArena` method. Modify your `HandleSubscriptionApplied` method as in the below. + +```cs + private void HandleSubscriptionApplied(SubscriptionEventContext 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); + } +``` + +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. + +In the scene view, select the `GameManager` object. Click on the `Border Material` property and choose `Sprites-Default`. + +### Creating GameObjects + +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 +``` + +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. + +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: + +``` +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 +``` + +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. + +#### EntityController + +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. + +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; + +public abstract class EntityController : MonoBehaviour +{ + 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 LerpTargetPosition; + protected Vector3 TargetScale; + + protected virtual void Spawn(uint entityId) + { + EntityId = entityId; + + var entity = GameManager.Conn.Db.Entity.EntityId.Find(entityId); + LerpStartPosition = LerpTargetPosition = 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; + LerpTargetPosition = (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, LerpTargetPosition, 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; +} +``` + +The `EntityController` script just provides some helper functions and basic functionality to manage our game objects based on entity updates. + +> 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. + +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; +using UnityEngine; + +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); + } + } +} +``` + +This just allows us to implicitly convert between our `DbVector2` type and the Unity `Vector2` type. + +#### CircleController + +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 +{ + 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) + { + base.Spawn(circle.EntityId); + SetColor(ColorPalette[circle.PlayerId % ColorPalette.Length]); + + this.Owner = owner; + GetComponentInChildren().text = owner.Username; + } + + public override void OnDelete(EventContext context) + { + base.OnDelete(context); + Owner.OnCircleDeleted(this); + } +} +``` + +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 + +Next open the `FoodController.cs` file and replace the contents with: + +```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]); + } +} +``` + +#### PlayerController + +Open the `PlayerController` script and modify the contents of the `PlayerController` script to be: + +```cs +using System.Collections.Generic; +using System.Linq; +using SpacetimeDB; +using SpacetimeDB.Types; +using UnityEngine; + +public class PlayerController : MonoBehaviour +{ + 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) + { + 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(); + } + + public void OnCircleSpawned(CircleController circle) + { + OwnedCircles.Add(circle); + } + + public void OnCircleDeleted(CircleController deletedCircle) + { + // This means we got eaten + if (OwnedCircles.Remove(deletedCircle) && IsLocalPlayer && OwnedCircles.Count == 0) + { + // DeathScreen.Instance.SetVisible(true); + } + } + + 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. + } + + 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; + } + + return totalPos / totalMass; + } + + private void OnGUI() + { + if (!IsLocalPlayer || !GameManager.IsConnected()) + { + return; + } + + GUI.Label(new Rect(0, 0, 100, 50), $"Total Mass: {TotalMass()}"); + } + + //Automated testing members + private bool testInputEnabled; + private Vector2 testInput; + + public void SetTestInput(Vector2 input) => testInput = input; + public void EnableTestInput() => testInputEnabled = true; +} +``` + +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: + +```cs +using SpacetimeDB.Types; +using System.Collections; +using System.Collections.Generic; +using UnityEngine; + +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; + } +} +``` + +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. + +### Hooking up the Data + +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. + +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: + +```cs + public static DbConnection Conn { get; private set; } + + public static Dictionary Entities = new Dictionary(); + public static Dictionary Players = new Dictionary(); +``` + +Next lets add some callbacks when rows change in the database. Modify the `HandleConnect` method as below. + +```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; + + 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; + + OnConnected?.Invoke(); + + // Request all tables + Conn.SubscriptionBuilder() + .OnApplied(HandleSubscriptionApplied) + .SubscribeToAllTables(); + } +``` + +Next add the following implementations for those callbacks to the `GameManager` class. + +```cs + private static void CircleOnInsert(EventContext context, Circle insertedValue) + { + var player = GetOrCreatePlayer(insertedValue.PlayerId); + var entityController = PrefabManager.SpawnCircle(insertedValue, player); + Entities.Add(insertedValue.EntityId, entityController); + } + + private static void EntityOnUpdate(EventContext context, Entity oldEntity, Entity newEntity) + { + if (!Entities.TryGetValue(newEntity.EntityId, out var entityController)) + { + return; + } + entityController.OnEntityUpdated(newEntity); + } + + private static void EntityOnDelete(EventContext context, Entity oldEntity) + { + if (Entities.Remove(oldEntity.EntityId, out var entityController)) + { + entityController.OnDelete(context); + } + } + + private static void FoodOnInsert(EventContext context, Food insertedValue) + { + var entityController = PrefabManager.SpawnFood(insertedValue); + Entities.Add(insertedValue.EntityId, entityController); + } + + 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); + } + } + + private static PlayerController GetOrCreatePlayer(uint playerId) + { + if (!Players.TryGetValue(playerId, out var playerController)) + { + var player = Conn.Db.Player.PlayerId.Find(playerId); + playerController = PrefabManager.SpawnPlayer(player); + Players.Add(playerId, playerController); + } + + return playerController; + } +``` + +### Camera Controller + +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: + +```cs +using System.Collections; +using System.Collections.Generic; +using UnityEngine; + +public class CameraController : MonoBehaviour +{ + public static float WorldSize = 0.0f; + + 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; + } + + 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; + } + + float targetCameraSize = CalculateCameraSize(PlayerController.Local); + Camera.main.orthographicSize = Mathf.Lerp(Camera.main.orthographicSize, targetCameraSize, Time.deltaTime * 2); + } + + 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 + } +} +``` + +Add the `CameraController` as a component to the `Main Camera` object in the scene. + +Lastly modify the `GameManager.SetupArena` method to set the `WorldSize` on the `CameraController`. + +```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; + } +``` + +### 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-unity/Assets/autogen +``` + +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(SubscriptionEventContext 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); + + // Call enter game with the player name 3Blave + ctx.Reducers.EnterGame("3Blave"); + } +``` + +### Trying it out + +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. + + + +> 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` + +- 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`. + +### 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. diff --git a/docs/unity/part-4.md b/docs/unity/part-4.md new file mode 100644 index 00000000..7e77fc83 --- /dev/null +++ b/docs/unity/part-4.md @@ -0,0 +1,635 @@ +# 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 [part 3](/docs/unity/part-3). + +### Moving 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. + +:::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 +use spacetimedb::SpacetimeType; + +// This allows us to store 2D points in tables. +#[derive(SpacetimeType, Debug, Clone, Copy)] +pub struct DbVector2 { + pub x: f32, + pub y: f32, +} + +impl std::ops::Add<&DbVector2> for DbVector2 { + type Output = DbVector2; + + fn add(self, other: &DbVector2) -> DbVector2 { + DbVector2 { + x: self.x + other.x, + y: self.y + other.y, + } + } +} + +impl std::ops::Add for DbVector2 { + type Output = DbVector2; + + fn add(self, other: DbVector2) -> DbVector2 { + DbVector2 { + x: self.x + other.x, + y: self.y + other.y, + } + } +} + +impl std::ops::AddAssign for DbVector2 { + fn add_assign(&mut self, rhs: DbVector2) { + self.x += rhs.x; + self.y += rhs.y; + } +} + +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 + } +} + +impl std::ops::Sub<&DbVector2> for DbVector2 { + type Output = DbVector2; + + fn sub(self, other: &DbVector2) -> DbVector2 { + DbVector2 { + x: self.x - other.x, + y: self.y - other.y, + } + } +} + +impl std::ops::Sub for DbVector2 { + type Output = DbVector2; + + 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() + } +} +``` + +At the very top of `lib.rs` add the following lines to import the moved `DbVector2` from the `math` module. + +```rust +pub mod math; + +use math::DbVector2; +// ... +``` + +Next, add the following reducer to your `lib.rs` file. + +```rust +#[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(()) +} +``` + +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.Sender) ?? 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.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. + +:::server-rust +```rust +#[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, +} + +const START_PLAYER_SPEED: u32 = 10; + +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()) +} + +#[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 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 = + 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); + } + + 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 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); + 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).into()), + })?; +``` +::: +:::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: + +```sh +spacetime publish --server local blackholio --delete-data +``` + +Regenerate your server bindings with: + +```sh +spacetime generate --lang csharp --out-dir ../client-unity/Assets/autogen +``` + +### 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: + +```cs +public void Update() +{ + if (!IsLocalPlayer || NumberOfOwnedCircles == 0) + { + return; + } + + if (Input.GetKeyDown(KeyCode.Q)) + { + if (LockInputPosition.HasValue) + { + LockInputPosition = null; + } + else + { + LockInputPosition = (Vector2)Input.mousePosition; + } + } + + // 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); + } +} +``` + +Let's try it out! Press play and roam freely around the arena! Now we're cooking with gas. + +### Collisions and Eating Food + +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 and make sure to replace the existing `move_all_players` reducer. + +```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 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 = + 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(()) +} +``` +::: +:::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 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); + 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. + +That's it. We don't even have to do anything on the client. + +```sh +spacetime publish --server local blackholio +``` + +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. + +## 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 database, you can run: `spacetime delete -s maincloud ` + +# 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 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! + +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, with these additional features, you can download it on GitHub: + +https://github.com/ClockworkLabs/Blackholio + +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/Module ABI Reference/index.md b/docs/webassembly-abi/index.md similarity index 100% rename from docs/Module ABI Reference/index.md rename to docs/webassembly-abi/index.md diff --git a/llms.md b/llms.md new file mode 100644 index 00000000..5a952845 --- /dev/null +++ b/llms.md @@ -0,0 +1,2673 @@ +# 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 (RLS) + +Row Level Security (RLS) allows module authors to restrict client access to specific rows +of tables that are marked as `public`. By default, tables *without* the `public` +attribute are private and completely inaccessible to clients. Tables *with* the `public` +attribute are, by default, fully visible to any client that subscribes to them. RLS provides +a mechanism to selectively restrict access to certain rows of these `public` tables based +on rules evaluated for each client. + +Private tables (those *without* the `public` attribute) are always completely inaccessible +to clients, and RLS rules do not apply to them. RLS rules are defined for `public` tables +to filter which rows are visible. + +These access-granting rules are expressed in SQL and evaluated automatically for queries +and subscriptions made by clients against private tables with associated RLS rules. + +:::info Version-Specific Status +Row-Level Security (RLS) was introduced as an **unstable** feature in **SpacetimeDB v1.1.0**. +It requires explicit opt-in via feature flags or pragmas. +::: + +**Enabling RLS** + +RLS is currently **unstable** and must be explicitly enabled in your module. + +To enable RLS, activate the `unstable` feature in your project's `Cargo.toml`: + +```toml +spacetimedb = { version = "1.1.0", features = ["unstable"] } # at least version 1.1.0 +``` + +**How It Works** + +RLS rules are attached to `public` tables (tables with `#[table(..., public)]`) +and are expressed in SQL using constants of type `Filter`. + +```rust +use spacetimedb::{client_visibility_filter, Filter, table, Identity}; + +// Define a public table for RLS +#[table(name = account, public)] // Now a public table +struct Account { + #[primary_key] + identity: Identity, + email: String, + balance: u32, +} + +/// RLS Rule: Allow a client to see *only* their own account record. +#[client_visibility_filter] +const ACCOUNT_VISIBILITY: Filter = Filter::Sql( + // This query is evaluated per client request. + // :sender is automatically bound to the requesting client's identity. + // Only rows matching this filter are returned to the client from the public 'account' table, + // overriding its default full visibility for matching clients. + "SELECT * FROM account WHERE identity = :sender" +); +``` + +A module will fail to publish if any of its RLS rules are invalid or malformed. + +**`:sender`** + +You can use the special `:sender` parameter in your rules for user-specific access control. +This parameter is automatically bound to the requesting client's [Identity](#identity). + +Note that module owners have unrestricted access to all tables, including all rows of +`public` tables (bypassing RLS rules) and `private` tables. + +**Semantic Constraints** + +RLS rules act as filters defining which rows of a `public` table are visible to a client. +Like subscriptions, arbitrary column projections are **not** allowed. +Joins **are** allowed (e.g., to check permissions in another table), but each rule must +ultimately return rows from the single public table it applies to. + +**Multiple Rules Per Table** + +Multiple RLS rules may be declared for the same `public` table. They are evaluated as a +logical `OR`, meaning clients can see any row that matches at least one rule. + +**Example (Building on previous Account table)** + +```rust +# use spacetimedb::{client_visibility_filter, Filter, table, Identity}; +# #[table(name = account)] struct Account { #[primary_key] identity: Identity, email: String, balance: u32 } +// Assume an 'admin' table exists to track administrator identities +#[table(name = admin)] struct Admin { #[primary_key] identity: Identity } + +/// RLS Rule 1: A client can see their own account. +#[client_visibility_filter] +const ACCOUNT_OWNER_VISIBILITY: Filter = Filter::Sql( + "SELECT * FROM account WHERE identity = :sender" +); + +/// RLS Rule 2: An admin client can see *all* accounts. +#[client_visibility_filter] +const ACCOUNT_ADMIN_VISIBILITY: Filter = Filter::Sql( + // This join checks if the requesting client (:sender) exists in the admin table. + // If they do, the join succeeds, and all rows from 'account' are potentially visible. + "SELECT account.* FROM account JOIN admin WHERE admin.identity = :sender" +); + +// Result: A non-admin client sees only their own account row. +// An admin client sees all account rows because they match the second rule. +``` + +**Recursive Application** + +RLS rules can reference other tables that might *also* have RLS rules. These rules are applied recursively. +For instance, if Rule A depends on Table B, and Table B has its own RLS rules, a client only gets results +from Rule A if they also have permission to see the relevant rows in Table B according to Table B's rules. +This ensures that the intended row visibility on `public` tables is maintained even through indirect access patterns. + +**Example (Building on previous Account/Admin tables)** + +```rust +# use spacetimedb::{client_visibility_filter, Filter, table, Identity}; +# #[table(name = account)] struct Account { #[primary_key] identity: Identity, email: String, balance: u32 } +# #[table(name = admin)] struct Admin { #[primary_key] identity: Identity } +// Define a private player table linked to account +#[table(name = player)] // Private table +struct Player { #[primary_key] id: Identity, level: u32 } + +# /// RLS Rule 1: A client can see their own account. +# #[client_visibility_filter] const ACCOUNT_OWNER_VISIBILITY: Filter = Filter::Sql( "SELECT * FROM account WHERE identity = :sender" ); +# /// RLS Rule 2: An admin client can see *all* accounts. +# #[client_visibility_filter] const ACCOUNT_ADMIN_VISIBILITY: Filter = Filter::Sql( "SELECT account.* FROM account JOIN admin WHERE admin.identity = :sender" ); + +/// RLS Rule for Player table: Players are visible if the associated account is visible. +#[client_visibility_filter] +const PLAYER_VISIBILITY: Filter = Filter::Sql( + // This rule joins Player with Account. + // Crucially, the client running this query must *also* satisfy the RLS rules + // defined for the `account` table for the specific account row being joined. + // Therefore, non-admins see only their own player, admins see all players. + "SELECT p.* FROM account a JOIN player p ON a.identity = p.id" +); +``` + +Self-joins are allowed within RLS rules. However, RLS rules cannot be mutually recursive +(e.g., Rule A depends on Table B, and Rule B depends on Table A), as this would cause +infinite recursion during evaluation. + +**Example: Self-Join (Valid)** + +```rust +# use spacetimedb::{client_visibility_filter, Filter, table, Identity}; +# #[table(name = player)] struct Player { #[primary_key] id: Identity, level: u32 } +# // Dummy account table for join context +# #[table(name = account)] struct Account { #[primary_key] identity: Identity } + +/// RLS Rule: A client can see other players at the same level as their own player. +#[client_visibility_filter] +const PLAYER_SAME_LEVEL_VISIBILITY: Filter = Filter::Sql(" + SELECT q.* + FROM account a -- Find the requester's account + JOIN player p ON a.identity = p.id -- Find the requester's player + JOIN player q on p.level = q.level -- Find other players (q) at the same level + WHERE a.identity = :sender -- Ensure we start with the requester +"); +``` + +**Example: Mutually Recursive Rules (Invalid)** + +This module would fail to publish because the `ACCOUNT_NEEDS_PLAYER` rule depends on the +`player` table, while the `PLAYER_NEEDS_ACCOUNT` rule depends on the `account` table. + +```rust +use spacetimedb::{client_visibility_filter, Filter, table, Identity}; + +#[table(name = account)] struct Account { #[primary_key] id: u64, identity: Identity } +#[table(name = player)] struct Player { #[primary_key] id: u64 } + +/// RLS: An account is visible only if a corresponding player exists. +#[client_visibility_filter] +const ACCOUNT_NEEDS_PLAYER: Filter = Filter::Sql( + "SELECT a.* FROM account a JOIN player p ON a.id = p.id WHERE a.identity = :sender" +); + +/// RLS: A player is visible only if a corresponding account exists. +#[client_visibility_filter] +const PLAYER_NEEDS_ACCOUNT: Filter = Filter::Sql( + // This rule requires access to 'account', which itself requires access to 'player' -> recursion! + "SELECT p.* FROM account a JOIN player p ON a.id = p.id WHERE a.identity = :sender" +); +``` + +**Usage in Subscriptions** + +When a client subscribes to a `public` table that has RLS rules defined, +the server automatically applies those rules. The subscription results (both initial +and subsequent updates) will only contain rows that the specific client is allowed to +see based on the RLS rules evaluating successfully for that client. + +While the SQL constraints and limitations outlined in the [SQL reference docs](/docs/sql/index.md#subscriptions) +(like limitations on complex joins or aggregations) do not apply directly to the definition +of RLS rules themselves, these constraints *do* apply to client subscriptions that *use* those rules. +For example, an RLS rule might use a complex join not normally supported in subscriptions. +If a client tries to subscribe directly to the table governed by that complex RLS rule, +the subscription itself might fail, even if the RLS rule is valid for direct queries. + +**Best Practices** + +1. Define RLS rules for `public` tables where you need to restrict row visibility for different clients. +2. Use `:sender` for client-specific filtering within your rules. +3. Keep RLS rules as simple as possible while enforcing desired access. +4. Be mindful of potential performance implications of complex joins in RLS rules, especially when combined with subscriptions. +5. Follow the general [SQL best practices](/docs/sql/index.md#best-practices-for-performance-and-scalability) for optimizing your RLS rules. + +### Client SDK (Rust) + +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 is important! + 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. + +#### Row-Level Security (RLS) + +Row Level Security (RLS) allows module authors to restrict which rows of a public table each client can access. +These access rules are expressed in SQL and evaluated automatically for queries and subscriptions. + +:::info Version-Specific Status +Row-Level Security (RLS) was introduced as an **unstable** feature in **SpacetimeDB v1.1.0**. +It requires explicit opt-in via feature flags or pragmas. +::: + +**Enabling RLS** + +RLS is currently **unstable** and must be explicitly enabled in your module. + +To enable RLS, include the following preprocessor directive at the top of your module files: + +```cs +#pragma warning disable STDB_UNSTABLE +``` + +**How It Works** + +RLS rules are attached to `public` tables (tables with `#[table(..., public)]`) +and are expressed in SQL using public static readonly fields of type `Filter` annotated with +`[SpacetimeDB.ClientVisibilityFilter]`. + +```cs +using SpacetimeDB; + +#pragma warning disable STDB_UNSTABLE + +// Define a public table for RLS +[Table(Name = "account", Public = true)] // Ensures correct C# syntax for public table +public partial class Account +{ + [PrimaryKey] public Identity Identity; + public string Email = ""; + public uint Balance; +} + +public partial class Module +{ + /// + /// RLS Rule: Allow a client to see *only* their own account record. + /// This rule applies to the public 'account' table. + /// + [SpacetimeDB.ClientVisibilityFilter] + public static readonly Filter ACCOUNT_VISIBILITY = new Filter.Sql( + // This query is evaluated per client request. + // :sender is automatically bound to the requesting client's identity. + // Only rows matching this filter are returned to the client from the public 'account' table, + // overriding its default full visibility for matching clients. + "SELECT * FROM account WHERE identity = :sender" + ); +} +``` + +A module will fail to publish if any of its RLS rules are invalid or malformed. + +**`:sender`** + +You can use the special `:sender` parameter in your rules for user specific access control. +This parameter is automatically bound to the requesting client's [Identity](#identity). + +Note that module owners have unrestricted access to all tables, including all rows of +`public` tables (bypassing RLS rules) and `private` tables. + +**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 `public` table. They are evaluated as a logical `OR`. +This means clients will be able to see to any row that matches at least one rule. + +**Example** + +```cs +using SpacetimeDB; + +#pragma warning disable STDB_UNSTABLE + +public partial class Module +{ + /// + /// A client can only see their account. + /// + [SpacetimeDB.ClientVisibilityFilter] + public static readonly Filter ACCOUNT_FILTER = new Filter.Sql( + "SELECT * FROM account WHERE identity = :sender" + ); + + /// + /// An admin can see all accounts. + /// + [SpacetimeDB.ClientVisibilityFilter] + public static readonly Filter ACCOUNT_FILTER_FOR_ADMINS = new Filter.Sql( + "SELECT account.* FROM account JOIN admin WHERE admin.identity = :sender" + ); +} +``` + +**Recursive Application** + +RLS rules can reference other tables with RLS rules, and they will be applied recursively. +This ensures that data is never leaked through indirect access patterns. + +**Example** + +```cs +using SpacetimeDB; + +public partial class Module +{ + /// + /// A client can only see their account. + /// + [SpacetimeDB.ClientVisibilityFilter] + public static readonly Filter ACCOUNT_FILTER = new Filter.Sql( + "SELECT * FROM account WHERE identity = :sender" + ); + + /// + /// An admin can see all accounts. + /// + [SpacetimeDB.ClientVisibilityFilter] + public static readonly Filter ACCOUNT_FILTER_FOR_ADMINS = new Filter.Sql( + "SELECT account.* FROM account JOIN admin WHERE admin.identity = :sender" + ); + + /// + /// Explicitly filtering by client identity in this rule is not necessary, + /// since the above RLS rules on `account` will be applied automatically. + /// Hence a client can only see their player, but an admin can see all players. + /// + [SpacetimeDB.ClientVisibilityFilter] + public static readonly Filter PLAYER_FILTER = new Filter.Sql( + "SELECT p.* FROM account a JOIN player p ON a.id = p.id" + ); +} +``` + +And while self-joins are allowed, in general RLS rules cannot be self-referential, +as this would result in infinite recursion. + +**Example: Self-Join** + +```cs +using SpacetimeDB; + +public partial class Module +{ + /// + /// A client can only see players on their same level. + /// + [SpacetimeDB.ClientVisibilityFilter] + public static readonly Filter PLAYER_FILTER = new Filter.Sql(@" + SELECT q.* + FROM account a + JOIN player p ON u.id = p.id + JOIN player q on p.level = q.level + WHERE a.identity = :sender + "); +} +``` + +**Example: Recursive Rules** + +This module will fail to publish because each rule depends on the other one. + +```cs +using SpacetimeDB; + +public partial class Module +{ + /// + /// An account must have a corresponding player. + /// + [SpacetimeDB.ClientVisibilityFilter] + public static readonly Filter ACCOUNT_FILTER = new Filter.Sql( + "SELECT a.* FROM account a JOIN player p ON a.id = p.id WHERE a.identity = :sender" + ); + + /// + /// A player must have a corresponding account. + /// + [SpacetimeDB.ClientVisibilityFilter] + public static readonly Filter ACCOUNT_FILTER = new Filter.Sql( + "SELECT p.* FROM account a JOIN player p ON a.id = p.id WHERE a.identity = :sender" + ); +} +``` + +**Usage in Subscriptions** + +RLS rules automatically apply to subscriptions so that if a client subscribes to a table with RLS filters, +the subscription will only return rows that the client is allowed to see. + +While the constraints and limitations outlined in the [SQL reference docs](/docs/sql/index.md#subscriptions) do not apply to RLS rules, +they do apply to the subscriptions that use them. +For example, it is valid for an RLS rule to have more joins than are supported by subscriptions. +However a client will not be able to subscribe to the table for which that rule is defined. + +**Best Practices** + +1. Use `:sender` for client specific filtering. +2. Follow the [SQL best practices](/docs/sql/index.md#best-practices-for-performance-and-scalability) for optimizing your RLS rules. + +### Client SDK (C#) + +This section details how to build native C# client applications (including Unity games) that interact with a SpacetimeDB module. + +#### 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(on_subscription_applied) + .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. + +* **Invoking:** Access generated reducer functions via `conn.reducers.reducerName(arg1, arg2, ...)`. Calling these functions sends the request to the server. +* **Reducer Callbacks:** Register callbacks using `conn.reducers.onReducerName((ctx: ReducerEventContext, arg1, ...) => { ... })` to react to the *outcome* of reducer calls initiated by *any* client (including your own). +* **ReducerEventContext (`ctx`)**: Contains information about the completed reducer call: + * `ctx.event.reducer`: The specific reducer variant record and its arguments. + * `ctx.event.status`: An object indicating the outcome. Check `ctx.event.status.tag` which will be a string like `"Committed"` or `"Failed"`. If failed, the reason is typically in `ctx.event.status.value`. + * `ctx.event.callerIdentity`: The `Identity` of the client that originally invoked the reducer. + * `ctx.event.message`: Contains the failure message if `ctx.event.status.tag === "Failed"`. + * `ctx.event.timestamp`, etc. + +```typescript +// Part of the ChatClient class +private registerReducerCallbacks() { + 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) { + // Check if this callback corresponds to a call made by this client instance + const wasOurCall = ctx.event.reducer.callerIdentity.isEqual(this.identity); + if (!wasOurCall) return; // Optional: Only react to your own calls + + // Check the status tag + if (ctx.event.status.tag === "Committed") { + console.log(`Our message "${messageText}" sent successfully.`); + } else if (ctx.event.status.tag === "Failed") { + // Access the error message via status.value or event.message + const errorMessage = ctx.event.status.value || ctx.event.message || "Unknown error"; + console.error(`Failed to send "${messageText}": ${errorMessage}`); + } +} + +// 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. diff --git a/nav.ts b/nav.ts new file mode 100644 index 00000000..62745c81 --- /dev/null +++ b/nav.ts @@ -0,0 +1,114 @@ +type Nav = { + items: NavItem[]; +}; +type NavItem = NavPage | NavSection; +type NavPage = { + type: 'page'; + path: string; + slug: string; + title: string; + disabled?: boolean; + href?: string; +}; +type NavSection = { + 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 section(title: string): NavSection { + 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('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'), + 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'), + 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' + ), + 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( + '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('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'), + page('Authorization', 'http/authorization', 'http/authorization.md'), + page('`/identity`', 'http/identity', 'http/identity.md'), + page('`/database`', 'http/database', 'http/database.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'), + ], +}; + +export default nav; diff --git a/package.json b/package.json new file mode 100644 index 00000000..c96b785b --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "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": { + "github-slugger": "^2.0.0" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "tsx": "^4.19.2", + "typescript": "^5.3.2" + }, + "scripts": { + "build": "tsc --project ./tsconfig.json", + "check-links": "tsx scripts/checkLinks.ts" + }, + "author": "Clockwork Labs", + "license": "ISC" +} diff --git a/scripts/checkLinks.ts b/scripts/checkLinks.ts new file mode 100644 index 00000000..944f67d2 --- /dev/null +++ b/scripts/checkLinks.ts @@ -0,0 +1,252 @@ +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 { + 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 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 + + 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) { + const link = match[2]; + linksAndImages.push({ link, type: 'link', line: index + 1 }); + } + }); + + // 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('#')) { + // 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 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; type: 'image' | 'link'; line: number }[] = []; + let totalFiles = 0; + let totalLinks = 0; + let validLinks = 0; + let invalidLinks = 0; + let totalFragments = 0; + let validFragments = 0; + let invalidFragments = 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 linksAndImages = extractLinksAndImagesFromMarkdown(file); + totalLinks += linksAndImages.length; + + const currentSlug = pathToSlug.get(file) || ''; + + 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 + } + + const siteLinks = ['/install', '/images', '/profile']; + for (const siteLink of siteLinks) { + if (link.startsWith(siteLink)) { + return; // Skip site links + } + } + + // 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; + + if (fragment) { + totalFragments += 1; + } + + // Check if the base link matches a valid slug + if (!validSlugs.includes(baseLink)) { + brokenLinks.push({ file, link: resolvedLink, type: 'link', 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, type: 'link', line }); + invalidFragments += 1; + invalidLinks += 1; + } else { + validFragments += 1; + } + } + } + }); + }); + + if (brokenLinks.length > 0) { + 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 and images are valid!'); + } + + // Print statistics + console.log('\n=== Validation Statistics ==='); + console.log(`Total markdown files processed: ${totalFiles}`); + 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('==============================='); + + 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[] = []; + const items = fs.readdirSync(dir); + + items.forEach((item) => { + const fullPath = path.join(dir, item); + const stat = fs.lstatSync(fullPath); + + if (stat.isDirectory()) { + files = files.concat(getMarkdownFiles(fullPath)); // Recurse into directories + } else if (fullPath.endsWith('.md')) { + files.push(fullPath); + } + }); + + return files; +} + +checkLinks(); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..d3f1db7d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "outDir": "./docs", + "esModuleInterop": false, + "strict": true, + "skipLibCheck": true + }, + "include": ["nav.ts"] +} diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 00000000..1527675f --- /dev/null +++ b/yarn.lock @@ -0,0 +1,202 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# 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" + +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" + 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==