diff --git a/docs/.github/workflows/check-cli-reference.yml b/docs/.github/workflows/check-cli-reference.yml new file mode 100644 index 00000000000..65a7b031b3d --- /dev/null +++ b/docs/.github/workflows/check-cli-reference.yml @@ -0,0 +1,52 @@ +on: + pull_request: + push: + branches: + - master + merge_group: + 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/docs/.github/workflows/check-links.yml b/docs/.github/workflows/check-links.yml new file mode 100644 index 00000000000..ee0c4e63afd --- /dev/null +++ b/docs/.github/workflows/check-links.yml @@ -0,0 +1,30 @@ +name: Check Link Validity in Documentation + +on: + pull_request: + branches: + - master + push: + branches: + - master + merge_group: + +jobs: + check-links: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: '16' # or the version of Node.js you're using + + - name: Install dependencies + run: | + npm install + + - name: Run link check + run: | + npm run check-links diff --git a/docs/.github/workflows/git-tree-checks.yml b/docs/.github/workflows/git-tree-checks.yml new file mode 100644 index 00000000000..5e797450e71 --- /dev/null +++ b/docs/.github/workflows/git-tree-checks.yml @@ -0,0 +1,23 @@ +name: Git tree checks + +on: + pull_request: + types: [opened, edited, reopened, synchronize] + branches: + - release +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/docs/.github/workflows/validate-nav-build.yml b/docs/.github/workflows/validate-nav-build.yml new file mode 100644 index 00000000000..b5b1a85961d --- /dev/null +++ b/docs/.github/workflows/validate-nav-build.yml @@ -0,0 +1,44 @@ +name: Validate nav.ts Matches nav.js + +on: + pull_request: + branches: + - master + push: + branches: + - master + merge_group: + +jobs: + validate-build: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: '16' + + - name: Install dependencies + run: | + npm install + + - name: Backup existing nav.js + run: | + mv docs/nav.js docs/nav.js.original + + - name: Build nav.ts + run: | + npm run build + + - name: Compare generated nav.js with original nav.js + run: | + diff -q docs/nav.js docs/nav.js.original || (echo "Generated nav.js differs from committed version. Run 'npm run build' and commit the updated file." && exit 1) + + - name: Restore original nav.js + if: success() || failure() + run: | + mv docs/nav.js.original docs/nav.js diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 00000000000..d839abdeeba --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,5 @@ +**/.vscode +.idea +*.log +node_modules +.DS_store diff --git a/docs/.prettierrc b/docs/.prettierrc new file mode 100644 index 00000000000..2921455b325 --- /dev/null +++ b/docs/.prettierrc @@ -0,0 +1,11 @@ +{ + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": true, + "arrowParens": "avoid", + "jsxSingleQuote": false, + "trailingComma": "es5", + "endOfLine": "auto", + "printWidth": 80 +} diff --git a/docs/LICENSE.txt b/docs/LICENSE.txt new file mode 100644 index 00000000000..d9a10c0d8e8 --- /dev/null +++ b/docs/LICENSE.txt @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000000..b5c6655155d --- /dev/null +++ b/docs/README.md @@ -0,0 +1,51 @@ +## SpacetimeDB Documentation + +This repository contains the markdown files which are used to display documentation on our [website](https://spacetimedb.com/docs). + +### Making Edits + +To make changes to our docs, you can open a pull request in this repository. You can typically edit the files directly using the GitHub web interface, but you can also clone our repository and make your edits locally. To do this you can follow these instructions: + +1. Fork our repository +2. Clone your fork: + +```bash +git clone ssh://git@github.com//spacetime-docs +``` + +3. Make your edits to the docs that you want to make + test them locally +4. Commit your changes: + +```bash +git add . +git commit -m "A specific description of the changes I made and why" +``` + +5. Push your changes to your fork as a branch + +```bash +git checkout -b a-branch-name-that-describes-my-change +git push -u origin a-branch-name-that-describes-my-change +``` + +6. Go to our GitHub and open a PR that references your branch in your fork on your GitHub + +> 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/docs/STYLE.md b/docs/STYLE.md new file mode 100644 index 00000000000..81de954f7c6 --- /dev/null +++ b/docs/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/docs/appendix.md b/docs/docs/appendix.md new file mode 100644 index 00000000000..bc184c24546 --- /dev/null +++ b/docs/docs/appendix.md @@ -0,0 +1,61 @@ +# Appendix + +## SEQUENCE + +For each table containing an `#[auto_inc]` column, SpacetimeDB creates a sequence number generator behind the scenes, which functions similarly to `postgres`'s `SEQUENCE`. + +### How It Works + +* Sequences in SpacetimeDB use Rust’s `i128` integer type. +* The field type marked with `#[auto_inc]` is cast to `i128` and increments by `1` for each new row. +* Sequences are pre-allocated in chunks of `4096` to speed up number generation, and then are only persisted to disk when the pre-allocated chunk is exhausted. + +> **⚠ Warning:** Sequence number generation is not transactional. + +* Numbers are incremented even if a transaction is later rolled back. +* Unused numbers are not reclaimed, meaning sequences may have *gaps*. +* If the server restarts or a transaction rolls back, the sequence continues from the next pre-allocated chunk + `1`: + +**Example:** + +```rust +#[spacetimedb::table(name = users, public)] +struct Users { + #[auto_inc] + user_id: u64, + name: String, +} + +#[spacetimedb::reducer] +pub fn insert_user(ctx: &ReducerContext, count: u8) { + for i in 0..count { + let name = format!("User {}", i); + ctx.db.users().insert(Users { user_id: 0, name }); + } + // Query the table to see the effect of the `[auto_inc]` attribute: + for user in ctx.db.users().iter() { + log::info!("User: {:?}", user); + } +} +``` + +Then: + +```bash +❯ cargo run --bin spacetimedb-cli call sample insert_user 3 + +❯ spacetimedb-cli logs sample +... +.. User: Users { user_id: 1, name: "User 0" } +.. User: Users { user_id: 2, name: "User 1" } +.. User: Users { user_id: 3, name: "User 2" } + +# Database restart, then + +❯ cargo run --bin spacetimedb-cli call sample insert_user 1 + +❯ spacetimedb-cli logs sample +... +.. User: Users { user_id: 3, name: "User 2" } +.. User: Users { user_id: 4098, name: "User 0" } +``` \ No newline at end of file diff --git a/docs/docs/bsatn.md b/docs/docs/bsatn.md new file mode 100644 index 00000000000..2e464b51374 --- /dev/null +++ b/docs/docs/bsatn.md @@ -0,0 +1,133 @@ +# Binary SATN Format (BSATN) + +The Spacetime Algebraic Type Notation binary (BSATN) format defines +how Spacetime `AlgebraicValue`s and friends are encoded as byte strings. + +Algebraic values and product values are BSATN-encoded for e.g., +module-host communication and for storing row data in the database. + +## Notes on notation + +In this reference, we give a formal definition of the format. +To do this, we use inductive definitions, and define the following notation: + +- `bsatn(x)` denotes a function converting some value `x` to a list of bytes. +- `a: B` means that `a` is of type `B`. +- `Foo(x)` denotes extracting `x` out of some variant or type `Foo`. +- `a ++ b` denotes concatenating two byte lists `a` and `b`. +- `bsatn(A) = bsatn(B) | ... | bsatn(Z)` where `B` to `Z` are variants of `A` + means that `bsatn(A)` is defined as e.g., + `bsatn(B)`, `bsatn(C)`, .., `bsatn(Z)` depending on what variant of `A` it was. +- `[]` denotes the empty list of bytes. + +## Values + +### At a glance + +| Type | Description | +|-------------------------------------|-----------------------------------------------------------------------| +| [`AlgebraicValue`](#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(ArrayValue) + | bsatn(String) + | bsatn(Bool) + | bsatn(U8) | bsatn(U16) | bsatn(U32) | bsatn(U64) | bsatn(U128) | bsatn(U256) + | bsatn(I8) | bsatn(I16) | bsatn(I32) | bsatn(I64) | bsatn(I128) | bsatn(I256) + | bsatn(F32) | bsatn(F64) +``` + +Algebraic values include sums, products, arrays, strings, and primitives types. +The primitive types include booleans, unsigned and signed integers up to 256-bits, and floats, both single and double precision. + +### `SumValue` + +An instance of a sum type, i.e. an enum or tagged union. +`SumValue`s are binary-encoded as `bsatn(tag) ++ bsatn(variant_data)` +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 product type, i.e. a struct or tuple. +`ProductValue`s are binary encoded as: + +```fsharp +bsatn(elems) = bsatn(elem_0) ++ .. ++ bsatn(elem_n) +``` + +Field names are not encoded. + +### `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. + +### Strings + +For strings, the encoding is defined as: +```fsharp +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 + +### 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)) +``` + +Where + +- `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/sats-json) +for more details of the conversion to meta values. +Note that these meta values are converted to BSATN and _not JSON_. diff --git a/docs/docs/cli-reference.md b/docs/docs/cli-reference.md new file mode 100644 index 00000000000..69ebbbd5ccd --- /dev/null +++ b/docs/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/docs/cli-reference/standalone-config.md b/docs/docs/cli-reference/standalone-config.md new file mode 100644 index 00000000000..0dd0a1502c8 --- /dev/null +++ b/docs/docs/cli-reference/standalone-config.md @@ -0,0 +1,82 @@ +# `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. + +### `websocket` + +```toml +[websocket] +ping-interval = "15s" +idle-timeout = "30s" +close-handshake-timeout = "250ms" +incoming-queue-length = 2048 +``` + +#### `websocket.ping-interval` + +Interval at which the server will send `Ping` frames to keep the connection alive. +Should be smaller than `websocket.idle-timeout` to be effective. + +Values are strings of any format the [`humantime`] crate can parse. + +#### `websocket.idle-timeout` + +If the server hasn't received any data from the client (including `Pong` responses to previous `Ping`s it sent), it will consider the client unresponsive and close the connection. +Should be greater than `websocket.ping-interval` to be effective. + +Values are strings of any format the [`humantime`] crate can parse. + +#### `websocket.close-handshake-timeout` + +Time the server waits for the client to respond to a graceful connection close. If the client doesn't respond within this timeout, the connection is dropped. + +Values are strings of any format the [`humantime`] crate can parse. + +#### `websocket.incoming-queue-length` + +Maximum number of client messages the server will queue up in case it is not able to process them quickly enough. When the queue length exceeds this value, the server will start disconnecting clients. +Note that the limit is per client, not across all clients of a particular database. + + +[`humantime`]: https://crates.io/crates/humantime diff --git a/docs/docs/deploying/maincloud.md b/docs/docs/deploying/maincloud.md new file mode 100644 index 00000000000..66130fdcbe2 --- /dev/null +++ b/docs/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 https://spacetimedb.com/profile, or you can navigate to your database directly at https://spacetimedb.com/my-cool-module. + +--- + +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/docs/deploying/spacetimedb-standalone.md b/docs/docs/deploying/spacetimedb-standalone.md new file mode 100644 index 00000000000..db738dd8d73 --- /dev/null +++ b/docs/docs/deploying/spacetimedb-standalone.md @@ -0,0 +1,280 @@ +# 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; + + ######################################### + # By default SpacetimeDB is completely open so that anyone can publish to it. If you want to block + # users from creating new databases you should keep this section commented out. Otherwise, if you + # want to open it up (probably for dev environments) then you can uncomment this section and then + # also comment out the location / section below. + ######################################### + # location / { + # proxy_pass http://localhost:3000; + # proxy_http_version 1.1; + # proxy_set_header Upgrade $http_upgrade; + # proxy_set_header Connection "Upgrade"; + # proxy_set_header Host $host; + # } + + # Anyone can subscribe to any database. + # Note: This is the only section *required* for the websocket to function properly. Clients will + # be able to create identities, call reducers, and subscribe to tables through this websocket. + location ~ ^/v1/database/[^/]+/subscribe$ { + proxy_pass http://localhost:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_set_header Host $host; + } + + # Uncomment this section to allow all HTTP reducer calls + # location ~ ^/v1/[^/]+/call/[^/]+$ { + # proxy_pass http://localhost:3000; + # proxy_http_version 1.1; + # proxy_set_header Upgrade $http_upgrade; + # proxy_set_header Connection "Upgrade"; + # proxy_set_header Host $host; + # } + + # Uncomment this section to allow all HTTP sql requests + # location ~ ^/v1/[^/]+/sql$ { + # proxy_pass http://localhost:3000; + # proxy_http_version 1.1; + # proxy_set_header Upgrade $http_upgrade; + # proxy_set_header Connection "Upgrade"; + # proxy_set_header Host $host; + # } + + # NOTE: This is required for the typescript sdk to function, it is optional + # for the rust and the C# SDKs. + location /v1/identity { + proxy_pass http://localhost:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_set_header Host $host; + } + + # Block all other routes explicitly. Only localhost can use these routes. If you want to open your + # server up so that anyone can publish to it you should comment this section out. + location / { + allow 127.0.0.1; + deny all; + } +} +``` + +This configuration by default blocks all connections other than `/v1/identity` and `/v1/database//subscribe` which only allows the most basic functionality. This will prevent all remote users from publishing to your SpacetimeDB instance. + +Enable the configuration: + +```sh +sudo ln -s /etc/nginx/sites-available/spacetimedb /etc/nginx/sites-enabled/ +``` + +Restart Nginx: + +```sh +sudo systemctl restart nginx +``` + +### Configure Firewall +Ensure your firewall allows HTTPS traffic: + +```sh +sudo ufw allow 'Nginx Full' +sudo ufw reload +``` + +## Step 4: Secure with Let's Encrypt + +### Install Certbot + +```sh +sudo apt install certbot python3-certbot-nginx -y +``` + +### Obtain an SSL Certificate + +Run this command to request a new SSL cert from Let's Encrypt. Remember to replace `example.com` with your own domain: + +```sh +sudo certbot --nginx -d example.com +``` + +Certbot will automatically configure SSL for Nginx. Restart Nginx to apply changes: + +```sh +sudo systemctl restart nginx +``` + +### Auto-Renew SSL Certificates +Certbot automatically installs a renewal timer. Verify that it is active: + +```sh +sudo systemctl status certbot.timer +``` + +## Step 5: Verify Installation + +On your local machine, add this new server to your CLI config. Make sure to replace `example.com` with your own domain: + +```bash +spacetime server add self-hosted --url https://example.com +``` + +If you have uncommented the `/v1/publish` restriction in Step 3 then you won't be able to publish to this instance unless you copy your module to the host first and then publish. We recommend something like this: + +```bash +spacetime build +scp target/wasm32-unknown-unknown/release/spacetime_module.wasm ubuntu@:/home/ubuntu/ +ssh ubuntu@ spacetime publish -s local --bin-path spacetime_module.wasm +``` + +You could put the above commands into a shell script to make publishing to your server easier and faster. It's also possible to integrate a script like this into Github Actions to publish on some event (like a PR merging into master). + +## Step 6: Updating SpacetimeDB Version +To update SpacetimeDB to the latest version, first stop the service: + +```sh +sudo systemctl stop spacetimedb +``` + +Then upgrade SpacetimeDB: + +```sh +sudo -u spacetimedb -i -- spacetime --root-dir=/stdb version upgrade +``` + +To install a specific version, use: + +```sh +sudo -u spacetimedb -i -- spacetime --root-dir=/stdb install +``` + +Finally, restart the service: + +```sh +sudo systemctl start spacetimedb +``` + +## Step 7: Troubleshooting + +### SpacetimeDB Service Fails to Start +Check the logs for errors: + +```sh +sudo journalctl -u spacetimedb --no-pager | tail -20 +``` + +Verify that the `spacetimedb` user has the correct permissions: + +```sh +sudo ls -lah /stdb/spacetime +``` + +If needed, add the executable permission: + +```sh +sudo chmod +x /stdb/spacetime +``` + +### Let's Encrypt Certificate Renewal Issues +Manually renew the certificate and check for errors: + +```sh +sudo certbot renew --dry-run +``` + +### Nginx Fails to Start +Test the configuration: + +```sh +sudo nginx -t +``` + +If errors are found, check the logs: + +```sh +sudo journalctl -u nginx --no-pager | tail -20 +``` diff --git a/docs/docs/getting-started.md b/docs/docs/getting-started.md new file mode 100644 index 00000000000..466d4cc6805 --- /dev/null +++ b/docs/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/docs/how-to/incremental-migrations.md b/docs/docs/how-to/incremental-migrations.md new file mode 100644 index 00000000000..3f9106b1745 --- /dev/null +++ b/docs/docs/how-to/incremental-migrations.md @@ -0,0 +1,369 @@ +# Incremental Migrations + +SpacetimeDB does not provide built-in support for general schema-modifying migrations. It does, however, allow adding new tables, and changing reducers' definitions in arbitrary ways. It's possible to run general migrations using an external tool, but this is tedious, necessitates downtime, and imposes the requirement that you update all your clients at the same time as publishing your new module version. + +Our friends at [Lightfox Games](https://www.lightfoxgames.com/) taught us a pattern they call "incremental migrations," which mitigates all these problems, and works perfectly with SpacetimeDB's capabilities. The short version is that, instead of altering an existing table, you add a new table with the desired new schema. Whenever your module wants to access a row from that table, it first checks the new table. If the row is present in the new table, then you've already migrated, so do whatever you want to do. If the new table doesn't have the row, instead look it up in the old table, compute and insert a row for the new table, and use that. (If the row isn't present in either the old or new table, it's just not present.) If possible, you should also update the row in the old table to match any mutations that happen in the new table, so that outdated clients can still function. + +This has several advantages: +- SpacetimeDB's module hotswapping makes this a zero-downtime update. Write your new module, `spacetime publish` it, and watch the new table populate as it's used. +- It amortizes the cost of transforming rows or computing new columns across many transactions. Rows will only be added to the new table when they're needed. +- In many cases, old clients from before the update can coexist with new clients that use the new table. You can publish the updated module without disconnecting your clients, roll out the client update through normal channels, and allow your users to update at their own pace. + +For example, imagine we have a table `player` which stores information about our players: + + + +```rust +#[spacetimedb::table(name = character, public)] +pub struct Character { + #[primary_key] + player_id: Identity, + #[unique] + nickname: String, + level: u32, + class: Class, +} + +#[derive(SpacetimeType, Debug, Copy, Clone)] +pub enum Class { + Fighter, + Caster, + Medic, +} +``` + +We'll write a few helper functions and some simple reducers: + +```rust +#[spacetimedb::reducer] +fn create_character(ctx: &ReducerContext, class: Class, nickname: String) { + log::info!( + "Creating new level 1 {class:?} named {nickname}", + ); + ctx.db.character().insert(Character { + player_id: ctx.sender, + nickname, + level: 1, + class, + }); +} + +fn find_character_for_player(ctx: &ReducerContext) -> Character { + ctx.db + .character() + .player_id() + .find(ctx.sender) + .expect("Player has not created a character") +} + +fn update_character(ctx: &ReducerContext, character: Character) { + ctx.db.character().player_id().update(character); +} + +#[spacetimedb::reducer] +fn rename_character(ctx: &ReducerContext, new_name: String) { + let character = find_character_for_player(ctx); + log::info!( + "Renaming {} to {}", + character.nickname, + new_name, + ); + update_character( + ctx, + Character { + nickname: new_name, + ..character + }, + ); +} + +#[spacetimedb::reducer] +fn level_up_character(ctx: &ReducerContext) { + let character = find_character_for_player(ctx); + log::info!( + "Leveling up {} from {} to {}", + character.nickname, + character.level, + character.level + 1, + ); + update_character( + ctx, + Character { + level: character.level + 1, + ..character + }, + ); +} +``` + +We'll play around a bit with `spacetime call` to set up a character: + +```sh +$ spacetime logs incr-migration-demo -f & + +$ spacetime call incr-migration-demo create_character '{ "Fighter": {} }' "Phoebe" + +2025-01-07T15:32:57.447286Z INFO: src/lib.rs:21: Creating new level 1 Fighter named Phoebe + +$ spacetime call -s local incr-migration-demo rename_character "Gefjon" + +2025-01-07T15:33:48.966134Z INFO: src/lib.rs:48: Renaming Phoebe to Gefjon + +$ spacetime call -s local incr-migration-demo level_up_character + +2025-01-07T15:34:01.437495Z INFO: src/lib.rs:66: Leveling up Gefjon from 1 to 2 + +$ spacetime sql incr-migration-demo 'SELECT * FROM character' + + player_id | nickname | level | class +-----------+----------+-------+---------------- + | "Gefjon" | 2 | (Fighter = ()) +``` + +See [the SATS JSON reference](/docs/sats-json) for more on the encoding of arguments to `spacetime call`. + +Now we want to add a new feature: each player should be able to align themselves with the forces of good or evil, so we can get some healthy competition going between our players. We'll start each character off with `Alliance::Neutral`, and then offer them a reducer `choose_alliance` to set it to either `Alliance::Good` or `Alliance::Evil`. Our first attempt will be to add a new column to the type `Character`: + +```rust +#[spacetimedb::table(name = character, public)] +struct Character { + #[primary_key] + player_id: Identity, + nickname: String, + level: u32, + class: Class, + alliance: Alliance, +} + +#[derive(SpacetimeType, Debug, Copy, Clone)] +enum Alliance { + Good, + Neutral, + Evil, +} + +#[spacetimedb::reducer] +fn choose_alliance(ctx: &ReducerContext, alliance: Alliance) { + let character = find_character_for_player(ctx); + log::info!( + "Setting {}'s alliance to {:?} for player {}", + character.nickname, + alliance, + ctx.sender, + ); + update_character( + ctx, + Character { + alliance, + ..character + }, + ); +} +``` + +But that will fail, since SpacetimeDB doesn't know how to update our existing `character` rows with the new column: + +``` +Error: Database update rejected: Errors occurred: +Adding a column alliance to table character requires a manual migration +``` + +Instead, we'll add a new table, `character_v2`, which will coexist with our original `character` table: + +```rust +#[spacetimedb::table(name = character_v2, public)] +struct CharacterV2 { + #[primary_key] + player_id: Identity, + nickname: String, + level: u32, + class: Class, + alliance: Alliance, +} +``` + +When a new player creates a character, we'll make rows in both tables for them. This way, any old clients that are still subscribing to the original `character` table will continue to work, though of course they won't know about the character's alliance. + +```rust +#[spacetimedb::reducer] +fn create_character(ctx: &ReducerContext, class: Class, nickname: String) { + log::info!( + "Creating new level 1 {class:?} named {nickname} for player {}", + ctx.sender, + ); + + ctx.db.character().insert(Character { + player_id: ctx.sender, + nickname: nickname.clone(), + level: 1, + class, + }); + + ctx.db.character_v2().insert(CharacterV2 { + player_id: ctx.sender, + nickname, + level: 1, + class, + alliance: Alliance::Neutral, + }); +} +``` + +We'll update our helper functions so that they operate on `character_v2` rows. In `find_character_for_player`, if we don't see the player's row in `character_v2`, we'll migrate it from `character` on the fly. In this case, we'll make the player neutral, since they haven't chosen an alliance yet. + +```rust +fn find_character_for_player(ctx: &ReducerContext) -> CharacterV2 { + if let Some(character) = ctx.db.character_v2().player_id().find(ctx.sender) { + // Already migrated; just return the new player. + return character; + } + + // Not yet migrated; look up an old character and update it. + let old_character = ctx + .db + .character() + .player_id() + .find(ctx.sender) + .expect("Player has not created a character"); + + ctx.db.character_v2().insert(CharacterV2 { + player_id: old_character.player_id, + nickname: old_character.nickname, + level: old_character.level, + class: old_character.class, + alliance: Alliance::Neutral, + }) +} +``` + +Just like when creating a new character, when we update a `character_v2` row, we'll also update the old `character` row, so that outdated clients can continue to function. It's very important that we perform the same translation between `character` and `character_v2` rows here as in `create_character` and `find_character_for_player`. + +```rust +fn update_character(ctx: &ReducerContext, character: CharacterV2) { + ctx.db.character().player_id().update(Character { + player_id: character.player_id, + nickname: character.nickname.clone(), + level: character.level, + class: character.class, + }); + ctx.db.character_v2().player_id().update(character); +} +``` + +Then we can make trivial modifications to the callers of `update_character` so that they pass in `CharacterV2` instances: + +```rust +#[spacetimedb::reducer] +fn rename_character(ctx: &ReducerContext, new_name: String) { + let character = find_character_for_player(ctx); + log::info!( + "Renaming {} to {}", + character.nickname, + new_name, + ); + update_character( + ctx, + CharacterV2 { + nickname: new_name, + ..character + }, + ); +} + +#[spacetimedb::reducer] +fn level_up_character(ctx: &ReducerContext) { + let character = find_character_for_player(ctx); + log::info!( + "Leveling up {} from {} to {}", + character.nickname, + character.level, + character.level + 1, + ); + update_character( + ctx, + CharacterV2 { + level: character.level + 1, + ..character + }, + ); +} +``` + +And finally, we can define our new `choose_alliance` reducer: + +```rust +#[spacetimedb::reducer] +fn choose_alliance(ctx: &ReducerContext, alliance: Alliance) { + let character = find_character_for_player(ctx); + log::info!( + "Setting alliance of {} to {:?}", + character.nickname, + alliance, + ); + update_character( + ctx, + CharacterV2 { + alliance, + ..character + }, + ); +} +``` + +A bit more playing around with the CLI will show us that everything works as intended: + +```sh +# Our row in `character` still exists: +$ spacetime sql incr-migration-demo 'SELECT * FROM character' + + player_id | nickname | level | class +-----------+----------+-------+---------------- + | "Gefjon" | 2 | (Fighter = ()) + +# We haven't triggered the "Gefjon" row to migrate yet, so `character_v2` is empty: +$ spacetime sql -s local incr-migration-demo 'SELECT * FROM character_v2' + + player_id | nickname | level | class | alliance +-----------+----------+-------+-------+---------- + +# Accessing our character, e.g. by leveling up, will cause it to migrate into `character_v2`: +$ spacetime call incr-migration-demo level_up_character + +2025-01-07T16:00:20.500600Z INFO: src/lib.rs:110: Leveling up Gefjon from 2 to 3 + +# Now `character_v2` is populated: +$ spacetime sql incr-migration-demo 'SELECT * FROM character_v2' + + player_id | nickname | level | class | alliance +-----------+----------+-------+----------------+---------------- + | "Gefjon" | 3 | (Fighter = ()) | (Neutral = ()) + +# The original row in `character` still got updated by `level_up_character`, +# so outdated clients can continue to function: +$ spacetime sql incr-migration-demo 'SELECT * FROM character' + + player_id | nickname | level | class +-----------+----------+-------+---------------- + | "Gefjon" | 3 | (Fighter = ()) + +# We can set our alliance: +$ spacetime call incr-migration-demo choose_alliance '{ "Good": {} }' + +2025-01-07T16:13:53.816501Z INFO: src/lib.rs:129: Setting alliance of Gefjon to Good + +# And that change shows up in `character_v2`: +$ spacetime sql incr-migration-demo 'SELECT * FROM character_v2' + + player_id | nickname | level | class | alliance +-----------+----------+-------+----------------+------------- + | "Gefjon" | 3 | (Fighter = ()) | (Good = ()) + +# But `character` is not changed, since it doesn't know about alliances: +$ spacetime sql incr-migration-demo 'SELECT * FROM character' + + player_id | nickname | level | class +-----------+----------+-------+---------------- + | "Gefjon" | 3 | (Fighter = ()) +``` + +Now that we know how to define incremental migrations, we can add new features that would seem to require breaking schema changes without cumbersome external migration tools and while maintaining compatibility of outdated clients! The complete for this tutorial is on GitHub in the `clockworklabs/incr-migration-demo` repository, in branches [`v1`](https://github.com/clockworklabs/incr-migration-demo/tree/v1), [`fails-publish`](https://github.com/clockworklabs/incr-migration-demo/tree/fails-publish) and [`v2`](https://github.com/clockworklabs/incr-migration-demo/tree/v2). diff --git a/docs/docs/http/authorization.md b/docs/docs/http/authorization.md new file mode 100644 index 00000000000..4f0973dc9b1 --- /dev/null +++ b/docs/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/docs/http/database.md b/docs/docs/http/database.md new file mode 100644 index 00000000000..56273f6b816 --- /dev/null +++ b/docs/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/docs/http/identity.md b/docs/docs/http/identity.md new file mode 100644 index 00000000000..222ac1e9756 --- /dev/null +++ b/docs/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/docs/index.md b/docs/docs/index.md new file mode 100644 index 00000000000..2d8840b402f --- /dev/null +++ b/docs/docs/index.md @@ -0,0 +1,284 @@ +# 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); +} +``` +While SpacetimeDB doesn't support nested transactions, +a reducer can [schedule another reducer](https://docs.rs/spacetimedb/latest/spacetimedb/attr.reducer.html#scheduled-reducers) to run at an interval, +or at a specific time. +::: +:::server-csharp +```csharp +[SpacetimeDB.Reducer] +public static void Hello(ReducerContext ctx) +{ + if(!World(ctx)) + { + OtherChanges(ctx); + } +} + +[SpacetimeDB.Reducer] +public static void World(ReducerContext ctx) +{ + ClearAllTables(ctx); + // ... +} +``` +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/docs/modules/c-sharp/index.md b/docs/docs/modules/c-sharp/index.md new file mode 100644 index 00000000000..209a83e583a --- /dev/null +++ b/docs/docs/modules/c-sharp/index.md @@ -0,0 +1,1407 @@ +# 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 + + + + + + + +``` + +> NOTE: It is important to not change the `StdbModule.csproj` name because SpacetimeDB assumes that this will be the name of the file. + +This is a standard `csproj`, with the exception of the line `wasi-wasm`. +This line is important: it allows the project to be compiled to a WebAssembly module. + +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. + +This macro is applied to a C# `partial class` or `partial struct` with named fields. (The `partial` modifier is required to allow code generation to add methods.) All of the fields of the table must be marked with [`[SpacetimeDB.Type]`]( #attribute-spacetimedbtype). + +The resulting type is used to store rows of the table. It's a normal class (or struct). Row values are not special -- operations on row types do not, by themselves, modify the table. Instead, a [`ReducerContext`](#class-reducercontext) is needed to get a handle to the table. + +```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).Value; + exampleUser.DogCount += 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/docs/modules/c-sharp/quickstart.md b/docs/docs/modules/c-sharp/quickstart.md new file mode 100644 index 00000000000..09213dad980 --- /dev/null +++ b/docs/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 SpacetimeDB's supported client languages to do this. Take a look at the quick start guide for your client language of choice: [Rust](/docs/sdks/rust/quickstart), [C#](/docs/sdks/c-sharp/quickstart), or [TypeScript](/docs/sdks/typescript/quickstart). + +If you are planning to use SpacetimeDB with the Unity game engine, you can skip right to the [Unity Comprehensive Tutorial](/docs/unity/part-1). diff --git a/docs/docs/modules/index.md b/docs/docs/modules/index.md new file mode 100644 index 00000000000..08d72a92605 --- /dev/null +++ b/docs/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/docs/modules/rust/index.md b/docs/docs/modules/rust/index.md new file mode 100644 index 00000000000..a86819548ae --- /dev/null +++ b/docs/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/docs/modules/rust/quickstart.md b/docs/docs/modules/rust/quickstart.md new file mode 100644 index 00000000000..1bf9ae64a10 --- /dev/null +++ b/docs/docs/modules/rust/quickstart.md @@ -0,0 +1,284 @@ +# 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); + } +} +``` + +## Start the Server + +If you haven't already started the SpacetimeDB server, run the `spacetime start` command in a _separate_ terminal and leave it running while you continue following along. + +## Publish the module + +And that's all of our module code! We'll run `spacetime publish` to compile our module and publish it on SpacetimeDB. `spacetime publish` takes an optional name which will map to the database's unique `Identity`. Clients can connect either by name or by `Identity`, but names are much more user-friendly. If you'd like, come up with a unique name that contains only URL-safe characters (letters, numbers, hyphens and underscores), and fill it in where we've written `quickstart-chat`. + +From the `quickstart-chat` directory, run in another tab: + +```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 SpacetimeDB's supported client languages to do this. Take a look at the quickstart guide for your client language of choice: [Rust](/docs/sdks/rust/quickstart), [C#](/docs/sdks/c-sharp/quickstart), or [TypeScript](/docs/sdks/typescript/quickstart). + +If you are planning to use SpacetimeDB with the Unity game engine, you can skip right to the [Unity Comprehensive Tutorial](/docs/unity/part-1). diff --git a/docs/docs/nav.js b/docs/docs/nav.js new file mode 100644 index 00000000000..3082ea7a702 --- /dev/null +++ b/docs/docs/nav.js @@ -0,0 +1,59 @@ +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'), + page('Subscription Semantics', 'subscriptions/semantics', 'subscriptions/semantics.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/docs/rls/index.md b/docs/docs/rls/index.md new file mode 100644 index 00000000000..7273d97b9c8 --- /dev/null +++ b/docs/docs/rls/index.md @@ -0,0 +1,303 @@ +# Row Level Security (RLS) + +Row Level Security (RLS) allows module authors to restrict which rows of a public table each client can access. +These access rules are expressed in SQL and evaluated automatically for queries and subscriptions. + +## Enabling RLS + +RLS is currently **experimental** and must be explicitly enabled in your module. + +:::server-rust +To enable RLS, activate the `unstable` feature in your project's `Cargo.toml`: + +```toml +spacetimedb = { version = "...", features = ["unstable"] } +``` +::: +:::server-csharp +To enable RLS, include the following preprocessor directive at the top of your module files: + +```cs +#pragma warning disable STDB_UNSTABLE +``` +::: + +## How It Works + +:::server-rust +RLS rules are expressed in SQL and declared as constants of type `Filter`. + +```rust +use spacetimedb::{client_visibility_filter, Filter}; + +/// A client can only see their account +#[client_visibility_filter] +const ACCOUNT_FILTER: Filter = Filter::Sql( + "SELECT * FROM account WHERE account.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 account.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/#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 account.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 account.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 account.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 account.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 a.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 a.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#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#best-practices-for-performance-and-scalability diff --git a/docs/docs/sats-json.md b/docs/docs/sats-json.md new file mode 100644 index 00000000000..38f087567de --- /dev/null +++ b/docs/docs/sats-json.md @@ -0,0 +1,169 @@ +# SATS-JSON Format + +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 + +### At a glance + +| Type | Description | +| ---------------- | ---------------------------------------------------------------- | +| `AlgebraicValue` | A value whose type may be any [`AlgebraicType`](#algebraictype). | +| `SumValue` | A value whose type is a [`SumType`](#sumtype). | +| `ProductValue` | A value whose type is a [`ProductType`](#producttype). | +| `BuiltinValue` | A value whose type is a [`BuiltinType`](#builtintype). | +| | | + +### `AlgebraicValue` + +```json +SumValue | ProductValue | BuiltinValue +``` + +### `SumValue` + +An instance of a [`SumType`](#sumtype). `SumValue`s are encoded as a JSON object with a single key, a non-negative integer tag which identifies the variant. The value associated with this key is the variant data. Variants which hold no data will have an empty array as their value. + +The tag is an index into the [`SumType.variants`](#sumtype) array of the value's [`SumType`](#sumtype). + +```json +{ + "": AlgebraicValue +} +``` + +The tag may also be the name of one of the variants. + +### `ProductValue` + +An instance of a [`ProductType`](#producttype). `ProductValue`s are encoded as JSON arrays. Each element of the `ProductValue` array is of the type of the corresponding index in the [`ProductType.elements`](#producttype) array of the value's [`ProductType`](#producttype). + +```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. + +```json +boolean | number | string | array | map +``` + +| [`BuiltinType`](#builtintype) | JSON type | +| ----------------------------- | ------------------------------------- | +| `Bool` | `boolean` | +| Integer types | `number` | +| Float types | `number` | +| `String` | `string` | +| Array types | `array` | +| Map types | `map` | + +All SATS integer types are encoded as JSON `number`s, so values of 64-bit and 128-bit integer types may lose precision when encoding values larger than 2⁵². + +## Types + +All SATS types are JSON-encoded by converting them to an `AlgebraicValue`, then JSON-encoding that meta-value. + +### At a glance + +| Type | Description | +| --------------------------------------- | ------------------------------------------------------------------------------------ | +| [`AlgebraicType`](#algebraictype) | Any SATS type. | +| [`SumType`](#sumtype) | Sum types, i.e. tagged unions. | +| [`ProductType`](#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. | + +#### `AlgebraicType` + +`AlgebraicType` is the most general meta-type in the Spacetime Algebraic Type System. Any SATS type can be represented as an `AlgebraicType`. `AlgebraicType` is encoded as a tagged union, with variants for [`SumType`](#sumtype), [`ProductType`](#producttype), [`BuiltinType`](#builtintype) and [`AlgebraicTypeRef`](#algebraictyperef). + +```json +{ "Sum": SumType } +| { "Product": ProductType } +| { "Builtin": BuiltinType } +| { "Ref": AlgebraicTypeRef } +``` + +#### `SumType` + +The meta-type `SumType` represents sum types, also called tagged unions or Rust `enum`s. A sum type has some number of variants, each of which has an `AlgebraicType` of variant data, and an optional string discriminant. For each instance, exactly one variant will be active. The instance will contain only that variant's data. + +A `SumType` with zero variants is called an empty type or never type because it is impossible to construct an instance. + +Instances of `SumType`s are [`SumValue`s](#sumvalue), and store a tag which identifies the active variant. + +```json +// SumType: +{ + "variants": array, +} + +// SumTypeVariant: +{ + "algebraic_type": AlgebraicType, + "name": { "some": string } | { "none": [] } +} +``` + +### `ProductType` + +The meta-type `ProductType` represents product types, also called structs or tuples. A product type has some number of fields, each of which has an `AlgebraicType` of field data, and an optional string field name. Each instance will contain data for all of the product type's fields. + +A `ProductType` with zero fields is called a unit type because it has a single instance, the unit, which is empty. + +Instances of `ProductType`s are [`ProductValue`s](#productvalue), and store an array of field data. + +```json +// ProductType: +{ + "elements": array, +} + +// ProductTypeElement: +{ + "algebraic_type": AlgebraicType, + "name": { "some": string } | { "none": [] } +} +``` + +### `BuiltinType` + +The meta-type `BuiltinType` represents SATS primitive types: booleans, integers, floating-point numbers, strings, arrays and maps. `BuiltinType` is encoded as a tagged union, with a variant for each SATS primitive type. + +SATS integer types are identified by their signedness and width in bits. SATS supports the same set of integer types as Rust, i.e. 8, 16, 32, 64 and 128-bit signed and unsigned integers. + +SATS floating-point number types are identified by their width in bits. SATS supports 32 and 64-bit floats, which correspond to [IEEE 754](https://en.wikipedia.org/wiki/IEEE_754) single- and double-precision binary floats, respectively. + +SATS array and map types are homogeneous, meaning that each array has a single element type to which all its elements must conform, and each map has a key type and a value type to which all of its keys and values must conform. + +```json +{ "Bool": [] } +| { "I8": [] } +| { "U8": [] } +| { "I16": [] } +| { "U16": [] } +| { "I32": [] } +| { "U32": [] } +| { "I64": [] } +| { "U64": [] } +| { "I128": [] } +| { "U128": [] } +| { "F32": [] } +| { "F64": [] } +| { "String": [] } +| { "Array": AlgebraicType } +| { "Map": { + "key_ty": AlgebraicType, + "ty": AlgebraicType, + } } +``` + +### `AlgebraicTypeRef` + +`AlgebraicTypeRef`s are JSON-encoded as non-negative integers. These are indices into a typespace, like the one returned by the [`GET /v1/database/:name_or_identity/schema` HTTP endpoint](/docs/http/database#get-v1databasename_or_identityschema). diff --git a/docs/docs/sdks/c-sharp/index.md b/docs/docs/sdks/c-sharp/index.md new file mode 100644 index 00000000000..d3e421188da --- /dev/null +++ b/docs/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 SpacetimeDB.ClientSDK +``` + +(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/docs/sdks/c-sharp/quickstart.md b/docs/docs/sdks/c-sharp/quickstart.md new file mode 100644 index 00000000000..974eecbb246 --- /dev/null +++ b/docs/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/SpacetimeDB.ClientSDK/) 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 DB_NAME = "quickstart-chat"; + +/// Load credentials from a file and connect to the database. +DbConnection ConnectToDB() +{ + DbConnection? conn = null; + conn = DbConnection.Builder() + .WithUri(HOST) + .WithModuleName(DB_NAME) + .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/docs/sdks/index.md b/docs/docs/sdks/index.md new file mode 100644 index 00000000000..ad9c082b621 --- /dev/null +++ b/docs/docs/sdks/index.md @@ -0,0 +1,73 @@ +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/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 + +The SpacetimeDB Client SDKs offer the following key functionalities: + +### Connection Management + +The SDKs handle the process of connecting and disconnecting from SpacetimeDB database servers, simplifying this process for the client applications. + +### Authentication + +The SDKs support authentication using an auth token, allowing clients to securely establish a session with the SpacetimeDB server. + +### Local Database View + +Each client can define a local view of the database via a subscription consisting of a set of queries. This local view is maintained by the server and populated into a local cache on the client side. + +### Reducer Calls + +The SDKs allow clients to call transactional functions (reducers) on the server. + +### Callback Registrations + +The SpacetimeDB Client SDKs offer powerful callback functionality that allow clients to monitor changes in their local database view. These callbacks come in two forms: + +#### Connection and Subscription Callbacks + +Clients can also register callbacks that trigger when the connection to the 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 + +Clients can register callbacks that trigger when any row in their local cache is updated by the server. These callbacks contain information about the reducer that triggered the change. This feature enables clients to react to changes in data that they're interested in. + +#### Reducer Call Callbacks + +Clients can also register callbacks that fire when a reducer call modifies something in the client's local view. This allows the client to know when a transactional function it has executed has had an effect on the data it cares about. + +Additionally, when a client makes a reducer call that fails, the SDK triggers the registered reducer callback on the client that initiated the failed call with the error message that was returned from the server. This allows for appropriate error handling or user notifications. + +## Choosing a Language + +When selecting a language for your client application with SpacetimeDB, a variety of factors come into play. While the functionality of the SDKs remains consistent across different languages, the choice of language will often depend on the specific needs and context of your application. Here are a few considerations: + +### Team Expertise + +The familiarity of your development team with a particular language can greatly influence your choice. You might want to choose a language that your team is most comfortable with to increase productivity and reduce development time. + +### Application Type + +Different languages are often better suited to different types of applications. For instance, if you are developing a web-based application, you might opt for TypeScript due to its seamless integration with web technologies. On the other hand, if you're developing a desktop application, you might choose C#, depending on your requirements and platform. + +### Performance + +The performance characteristics of the different languages can also be a factor. If your application is performance-critical, you might opt for Rust, known for its speed and memory efficiency. + +### Platform Support + +The platform you're targeting can also influence your choice. For instance, if you're developing a game or a 3D application using the Unity engine, you'll want to choose the C# SDK, as Unity uses C# as its primary scripting language. + +### Ecosystem and Libraries + +Each language has its own ecosystem of libraries and tools that can help in developing your application. If there's a library in a particular language that you want to use, it may influence your choice. + +Remember, the best language to use is the one that best fits your use case and the one you and your team are most comfortable with. It's worth noting that due to the consistent functionality across different SDKs, transitioning from one language to another should you need to in the future will primarily involve syntax changes rather than changes in the application's logic. + +You may want to use multiple languages in your application. For instance, you might want to use C# in Unity for your game logic and TypeScript for a web-based administration panel. This is perfectly fine, as the SpacetimeDB server is completely client-agnostic. diff --git a/docs/docs/sdks/rust/index.md b/docs/docs/sdks/rust/index.md new file mode 100644 index 00000000000..4c180f5266e --- /dev/null +++ b/docs/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/docs/sdks/rust/quickstart.md b/docs/docs/sdks/rust/quickstart.md new file mode 100644 index 00000000000..21f4d9471df --- /dev/null +++ b/docs/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/docs/sdks/typescript/index.md b/docs/docs/sdks/typescript/index.md new file mode 100644 index 00000000000..ef55ed1e61b --- /dev/null +++ b/docs/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/docs/sdks/typescript/quickstart.md b/docs/docs/sdks/typescript/quickstart.md new file mode 100644 index 00000000000..30898706376 --- /dev/null +++ b/docs/docs/sdks/typescript/quickstart.md @@ -0,0 +1,669 @@ +# 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 introduction, you will have created a basic single page web app which connects to the `quickstart-chat` database created in the above module quickstart guides. + +## Project structure + +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, type ErrorContext, type 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[]) => { + conn + ?.subscriptionBuilder() + .onApplied(() => { + console.log('SDK client cache initialized.'); + }) + .subscribe(queries); + }; + + 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/docs/sql/index.md b/docs/docs/sql/index.md new file mode 100644 index 00000000000..66c3528ba61 --- /dev/null +++ b/docs/docs/sql/index.md @@ -0,0 +1,651 @@ +# 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 multiple 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. + +Because SpacetimeDB uses a postgres compatible parser, identifiers which are +reserved in postgres are automatically reserved in Spacetime SQL. See [SQL Key Words in the PostgreSQL documentation](https://www.postgresql.org/docs/current/sql-keywords-appendix.html). + +### Example + +```sql +-- `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#subscribe-to-queries +[http]: /docs/http/database#post-v1databasename_or_identitysql +[cli]: /docs/cli-reference#spacetime-sql + +[Identity]: /docs#identity +[ConnectionId]: /docs#connectionid diff --git a/docs/docs/subscriptions/index.md b/docs/docs/subscriptions/index.md new file mode 100644 index 00000000000..a896f6a6db6 --- /dev/null +++ b/docs/docs/subscriptions/index.md @@ -0,0 +1,446 @@ +# The SpacetimeDB Subscription API + +The subscription API allows a client to replicate a subset of a database. +It does so by registering SQL queries, which we call subscriptions, through a database connection. +A client will only receive updates for rows that match the subscriptions it has registered. + +For more information on syntax and requirements see the [SQL docs](/docs/sql#subscriptions). + +This guide describes the two main interfaces that comprise the API - `SubscriptionBuilder` and `SubscriptionHandle`. +By using these interfaces, you can create efficient and responsive client applications that only receive the data they need. + +## SubscriptionBuilder + +:::server-rust +```rust +pub struct SubscriptionBuilder { /* private fields */ } + +impl SubscriptionBuilder { + /// Register a callback that runs when the subscription has been applied. + /// This callback receives a context containing the current state of the subscription. + pub fn on_applied(mut self, callback: impl FnOnce(&M::SubscriptionEventContext) + Send + 'static); + + /// Register a callback to run when the subscription fails. + /// + /// Note that this callback may run either when attempting to apply the subscription, + /// in which case [`Self::on_applied`] will never run, + /// or later during the subscription's lifetime if the module's interface changes, + /// in which case [`Self::on_applied`] may have already run. + pub fn on_error(mut self, callback: impl FnOnce(&M::ErrorContext, crate::Error) + Send + 'static); + + /// Subscribe to a subset of the database via a set of SQL queries. + /// Returns a handle which you can use to monitor or drop the subscription later. + pub fn subscribe(self, query_sql: Queries) -> M::SubscriptionHandle; + + /// Subscribe to all rows from all tables. + /// + /// This method is intended as a convenience + /// for applications where client-side memory use and network bandwidth are not concerns. + /// Applications where these resources are a constraint + /// should register more precise queries via [`Self::subscribe`] + /// in order to replicate only the subset of data which the client needs to function. + pub fn subscribe_to_all_tables(self); +} + +/// Types which specify a list of query strings. +pub trait IntoQueries { + fn into_queries(self) -> Box<[Box]>; +} +``` +::: +:::server-csharp +```cs +public sealed class SubscriptionBuilder +{ + /// + /// Register a callback to run when the subscription is applied. + /// + public SubscriptionBuilder OnApplied( + Action callback + ); + + /// + /// Register a callback to run when the subscription fails. + /// + /// Note that this callback may run either when attempting to apply the subscription, + /// in which case Self::on_applied will never run, + /// or later during the subscription's lifetime if the module's interface changes, + /// in which case Self::on_applied may have already run. + /// + public SubscriptionBuilder OnError( + Action callback + ); + + /// + /// Subscribe to the following SQL queries. + /// + /// This method returns immediately, with the data not yet added to the DbConnection. + /// The provided callbacks will be invoked once the data is returned from the remote server. + /// Data from all the provided queries will be returned at the same time. + /// + /// See the SpacetimeDB SQL docs for more information on SQL syntax: + /// https://spacetimedb.com/docs/sql + /// + public SubscriptionHandle Subscribe( + string[] querySqls + ); + + /// + /// Subscribe to all rows from all tables. + /// + /// This method is intended as a convenience + /// for applications where client-side memory use and network bandwidth are not concerns. + /// Applications where these resources are a constraint + /// should register more precise queries via Self.Subscribe + /// in order to replicate only the subset of data which the client needs to function. + /// + public void SubscribeToAllTables(); +} +``` +::: + +A `SubscriptionBuilder` provides an interface for registering subscription queries with a database. +It allows you to register callbacks that run when the subscription is successfully applied or when an error occurs. +Once applied, a client will start receiving row updates to its client cache. +A client can react to these updates by registering row callbacks for the appropriate table. + +### Example Usage + +:::server-rust +```rust +// Establish a database connection +let conn: DbConnection = connect_to_db(); + +// Register a subscription with the database +let subscription_handle = conn + .subscription_builder() + .on_applied(|ctx| { /* handle applied state */ }) + .on_error(|error_ctx, error| { /* handle error */ }) + .subscribe(["SELECT * FROM user", "SELECT * FROM message"]); +``` +::: +:::server-csharp +```cs +// Establish a database connection +var conn = ConnectToDB(); + +// Register a subscription with the database +var userSubscription = conn + .SubscriptionBuilder() + .OnApplied((ctx) => { /* handle applied state */ }) + .OnError((errorCtx, error) => { /* handle error */ }) + .Subscribe(new string[] { "SELECT * FROM user", "SELECT * FROM message" }); +``` +::: + +## SubscriptionHandle + +:::server-rust +```rust +pub trait SubscriptionHandle: InModule + Clone + Send + 'static +where + Self::Module: SpacetimeModule, +{ + /// Returns `true` if the subscription has been ended. + /// That is, if it has been unsubscribed or terminated due to an error. + fn is_ended(&self) -> bool; + + /// Returns `true` if the subscription is currently active. + fn is_active(&self) -> bool; + + /// Unsubscribe from the query controlled by this `SubscriptionHandle`, + /// then run `on_end` when its rows are removed from the client cache. + /// Returns an error if the subscription is already ended, + /// or if unsubscribe has already been called. + fn unsubscribe_then(self, on_end: OnEndedCallback) -> crate::Result<()>; + + /// Unsubscribe from the query controlled by this `SubscriptionHandle`. + /// Returns an error if the subscription is already ended, + /// or if unsubscribe has already been called. + fn unsubscribe(self) -> crate::Result<()>; +} +``` +::: +:::server-csharp +```cs + public class SubscriptionHandle : ISubscriptionHandle + where SubscriptionEventContext : ISubscriptionEventContext + where ErrorContext : IErrorContext + { + /// + /// Whether the subscription has ended. + /// + public bool IsEnded; + + /// + /// Whether the subscription is active. + /// + public bool IsActive; + + /// + /// Unsubscribe from the query controlled by this subscription handle. + /// + /// Calling this more than once will result in an exception. + /// + public void Unsubscribe(); + + /// + /// Unsubscribe from the query controlled by this subscription handle, + /// and call onEnded when its rows are removed from the client cache. + /// + public void UnsubscribeThen(Action? onEnded); + } +``` +::: + +When you register a subscription, you receive a `SubscriptionHandle`. +A `SubscriptionHandle` manages the lifecycle of each subscription you register. +In particular, it provides methods to check the status of the subscription and to unsubscribe if necessary. +Because each subscription has its own independently managed lifetime, +clients can dynamically subscribe to different subsets of the database as their application requires. + +### Example Usage + +:::server-rust +Consider a game client that displays shop items and discounts based on a player's level. +You subscribe to `shop_items` and `shop_discounts` when a player is at level 5: + +```rust +let conn: DbConnection = connect_to_db(); + +let shop_items_subscription = conn + .subscription_builder() + .on_applied(|ctx| { /* handle applied state */ }) + .on_error(|error_ctx, error| { /* handle error */ }) + .subscribe([ + "SELECT * FROM shop_items WHERE required_level <= 5", + "SELECT * FROM shop_discounts WHERE required_level <= 5", + ]); +``` + +Later, when the player reaches level 6 and new items become available, +you can subscribe to the new queries and unsubscribe from the old ones: + +```rust +let new_shop_items_subscription = conn + .subscription_builder() + .on_applied(|ctx| { /* handle applied state */ }) + .on_error(|error_ctx, error| { /* handle error */ }) + .subscribe([ + "SELECT * FROM shop_items WHERE required_level <= 6", + "SELECT * FROM shop_discounts WHERE required_level <= 6", + ]); + +if shop_items_subscription.is_active() { + shop_items_subscription + .unsubscribe() + .expect("Unsubscribing from shop_items failed"); +} +``` + +All other subscriptions continue to remain in effect. +::: +:::server-csharp +Consider a game client that displays shop items and discounts based on a player's level. +You subscribe to `shop_items` and `shop_discounts` when a player is at level 5: + +```cs +var conn = ConnectToDB(); + +var shopItemsSubscription = conn + .SubscriptionBuilder() + .OnApplied((ctx) => { /* handle applied state */ }) + .OnError((errorCtx, error) => { /* handle error */ }) + .Subscribe(new string[] { + "SELECT * FROM shop_items WHERE required_level <= 5", + "SELECT * FROM shop_discounts WHERE required_level <= 5", + }); +``` + +Later, when the player reaches level 6 and new items become available, +you can subscribe to the new queries and unsubscribe from the old ones: + +```cs +var newShopItemsSubscription = conn + .SubscriptionBuilder() + .OnApplied((ctx) => { /* handle applied state */ }) + .OnError((errorCtx, error) => { /* handle error */ }) + .Subscribe(new string[] { + "SELECT * FROM shop_items WHERE required_level <= 6", + "SELECT * FROM shop_discounts WHERE required_level <= 6", + }); + +if (shopItemsSubscription.IsActive) +{ + shopItemsSubscription.Unsubscribe(); +} +``` + +All other subscriptions continue to remain in effect. +::: + +## Best Practices for Optimizing Server Compute and Reducing Serialization Overhead + +### 1. Writing Efficient SQL Queries + +For writing efficient SQL queries, see our [SQL Best Practices Guide](/docs/sql#best-practices-for-performance-and-scalability). + +### 2. Group Subscriptions with the Same Lifetime Together + +Subscriptions with the same lifetime should be grouped together. + +For example, you may have certain data that is required for the lifetime of your application, +but you may have other data that is only sometimes required by your application. + +By managing these sets as two independent subscriptions, +your application can subscribe and unsubscribe from the latter, +without needlessly unsubscribing and resubscribing to the former. + +This will improve throughput by reducing the amount of data transferred from the database to your application. + +#### Example + +:::server-rust +```rust +let conn: DbConnection = connect_to_db(); + +// Never need to unsubscribe from global subscriptions +let global_subscriptions = conn + .subscription_builder() + .subscribe([ + // Global messages the client should always display + "SELECT * FROM announcements", + // A description of rewards for in-game achievements + "SELECT * FROM badges", + ]); + +// May unsubscribe to shop_items as player advances +let shop_subscription = conn + .subscription_builder() + .subscribe([ + "SELECT * FROM shop_items WHERE required_level <= 5", + ]); +``` +::: +:::server-csharp +```cs +var conn = ConnectToDB(); + +// Never need to unsubscribe from global subscriptions +var globalSubscriptions = conn + .SubscriptionBuilder() + .Subscribe(new string[] { + // Global messages the client should always display + "SELECT * FROM announcements", + // A description of rewards for in-game achievements + "SELECT * FROM badges", + }); + +// May unsubscribe to shop_items as player advances +var shopSubscription = conn + .SubscriptionBuilder() + .Subscribe(new string[] { + "SELECT * FROM shop_items WHERE required_level <= 5" + }); +``` +::: + +### 3. Subscribe Before Unsubscribing + +If you want to update or modify a subscription by dropping it and subscribing to a new set, +you should subscribe to the new set before unsubscribing from the old one. + +This is because SpacetimeDB subscriptions are zero-copy. +Subscribing to the same query more than once doesn't incur additional processing or serialization overhead. +Likewise, if a query is subscribed to more than once, +unsubscribing from it does not result in any server processing or data serializtion. + +#### Example + +:::server-rust +```rust +let conn: DbConnection = connect_to_db(); + +// Initial subscription: player at level 5. +let shop_subscription = conn + .subscription_builder() + .subscribe([ + // For displaying the price of shop items in the player's currency of choice + "SELECT * FROM exchange_rates", + "SELECT * FROM shop_items WHERE required_level <= 5", + ]); + +// New subscription: player now at level 6, which overlaps with the previous query. +let new_shop_subscription = conn + .subscription_builder() + .subscribe([ + // For displaying the price of shop items in the player's currency of choice + "SELECT * FROM exchange_rates", + "SELECT * FROM shop_items WHERE required_level <= 6", + ]); + +// Unsubscribe from the old subscription once the new one is active. +if shop_subscription.is_active() { + shop_subscription.unsubscribe(); +} +``` +::: +:::server-csharp +```cs +var conn = ConnectToDB(); + +// Initial subscription: player at level 5. +var shopSubscription = conn + .SubscriptionBuilder() + .Subscribe(new string[] { + // For displaying the price of shop items in the player's currency of choice + "SELECT * FROM exchange_rates", + "SELECT * FROM shop_items WHERE required_level <= 5" + }); + +// New subscription: player now at level 6, which overlaps with the previous query. +var newShopSubscription = conn + .SubscriptionBuilder() + .Subscribe(new string[] { + // For displaying the price of shop items in the player's currency of choice + "SELECT * FROM exchange_rates", + "SELECT * FROM shop_items WHERE required_level <= 6" + }); + +// Unsubscribe from the old subscription once the new one is in place. +if (shopSubscription.IsActive) +{ + shopSubscription.Unsubscribe(); +} +``` +::: + +### 4. Avoid Overlapping Queries + +This refers to distinct queries that return intersecting data sets, +which can result in the server processing and serializing the same row multiple times. +While SpacetimeDB can manage this redundancy, it may lead to unnecessary inefficiencies. + +Consider the following two queries: +```sql +SELECT * FROM User +SELECT * FROM User WHERE id = 5 +``` + +If `User.id` is a unique or primary key column, +the cost of subscribing to both queries is minimal. +This is because the server will use an index when processing the 2nd query, +and it will only serialize a single row for the 2nd query. + +In contrast, consider these two queries: +```sql +SELECT * FROM User +SELECT * FROM User WHERE id != 5 +``` + +The server must now process each row of the `User` table twice, +since the 2nd query cannot be processed using an index. +It must also serialize all but one row of the `User` table twice, +due to the significant overlap between the two queries. + +By following these best practices, you can optimize your data replication strategy and ensure your application remains efficient and responsive. diff --git a/docs/docs/subscriptions/semantics.md b/docs/docs/subscriptions/semantics.md new file mode 100644 index 00000000000..dd2a17db71d --- /dev/null +++ b/docs/docs/subscriptions/semantics.md @@ -0,0 +1,84 @@ + + +# SpacetimeDB Subscription Semantics + +This document describes the subscription semantics maintained by the SpacetimeDB host over WebSocket connections. These semantics outline message ordering guarantees, subscription handling, transaction updates, and client cache consistency. + +## WebSocket Communication Channels + +A single WebSocket connection between a client and the SpacetimeDB host consists of two distinct message channels: + +- **Client → Server:** Sends requests such as reducer invocations and subscription queries. +- **Server → Client:** Sends responses to client requests and database transaction updates. + +### Ordering Guarantees + +The server maintains the following guarantees: + +1. **Sequential Response Ordering:** + - Responses to client requests are always sent back in the same order the requests were received. If request A precedes request B, the response to A will always precede the response to B, even if A takes longer to process. + +2. **Atomic Transaction Updates:** + - Each database transaction (e.g., reducer invocation, INSERT, UPDATE, DELETE queries) generates exactly zero or one update message sent to clients. These updates are atomic and reflect the exact order of committed transactions. + +3. **Atomic Subscription Initialization:** + - When subscriptions are established, clients receive exactly one response containing all initially matching rows from a consistent database state snapshot taken between two transactions. + - The state snapshot reflects a committed database state that includes all previous transaction updates received and excludes all future transaction updates. + +## Subscription Workflow + +When invoking `SubscriptionBuilder::subscribe(QUERIES)` from the client SDK: + +1. **Client SDK → Host:** + - Sends a `Subscribe` message containing the specified QUERIES. + +2. **Host Processing:** + - Captures a snapshot of the committed database state. + - Evaluates the QUERIES against this snapshot to determine matching rows. + +3. **Host → Client SDK:** + - Sends a `SubscribeApplied` message containing the matching rows. + +4. **Client SDK Processing:** + - Receives and processes the message. + - Locks the client cache and inserts all rows atomically. + - Invokes relevant callbacks: + - `on_insert` callback for each row. + - `on_applied` callback for the subscription. + > **Note:** No relative ordering guarantees are made regarding the invocation order of these callbacks. + +## Transaction Update Workflow + +Upon committing a database transaction: + +1. **Transaction Results in a State Delta:** + - The result of a transaction is a state delta, i.e. an unordered set of inserted and deleted rows. + +2. **Host Evaluates Queries:** + - Evaluates the QUERIES against the state delta to determine matching altered rows. + +3. **Host → Client SDK:** + - Sends a `TransactionUpdate` message if relevant updates exist, containing affected rows and transaction metadata. + +4. **Client SDK Processing:** + - Receives and processes the message. + - Locks the client cache, applying deletions and insertions atomically. + - Invokes relevant callbacks: + - `on_insert`, `on_delete`, `on_update` callbacks for modified rows. + - Reducer callbacks, if the transaction was the result of a reducer. + > **Note:** No relative ordering guarantees are made regarding the invocation order of these callbacks. + +## Multiple Subscription Sets + +If multiple subscription sets are active, updates across these sets are bundled together into a single `TransactionUpdate` message. + +## Client Cache Guarantees + +- The client cache always maintains a consistent and correct subset of the committed database state. +- Callback functions invoked due to events have guaranteed visibility into a fully updated cache state. +- Reads from the client cache are effectively free as they access locally cached data. +- During callback execution, the client cache accurately reflects the database state immediately following the event-triggering transaction. + +### Pending Callbacks and Cache Consistency + +While processing a `TransactionUpdate` message, callbacks are queued within the SDK and deferred until the cache updates (inserts/deletes) from a transaction are fully applied. This ensures all callbacks see the fully consistent state of the cache, preventing callbacks from observing an inconsistent intermediate state. diff --git a/docs/docs/unity/index.md b/docs/docs/unity/index.md new file mode 100644 index 00000000000..007721bcb26 --- /dev/null +++ b/docs/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 acquainted with all the features and best practices of SpacetimeDB, while building [a fun little game](https://github.com/ClockworkLabs/Blackholio). + +By the end, you should have a basic understanding of what SpacetimeDB offers for developers making multiplayer games. + +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. + +SpacetimeDB supports Unity version `2022.3.32f1` or later, and this tutorial has been tested with the following Unity versions: + +- `2022.3.32f1 LTS` +- `6000.0.33f1` + +Please file an issue [here](https://github.com/clockworklabs/spacetime-docs/issues) if you encounter an issue with a specific Unity version, but please be aware that the SpacetimeDB team is unable to offer support for issues related to versions of Unity prior to `2022.3.32f1 LTS`. + +## Blackhol.io Tutorial - Basic Multiplayer + +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/docs/unity/part-1-hero-image.png b/docs/docs/unity/part-1-hero-image.png new file mode 100644 index 00000000000..b37d9690bfb Binary files /dev/null and b/docs/docs/unity/part-1-hero-image.png differ diff --git a/docs/docs/unity/part-1-unity-hub-new-project.jpg b/docs/docs/unity/part-1-unity-hub-new-project.jpg new file mode 100644 index 00000000000..7f23fdb8852 Binary files /dev/null and b/docs/docs/unity/part-1-unity-hub-new-project.jpg differ diff --git a/docs/docs/unity/part-1-universal-2d-template.png b/docs/docs/unity/part-1-universal-2d-template.png new file mode 100644 index 00000000000..414fae5bc99 Binary files /dev/null and b/docs/docs/unity/part-1-universal-2d-template.png differ diff --git a/docs/docs/unity/part-1.md b/docs/docs/unity/part-1.md new file mode 100644 index 00000000000..230bb95aed0 --- /dev/null +++ b/docs/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 + +SpacetimeDB supports Unity version `2022.3.32f1` or later. See [the overview](.) for more information on specific supported versions. + +Open Unity and create a new project by selecting "New" from the Unity Hub or going to **File -> New Project**. + + + +**⚠️ 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/docs/unity/part-2.md b/docs/docs/unity/part-2.md new file mode 100644 index 00000000000..ebfc7a695dd --- /dev/null +++ b/docs/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/docs/unity/part-3-player-on-screen.png b/docs/docs/unity/part-3-player-on-screen.png new file mode 100644 index 00000000000..16c23dd0c68 Binary files /dev/null and b/docs/docs/unity/part-3-player-on-screen.png differ diff --git a/docs/docs/unity/part-3.md b/docs/docs/unity/part-3.md new file mode 100644 index 00000000000..4dfb8e24224 --- /dev/null +++ b/docs/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/docs/unity/part-4.md b/docs/docs/unity/part-4.md new file mode 100644 index 00000000000..7e77fc83238 --- /dev/null +++ b/docs/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/docs/webassembly-abi/index.md b/docs/docs/webassembly-abi/index.md new file mode 100644 index 00000000000..de24635546b --- /dev/null +++ b/docs/docs/webassembly-abi/index.md @@ -0,0 +1,499 @@ +# Module ABI Reference + +This document specifies the _low level details_ of module-host interactions (_"Module ABI"_). _**Most users**_ looking to interact with the host will want to use derived and higher level functionality like [`bindings`], `#[spacetimedb(table)]`, and `#[derive(SpacetimeType)]` rather than this low level ABI. For more on those, read the [Rust module quick start][module_quick_start] guide and the [Rust module reference][module_ref]. + +The Module ABI is defined in [`bindings_sys::raw`] and is used by modules to interact with their host and perform various operations like: + +- logging, +- transporting data, +- scheduling reducers, +- altering tables, +- inserting and deleting rows, +- querying tables. + +In the next few sections, we'll define the functions that make up the ABI and what these functions do. + +## General notes + +The functions in this ABI all use the [`C` ABI on the `wasm32` platform][wasm_c_abi]. They are specified in a Rust `extern "C" { .. }` block. For those more familiar with the `C` notation, an [appendix][c_header] is provided with equivalent definitions as would occur in a `.h` file. + +Many functions in the ABI take in- or out-pointers, e.g. `*const u8` and `*mut u8`. The WASM host itself does not have undefined behavior. However, what WASM does not consider a memory access violation could be one according to some other language's abstract machine. For example, running the following on a WASM host would violate Rust's rules around writing across allocations: + +```rust +fn main() { + let mut bytes = [0u8; 12]; + let other_bytes = [0u8; 4]; + unsafe { ffi_func_with_out_ptr_and_len(&mut bytes as *mut u8, 16); } + assert_eq!(other_bytes, [0u8; 4]); +} +``` + +When we note in this reference that traps occur or errors are returned on memory access violations, we only mean those that WASM can directly detected, and not cases like the one above. + +Should memory access violations occur, such as a buffer overrun, undefined behavior will never result, as it does not exist in WASM. However, in many cases, an error code will result. + +Some functions will treat UTF-8 strings _lossily_. That is, if the slice identified by a `(ptr, len)` contains non-UTF-8 bytes, these bytes will be replaced with `�` in the read string. + +Most functions return a `u16` value. This is how these functions indicate an error where a `0` value means that there were no errors. Such functions will instead return any data they need to through out pointers. + +## Logging + +```rust +/// The error log level. +const LOG_LEVEL_ERROR: u8 = 0; +/// The warn log level. +const LOG_LEVEL_WARN: u8 = 1; +/// The info log level. +const LOG_LEVEL_INFO: u8 = 2; +/// The debug log level. +const LOG_LEVEL_DEBUG: u8 = 3; +/// The trace log level. +const LOG_LEVEL_TRACE: u8 = 4; +/// The panic log level. +/// +/// A panic level is emitted just before +/// a fatal error causes the WASM module to trap. +const LOG_LEVEL_PANIC: u8 = 101; + +/// Log at `level` a `text` message occuring in `filename:line_number` +/// with `target` being the module path at the `log!` invocation site. +/// +/// These various pointers are interpreted lossily as UTF-8 strings. +/// The data pointed to are copied. Ownership does not transfer. +/// +/// See https://docs.rs/log/latest/log/struct.Record.html#method.target +/// for more info on `target`. +/// +/// Calls to the function cannot fail +/// irrespective of memory access violations. +/// If they occur, no message is logged. +fn _console_log( + // The level we're logging at. + // One of the `LOG_*` constants above. + level: u8, + // The module path, if any, associated with the message + // or to "blame" for the reason we're logging. + // + // This is a pointer to a buffer holding an UTF-8 encoded string. + // When the pointer is `NULL`, `target` is ignored. + target: *const u8, + // The length of the buffer pointed to by `text`. + // Unused when `target` is `NULL`. + target_len: usize, + // The file name, if any, associated with the message + // or to "blame" for the reason we're logging. + // + // This is a pointer to a buffer holding an UTF-8 encoded string. + // When the pointer is `NULL`, `filename` is ignored. + filename: *const u8, + // The length of the buffer pointed to by `text`. + // Unused when `filename` is `NULL`. + filename_len: usize, + // The line number associated with the message + // or to "blame" for the reason we're logging. + line_number: u32, + // A pointer to a buffer holding an UTF-8 encoded message to log. + text: *const u8, + // The length of the buffer pointed to by `text`. + text_len: usize, +); +``` + +## Buffer handling + +```rust +/// Returns the length of buffer `bufh` without +/// transferring ownership of the data into the function. +/// +/// The `bufh` must have previously been allocating using `_buffer_alloc`. +/// +/// Traps if the buffer does not exist. +fn _buffer_len( + // The buffer previously allocated using `_buffer_alloc`. + // Ownership of the buffer is not taken. + bufh: ManuallyDrop +) -> usize; + +/// Consumes the buffer `bufh`, +/// moving its contents to the WASM byte slice `(ptr, len)`. +/// +/// Returns an error if the buffer does not exist +/// or on any memory access violations associated with `(ptr, len)`. +fn _buffer_consume( + // The buffer to consume and move into `(ptr, len)`. + // Ownership of the buffer and its contents are taken. + // That is, `bufh` won't be usable after this call. + bufh: Buffer, + // A WASM out pointer to write the contents of `bufh` to. + ptr: *mut u8, + // The size of the buffer pointed to by `ptr`. + // This size must match that of `bufh` or a trap will occur. + len: usize +); + +/// Creates a buffer of size `data_len` in the host environment. +/// +/// The contents of the byte slice lasting `data_len` bytes +/// at the `data` WASM pointer are read +/// and written into the newly initialized buffer. +/// +/// Traps on any memory access violations. +fn _buffer_alloc(data: *const u8, data_len: usize) -> Buffer; +``` + +## Reducer scheduling + +```rust +/// Schedules a reducer to be called asynchronously at `time`. +/// +/// The reducer is named as the valid UTF-8 slice `(name, name_len)`, +/// and is passed the slice `(args, args_len)` as its argument. +/// +/// A generated schedule id is assigned to the reducer. +/// This id is written to the pointer `out`. +/// +/// Errors on any memory access violations, +/// if `(name, name_len)` does not point to valid UTF-8, +/// or if the `time` delay exceeds `64^6 - 1` milliseconds from now. +fn _schedule_reducer( + // A pointer to a buffer + // with a valid UTF-8 string of `name_len` many bytes. + name: *const u8, + // The number of bytes in the `name` buffer. + name_len: usize, + // A pointer to a byte buffer of `args_len` many bytes. + args: *const u8, + // The number of bytes in the `args` buffer. + args_len: usize, + // When to call the reducer. + time: u64, + // The schedule ID is written to this out pointer on a successful call. + out: *mut u64, +); + +/// Unschedules a reducer +/// using the same `id` generated as when it was scheduled. +/// +/// This assumes that the reducer hasn't already been executed. +fn _cancel_reducer(id: u64); +``` + +## Altering tables + +```rust +/// Creates an index with the name `index_name` and type `index_type`, +/// on a product of the given columns in `col_ids` +/// in the table identified by `table_id`. +/// +/// Here `index_name` points to a UTF-8 slice in WASM memory +/// and `col_ids` points to a byte slice in WASM memory +/// with each element being a column. +/// +/// Currently only single-column-indices are supported +/// and they may only be of the btree index type. +/// In the former case, the function will panic, +/// and in latter, an error is returned. +/// +/// Returns an error on any memory access violations, +/// if `(index_name, index_name_len)` is not valid UTF-8, +/// or when a table with the provided `table_id` doesn't exist. +/// +/// Traps if `index_type /= 0` or if `col_len /= 1`. +fn _create_index( + // A pointer to a buffer holding an UTF-8 encoded index name. + index_name: *const u8, + // The length of the buffer pointed to by `index_name`. + index_name_len: usize, + // The ID of the table to create the index for. + table_id: u32, + // The type of the index. + // Must be `0` currently, that is, a btree-index. + index_type: u8, + // A pointer to a buffer holding a byte slice + // where each element is the position + // of a column to include in the index. + col_ids: *const u8, + // The length of the byte slice in `col_ids`. Must be `1`. + col_len: usize, +) -> u16; +``` + +## Inserting and deleting rows + +```rust +/// Inserts a row into the table identified by `table_id`, +/// where the row is read from the byte slice `row_ptr` in WASM memory, +/// lasting `row_len` bytes. +/// +/// Errors if there were unique constraint violations, +/// if there were any memory access violations in associated with `row`, +/// if the `table_id` doesn't identify a table, +/// or if `(row, row_len)` doesn't decode from BSATN to a `ProductValue` +/// according to the `ProductType` that the table's schema specifies. +fn _insert( + // The table to insert the row into. + // The interpretation of `(row, row_len)` depends on this ID + // as it's table schema determines how to decode the raw bytes. + table_id: u32, + // An in/out pointer to a byte buffer + // holding the BSATN-encoded `ProductValue` row data to insert. + // + // The pointer is written to with the inserted row re-encoded. + // This is due to auto-incrementing columns. + row: *mut u8, + // The length of the buffer pointed to by `row`. + row_len: usize +) -> u16; + +/// Deletes all rows in the table identified by `table_id` +/// where the column identified by `col_id` matches the byte string, +/// in WASM memory, pointed to by `value`. +/// +/// Matching is defined by decoding of `value` to an `AlgebraicValue` +/// according to the column's schema and then `Ord for AlgebraicValue`. +/// +/// The number of rows deleted is written to the WASM pointer `out`. +/// +/// Errors if there were memory access violations +/// associated with `value` or `out`, +/// if no columns were deleted, +/// or if the column wasn't found. +fn _delete_by_col_eq( + // The table to delete rows from. + table_id: u32, + // The position of the column to match `(value, value_len)` against. + col_id: u32, + // A pointer to a byte buffer holding a BSATN-encoded `AlgebraicValue` + // of the `AlgebraicType` that the table's schema specifies + // for the column identified by `col_id`. + value: *const u8, + // The length of the buffer pointed to by `value`. + value_len: usize, + // An out pointer that the number of rows deleted is written to. + out: *mut u32 +) -> u16; +``` + +## Querying tables + +```rust +/// Queries the `table_id` associated with the given (table) `name` +/// where `name` points to a UTF-8 slice +/// in WASM memory of `name_len` bytes. +/// +/// The table id is written into the `out` pointer. +/// +/// Errors on memory access violations associated with `name` +/// or if the table does not exist. +fn _get_table_id( + // A pointer to a buffer holding the name of the table + // as a valid UTF-8 encoded string. + name: *const u8, + // The length of the buffer pointed to by `name`. + name_len: usize, + // An out pointer to write the table ID to. + out: *mut u32 +) -> u16; + +/// Finds all rows in the table identified by `table_id`, +/// where the row has a column, identified by `col_id`, +/// with data matching the byte string, +/// in WASM memory, pointed to at by `val`. +/// +/// Matching is defined by decoding of `value` +/// to an `AlgebraicValue` according to the column's schema +/// and then `Ord for AlgebraicValue`. +/// +/// The rows found are BSATN encoded and then concatenated. +/// The resulting byte string from the concatenation +/// is written to a fresh buffer +/// with the buffer's identifier written to the WASM pointer `out`. +/// +/// Errors if no table with `table_id` exists, +/// if `col_id` does not identify a column of the table, +/// if `(value, value_len)` cannot be decoded to an `AlgebraicValue` +/// typed at the `AlgebraicType` of the column, +/// or if memory access violations occurred associated with `value` or `out`. +fn _iter_by_col_eq( + // Identifies the table to find rows in. + table_id: u32, + // The position of the column in the table + // to match `(value, value_len)` against. + col_id: u32, + // A pointer to a byte buffer holding a BSATN encoded + // value typed at the `AlgebraicType` of the column. + value: *const u8, + // The length of the buffer pointed to by `value`. + value_len: usize, + // An out pointer to which the new buffer's id is written to. + out: *mut Buffer +) -> u16; + +/// Starts iteration on each row, as bytes, +/// of a table identified by `table_id`. +/// +/// The iterator is registered in the host environment +/// under an assigned index which is written to the `out` pointer provided. +/// +/// Errors if the table doesn't exist +/// or if memory access violations occurred in association with `out`. +fn _iter_start( + // The ID of the table to start row iteration on. + table_id: u32, + // An out pointer to which an identifier + // to the newly created buffer is written. + out: *mut BufferIter +) -> u16; + +/// Like [`_iter_start`], starts iteration on each row, +/// as bytes, of a table identified by `table_id`. +/// +/// The rows are filtered through `filter`, which is read from WASM memory +/// and is encoded in the embedded language defined by `spacetimedb_lib::filter::Expr`. +/// +/// The iterator is registered in the host environment +/// under an assigned index which is written to the `out` pointer provided. +/// +/// Errors if `table_id` doesn't identify a table, +/// if `(filter, filter_len)` doesn't decode to a filter expression, +/// or if there were memory access violations +/// in association with `filter` or `out`. +fn _iter_start_filtered( + // The ID of the table to start row iteration on. + table_id: u32, + // A pointer to a buffer holding an encoded filter expression. + filter: *const u8, + // The length of the buffer pointed to by `filter`. + filter_len: usize, + // An out pointer to which an identifier + // to the newly created buffer is written. + out: *mut BufferIter +) -> u16; + +/// Advances the registered iterator with the index given by `iter_key`. +/// +/// On success, the next element (the row as bytes) is written to a buffer. +/// The buffer's index is returned and written to the `out` pointer. +/// If there are no elements left, an invalid buffer index is written to `out`. +/// On failure however, the error is returned. +/// +/// Errors if `iter` does not identify a registered `BufferIter`, +/// or if there were memory access violations in association with `out`. +fn _iter_next( + // An identifier for the iterator buffer to advance. + // Ownership of the buffer nor the identifier is moved into the function. + iter: ManuallyDrop, + // An out pointer to write the newly created buffer's identifier to. + out: *mut Buffer +) -> u16; + +/// Drops the entire registered iterator with the index given by `iter_key`. +/// The iterator is effectively de-registered. +/// +/// Returns an error if the iterator does not exist. +fn _iter_drop( + // An identifier for the iterator buffer to unregister / drop. + iter: ManuallyDrop +) -> u16; +``` + +## Appendix, `bindings.h` + +```c +#include +#include +#include +#include +#include + +typedef uint32_t Buffer; +typedef uint32_t BufferIter; + +void _console_log( + uint8_t level, + const uint8_t *target, + size_t target_len, + const uint8_t *filename, + size_t filename_len, + uint32_t line_number, + const uint8_t *text, + size_t text_len +); + + +Buffer _buffer_alloc( + const uint8_t *data, + size_t data_len +); +void _buffer_consume( + Buffer bufh, + uint8_t *into, + size_t len +); +size_t _buffer_len(Buffer bufh); + + +void _schedule_reducer( + const uint8_t *name, + size_t name_len, + const uint8_t *args, + size_t args_len, + uint64_t time, + uint64_t *out +); +void _cancel_reducer(uint64_t id); + + +uint16_t _create_index( + const uint8_t *index_name, + size_t index_name_len, + uint32_t table_id, + uint8_t index_type, + const uint8_t *col_ids, + size_t col_len +); + + +uint16_t _insert( + uint32_t table_id, + uint8_t *row, + size_t row_len +); +uint16_t _delete_by_col_eq( + uint32_t table_id, + uint32_t col_id, + const uint8_t *value, + size_t value_len, + uint32_t *out +); + + +uint16_t _get_table_id( + const uint8_t *name, + size_t name_len, + uint32_t *out +); +uint16_t _iter_by_col_eq( + uint32_t table_id, + uint32_t col_id, + const uint8_t *value, + size_t value_len, + Buffer *out +); +uint16_t _iter_drop(BufferIter iter); +uint16_t _iter_next(BufferIter iter, Buffer *out); +uint16_t _iter_start(uint32_t table_id, BufferIter *out); +uint16_t _iter_start_filtered( + uint32_t table_id, + const uint8_t *filter, + size_t filter_len, + BufferIter *out +); +``` + +[`bindings_sys::raw`]: https://github.com/clockworklabs/SpacetimeDB/blob/master/crates/bindings-sys/src/lib.rs#L44-L215 +[`bindings`]: https://github.com/clockworklabs/SpacetimeDB/blob/master/crates/bindings/src/lib.rs +[module_ref]: /docs/modules/rust +[module_quick_start]: /docs/modules/rust/quickstart +[wasm_c_abi]: https://github.com/WebAssembly/tool-conventions/blob/main/BasicCABI.md +[c_header]: #appendix-bindingsh diff --git a/docs/llms.md b/docs/llms.md new file mode 100644 index 00000000000..16ca54f2e47 --- /dev/null +++ b/docs/llms.md @@ -0,0 +1,2286 @@ +# SpacetimeDB + +> SpacetimeDB is a fully-featured relational database system that integrates +application logic directly within the database, eliminating the need for +separate web or game servers. It supports multiple programming languages, +including C# and Rust, allowing developers to write and deploy entire +applications as a single binary. It is optimized for high-throughput and low +latency multiplayer applications like multiplayer games. + +Users upload their application logic to run inside SpacetimeDB as a WebAssembly +module. There are three main features of SpacetimeDB: tables, reducers, and +subscription queries. Tables are relational database tables like you would find +in a database like Postgres. Reducers are atomic, transactional, RPC functions +that are defined in the WebAssembly module which can be called by clients. +Subscription queries are SQL queries which are made over a WebSocket connection +which are initially evaluated by SpacetimeDB and then incrementally evaluated +sending changes to the query result over the WebSocket. + +All data in the tables are stored in memory, but are persisted to the disk via a +Write-Ahead Log (WAL) called the Commitlog. All tables are persistent in +SpacetimeDB. + +SpacetimeDB allows users to code generate type-safe client libraries based on +the tables, types, and reducers defined in their module. Subscription queries +allows the client SDK to store a partial, live updating, replica of the servers +state. This makes reading database state on the client extremely low-latency. + +Authentication is implemented in SpacetimeDB using the OpenID Connect protocol. +An OpenID Connect token with a valid `iss`/`sub` pair constitutes a unique and +authenticable SpacetimeDB identity. SpacetimeDB uses the `Identity` type as an +identifier for all such identities. `Identity` is computed from the `iss`/`sub` +pair using the following algorithm: + +1. Concatenate the issuer and subject with a pipe symbol (`|`). +2. Perform the first BLAKE3 hash on the concatenated string. +3. Get the first 26 bytes of the hash (let's call this `idHash`). +4. Create a 28-byte sequence by concatenating the bytes `0xc2`, `0x00`, and `idHash`. +5. Compute the BLAKE3 hash of the 28-byte sequence from step 4 (let's call this `checksumHash`). +6. Construct the final 32-byte `Identity` by concatenating: the two prefix bytes (`0xc2`, `0x00`), the first 4 bytes of `checksumHash`, and the 26-byte `idHash`. +7. This final 32-byte value is typically represented as a hexadecimal string. + +```ascii +Byte Index: | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | ... | 31 | + +-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+ +Contents: | 0xc2| 0x00| Checksum Hash (4 bytes) | ID Hash (26 bytes) | + +-----+-----+-------------------------+---------------------+ + (First 4 bytes of (First 26 bytes of + BLAKE3(0xc200 || idHash)) BLAKE3(iss|sub)) +``` + +This allows SpacetimeDB to easily integrate with OIDC authentication +providers like FirebaseAuth, Auth0, or SuperTokens. + +Clockwork Labs, the developers of SpacetimeDB, offers three products: + +1. SpacetimeDB Standalone: a source available (Business Source License), single node, self-hosted version +2. SpacetimeDB Maincloud: a hosted, managed-service, serverless cluster +3. SpacetimeDB Enterprise: a closed-source, clusterized version of SpacetimeDB which can be licensed for on-prem hosting or dedicated hosting + +## Basic Project Workflow + +Getting started with SpacetimeDB involves a few key steps: + +1. **Install SpacetimeDB:** Install the `spacetime` CLI tool for your operating system. This tool is used for managing modules, databases, and local instances. + + * **macOS:** + ```bash + curl -sSf https://install.spacetimedb.com | sh + ``` + * **Windows (PowerShell):** + ```powershell + iwr https://windows.spacetimedb.com -useb | iex + ``` + * **Linux:** + ```bash + curl -sSf https://install.spacetimedb.com | sh + ``` + * **Docker (to run the server):** + ```bash + # This command starts a SpacetimeDB server instance in Docker + docker run --rm --pull always -p 3000:3000 clockworklabs/spacetime start + # Note: While the CLI can be installed separately (see above), you can also execute + # CLI commands *within* the running Docker container (e.g., using `docker exec`) + # or use the image as a base for a custom image containing your module management tools. + ``` + * **Docker (to execute CLI commands directly):** + You can also use the Docker image to run `spacetime` CLI commands without installing the CLI locally. For commands that operate on local files (like `build`, `publish`, `generate`), this involves mounting your project directory into the container. For commands that only interact with a database instance (like `sql`, `status`), mounting is typically not required, but network access to the database is. + ```bash + # Example: Build a module located in the current directory (.) + # Mount current dir to /module inside container, set working dir to /module + docker run --rm -v "$(pwd):/module" -w /module clockworklabs/spacetime build --project-path . + + # Example: Publish the module after building + # Assumes a local server is running (or use --host for Maincloud/other) + docker run --rm -v "$(pwd):/module" -w /module --network host clockworklabs/spacetime publish --project-path . my-database-name + # Note: `--network host` is often needed to connect to a local server from the container. + ``` + * For more details or troubleshooting, see the official [Getting Started Guide](https://spacetimedb.com/docs/getting-started) and [Installation Page](https://spacetimedb.com/install). + +1.b **Log In (If Necessary):** If you plan to publish to a server that requires authentication (like the public Maincloud at `maincloud.spacetimedb.com`), you generally need to log in first using `spacetime login`. This associates your actions with your global SpacetimeDB identity (e.g., linked to your spacetimedb.com account). + ```bash + spacetime login + # Follow the prompts to authenticate via web browser + ``` + If you attempt commands like `publish` against an authenticated server without being logged in, the CLI will prompt you: `You are not logged in. Would you like to log in with spacetimedb.com? [y/N]`. + * Choosing `y` initiates the standard browser login flow. + * Choosing `n` proceeds without a global login for this operation. The CLI will confirm `We have logged in directly to your target server. WARNING: This login will NOT work for any other servers.` This uses or creates a server-issued identity specific to that server (see Step 5). + + In general, using `spacetime login` (which authenticates via spacetimedb.com) is recommended, as the resulting identities are portable across different SpacetimeDB servers. + +2. **Initialize Server Module:** Create a new directory for your project and use the CLI to initialize the server module structure: + ```bash + # For Rust + spacetime init --lang rust my_server_module + # For C# + spacetime init --lang csharp my_server_module + ``` + :::note C# Project Filename Convention (SpacetimeDB CLI) + The `spacetime` CLI tool (particularly `publish` and `build`) follows a convention and often expects the C# project file (`.csproj`) to be named `StdbModule.csproj`, matching the default generated by `spacetime init`. This **is** a requirement of the SpacetimeDB tool itself (due to how it locates build artifacts), not the underlying .NET build system. This is a known issue tracked [here](https://github.com/clockworklabs/SpacetimeDB/issues/2475). If you encounter issues where the build succeeds but publishing fails (e.g., "couldn't find the output file" or silent failures after build), ensure your `.csproj` file is named `StdbModule.csproj` within your module's directory. + ::: +3. **Define Schema & Logic:** Edit the generated module code (`lib.rs` for Rust, `Lib.cs` for C#) to define your custom types (`[SpacetimeType]`/`[Type]`), database tables (`#[table]`/`[Table]`), and reducers (`#[reducer]`/`[Reducer]`). +4. **Build Module:** Compile your module code into WebAssembly using the CLI: + ```bash + # Run from the directory containing your module folder + spacetime build --project-path my_server_module + ``` + :::note C# Build Prerequisite (.NET SDK) + Building a **C# module** (on any platform: Windows, macOS, Linux) requires the .NET SDK to be installed. If the build fails with an error mentioning `dotnet workload list` or `No .NET SDKs were found`, you need to install the SDK first. Download and install the **.NET 8 SDK** specifically from the official Microsoft website: [https://dotnet.microsoft.com/download](https://dotnet.microsoft.com/download). Newer versions (like .NET 9) are not currently supported for building SpacetimeDB modules, although they can be installed alongside .NET 8 without conflicting. + ::: +5. **Publish Module:** Deploy your compiled module to a SpacetimeDB instance (either a local one started with `spacetime start` or the managed Maincloud). Publishing creates or updates a database associated with your module. + + * Providing a `[name|identity]` for the database is **optional**. If omitted, a nameless database will be created and assigned a unique `Identity` automatically. If providing a *name*, it must match the regex `^[a-z0-9]+(-[a-z0-9]+)*$`. + * By default (`--project-path`), it builds the module before publishing. Use `--bin-path ` to publish a pre-compiled WASM instead. + * Use `-s, --server ` to specify the target instance (e.g., `maincloud.spacetimedb.com` or the nickname `maincloud`). If omitted, it targets a local instance or uses your configured default (check with `spacetime server list`). + * Use `-c, --delete-data` when updating an existing database identity to destroy all existing data first. + + :::note Server-Issued Identities + If you publish without being logged in (and choose to proceed without a global login when prompted), the SpacetimeDB server instance will generate or use a unique "server-issued identity" for the database operation. This identity is specific to that server instance. Its issuer (`iss`) is specifically `http://localhost`, and its subject (`sub`) will be a generated UUIDv4. This differs from the global identities derived from OIDC providers (like spacetimedb.com) when you use `spacetime login`. The token associated with this identity is signed by the issuing server, and the signature will be considered invalid if the token is presented to any other SpacetimeDB server instance. + ::: + + ```bash + # Build and publish from source to 'my-database-name' on the default server + spacetime publish --project-path my_server_module my-database-name + + # Example: Publish a pre-compiled wasm to Maincloud using its nickname, clearing existing data + spacetime publish --bin-path ./my_module/target/wasm32-wasi/debug/my_module.wasm -s maincloud -c my-cloud-db-identity + ``` + +6. **List Databases (Optional):** Use `spacetime list` to see the databases associated with your logged-in identity on the target server (defaults to your configured server). This is helpful to find the `Identity` of databases, especially unnamed ones. + ```bash + # List databases on the default server + spacetime list + + # List databases on Maincloud + # spacetime list -s maincloud + ``` + +7. **Generate Client Bindings:** Create type-safe client code based on your module's definitions. + This command inspects your compiled module's schema (tables, types, reducers) and generates corresponding code (classes, structs, functions) for your target client language. This allows you to interact with your SpacetimeDB module in a type-safe way on the client. + ```bash + # For Rust client (output to src/module_bindings) + spacetime generate --lang rust --out-dir path/to/client/src/module_bindings --project-path my_server_module + # For C# client (output to module_bindings directory) + spacetime generate --lang csharp --out-dir path/to/client/module_bindings --project-path my_server_module + ``` +8. **Develop Client:** Create your client application (e.g., Rust binary, C# console app, Unity game). Use the generated bindings and the appropriate client SDK to: + * Connect to the database (`my-database-name`). + * Subscribe to data in public tables. + * Register callbacks to react to data changes. + * Call reducers defined in your module. +9. **Run:** Start your SpacetimeDB instance (if local or Docker), then run your client application. + +10. **Inspect Data (Optional):** Use the `spacetime sql` command to run SQL queries directly against your database to view or verify data. + ```bash + # Query all data from the 'player_state' table in 'my-database-name' + # Note: Table names are case-sensitive (match your definition) + spacetime sql my-database-name "SELECT * FROM PlayerState" + + # Use --interactive for a SQL prompt + # spacetime sql --interactive my-database-name + ``` + +11. **View Logs (Optional):** Use the `spacetime logs` command to view logs generated by your module's reducers (e.g., using `log::info!` in Rust or `Log.Info()` in C#). + ```bash + # Show all logs for 'my-database-name' + spacetime logs my-database-name + + # Follow the logs in real-time (like tail -f) + # spacetime logs -f my-database-name + + # Show the last 50 log lines + # spacetime logs -n 50 my-database-name + ``` + +12. **Delete Database (Optional):** When you no longer need a database (e.g., after testing), you can delete it using `spacetime delete` with its name or identity. + ```bash + # Delete the database named 'my-database-name' + spacetime delete my-database-name + + # Delete a database by its identity (replace with actual identity) + # spacetime delete 0x123abc... + ``` + +## Core Concepts and Syntax Examples + +### Reducer Context: Understanding Identities and Execution Information + +When a reducer function executes, it is provided with a **Reducer Context**. This context contains vital information about the call's origin and environment, crucial for logic, especially security checks. Key pieces of information typically available within the context include: + +* **Sender Identity**: The authenticated [`Identity`](#identity) of the entity that invoked the reducer. This could be: + * A client application connected to the database. + * The module itself, if the reducer was triggered by the internal scheduler (for scheduled reducers). + * The module itself, if the reducer was called internally by another reducer function within the same module. +* **Module Identity**: The authenticated [`Identity`](#identity) representing the database (module) itself. This is useful for checks where an action should only be performed by the module (e.g., in scheduled reducers). +* **Database Access**: Handles or interfaces for interacting with the database tables defined in the module. This allows the reducer to perform operations like inserting, updating, deleting, and querying rows based on primary keys or indexes. +* **Timestamp**: A [`Timestamp`](#timestamp) indicating precisely when the current reducer execution began. +* **Connection ID**: A [`ConnectionId`](#connectionid) representing the specific network connection instance (like a WebSocket session or a stateless HTTP request) that invoked the reducer. This is a unique, server-assigned identifier that persists only for the duration of that connection (from connection start to disconnect). + * **Important Distinction**: Unlike the **Sender Identity** (which represents the *authenticated user or module*), the **Connection ID** solely identifies the *transient network session*. It is assigned by the server and is not based on client-provided authentication credentials. Use the Connection ID for logic tied to a specific connection instance (e.g., tracking session state, rate limiting per connection), and use the Sender Identity for logic related to the persistent, authenticated user or the module itself. + +Understanding the difference between the **Sender Identity** and the **Module Identity** is particularly important for security. For example, when writing scheduled reducers, you often need to verify that the **Sender Identity** matches the **Module Identity** to ensure the action wasn't improperly triggered by an external client. + +### Server Module (Rust) + +#### Defining Types + +Custom structs or enums intended for use as fields within database tables or as parameters/return types in reducers must derive `SpacetimeType`. This derivation enables SpacetimeDB to handle the serialization and deserialization of these types. + +* **Basic Usage:** Apply `#[derive(SpacetimeType, ...)]` to your structs and enums. Other common derives like `Clone`, `Debug`, `PartialEq` are often useful. +* **Cross-Language Naming:** Use the `#[sats(name = "Namespace.TypeName")]` attribute *on the type definition* to explicitly control the name exposed in generated client bindings (e.g., for C# or TypeScript). This helps prevent naming collisions and provides better organization. You can also use `#[sats(name = "VariantName")]` *on enum variants* to control their generated names. +* **Type Aliases:** Standard Rust `pub type` aliases can be used for clarity (e.g., `pub type PlayerScore = u32;`). The underlying primitive type must still be serializable by SpacetimeDB. +* **Advanced Deserialization:** For types with complex requirements (like lifetimes or custom binary representations), you might need manual implementation using `spacetimedb::Deserialize` and the `bsatn` crate (available via `spacetimedb::spacetimedb_lib`), though this is uncommon for typical application types. + +```rust +use spacetimedb::{SpacetimeType, Identity, Timestamp}; + +// Example Struct +#[derive(SpacetimeType, Clone, Debug, PartialEq)] +pub struct Position { + pub x: i32, + pub y: i32, +} + +// Example Enum +#[derive(SpacetimeType, Clone, Debug, PartialEq)] +pub enum PlayerStatus { + Idle, + Walking(Position), + Fighting(Identity), // Store the identity of the opponent +} + +// Example Enum with Cross-Language Naming Control +// This enum will appear as `Game.ItemType` in C# bindings. +#[derive(SpacetimeType, Clone, Debug, PartialEq)] +#[sats(name = "Game.ItemType")] +pub enum ItemType { + Weapon, + Armor, + // This specific variant will be `ConsumableItem` in C# bindings. + #[sats(name = "ConsumableItem")] + Potion, +} + +// Example Type Alias +pub type PlayerScore = u32; + +// Advanced: For types with lifetimes or custom binary representations, +// you can derive `spacetimedb::Deserialize` and use the `bsatn` crate +// (provided by spacetimedb::spacetimedb_lib) for manual deserialization if needed. +``` + +:::info Rust `crate-type = ["cdylib"]` +The `[lib]` section in your module's `Cargo.toml` must contain `crate-type = ["cdylib"]`. This tells the Rust compiler to produce a dynamic system library compatible with the C ABI, which allows the SpacetimeDB host (written in Rust) to load and interact with your compiled WebAssembly module. +::: + +#### Defining Tables + +Database tables store the application's persistent state. They are defined using Rust structs annotated with the `#[table]` macro. + +* **Core Attribute:** `#[table(name = my_table_name, ...)]` marks a struct as a database table definition. The specified `name` (an identifier, *not* a string literal) is how the table will be referenced in SQL queries and generated APIs. +* **Derivations:** The `#[table]` macro automatically handles deriving necessary traits like `SpacetimeType`, `Serialize`, `Deserialize`, and `Debug`. **Do not** manually add `#[derive(SpacetimeType)]` to a `#[table]` struct, as it will cause compilation conflicts. +* **Public vs. Private:** By default, tables are **private**, accessible only by server-side reducer code. To allow clients to read or subscribe to a table's data, mark it as `public` using `#[table(..., public)]`. This is a common source of errors if forgotten. +* **Primary Keys:** Designate a single field as the primary key using `#[primary_key]`. This ensures uniqueness, creates an efficient index, and allows clients to track row updates. +* **Auto-Increment:** Mark an integer-typed primary key field with `#[auto_inc]` to have SpacetimeDB automatically assign unique, sequentially increasing values upon insertion. Provide `0` as the value for this field when inserting a new row to trigger the auto-increment mechanism. +* **Unique Constraints:** Enforce uniqueness on non-primary key fields using `#[unique]`. Attempts to insert or update rows violating this constraint will fail. +* **Indexes:** Create B-tree indexes for faster lookups on specific fields or combinations of fields. Use `#[index(btree)]` on a single field for a simple index, or `#[table(index(name = my_index_name, btree(columns = [col_a, col_b])))])` within the `#[table(...)]` attribute for named, multi-column indexes. +* **Nullable Fields:** Use standard Rust `Option` for fields that can hold null values. +* **Instances vs. Database:** Remember that table struct instances (e.g., `let player = PlayerState { ... };`) are just data. Modifying an instance does **not** automatically update the database. Interaction happens through generated handles accessed via the `ReducerContext` (e.g., `ctx.db.player_state().insert(...)`). +* **Case Sensitivity:** Table names specified via `name = ...` are case-sensitive and must be matched exactly in SQL queries. +* **Pitfalls:** + * Avoid manually inserting values into `#[auto_inc]` fields that are also `#[unique]`, especially values larger than the current sequence counter, as this can lead to future unique constraint violations when the counter catches up. + * Ensure `public` is set if clients need access. + * Do not manually derive `SpacetimeType`. + * Define indexes *within* the main `#[table(name=..., index=...)]` attribute. Each `#[table]` macro invocation defines a *distinct* table and requires a `name`; separate `#[table]` attributes cannot be used solely to add indexes to a previously named table. + +```rust +use spacetimedb::{table, Identity, Timestamp, SpacetimeType, Table}; // Added Table import + +// Assume Position, PlayerStatus, ItemType are defined as types + +// Example Table Definition +#[table( + name = player_state, + public, + // Index definition is included here + index(name = idx_level_btree, btree(columns = [level])) +)] +#[derive(Clone, Debug)] // No SpacetimeType needed here +pub struct PlayerState { + #[primary_key] + player_id: Identity, + #[unique] // Player names must be unique + name: String, + conn_id: Option, // Nullable field + health: u32, + level: u16, + position: Position, // Custom type field + status: PlayerStatus, // Custom enum field + last_login: Option, // Nullable timestamp +} + +#[table(name = inventory_item, public)] +#[derive(Clone, Debug)] +pub struct InventoryItem { + #[primary_key] + #[auto_inc] // Automatically generate IDs + item_id: u64, + owner_id: Identity, + #[index(btree)] // Simple index on this field + item_type: ItemType, + quantity: u32, +} + +// Example of a private table +#[table(name = internal_game_data)] // No `public` flag +#[derive(Clone, Debug)] +struct InternalGameData { + #[primary_key] + key: String, + value: String, +} +``` + +##### Multiple Tables from One Struct + +:::caution Wrapper Struct Pattern Not Supported for This Use Case +Defining multiple tables using wrapper tuple structs (e.g., `struct ActiveCharacter(CharacterInfo);`) where field attributes like `#[primary_key]`, `#[unique]`, etc., are defined only on fields inside the inner struct (`CharacterInfo` in this example) is **not supported**. This pattern can lead to macro expansion issues and compilation errors because the `#[table]` macro applied to the wrapper struct cannot correctly process attributes defined within the inner type. +::: + +**Recommended Pattern:** Apply multiple `#[table(...)]` attributes directly to the single struct definition that contains the necessary fields and field-level attributes (like `#[primary_key]`). This maps the same underlying type definition to multiple distinct tables reliably: + +```rust +use spacetimedb::{table, Identity, Timestamp, Table}; // Added Table import + +// Define the core data structure once +// Note: #[table] automatically derives SpacetimeType, Serialize, Deserialize +// Do NOT add #[derive(SpacetimeType)] here. +#[derive(Clone, Debug)] +#[table(name = logged_in_players, public)] // Identifier name +#[table(name = players_in_lobby, public)] // Identifier name +pub struct PlayerSessionData { + #[primary_key] + player_id: Identity, + #[unique] + #[auto_inc] + session_id: u64, + last_activity: Timestamp, +} + +// Example Reducer demonstrating interaction +#[spacetimedb::reducer] +fn example_reducer(ctx: &spacetimedb::ReducerContext) { + // Reducers interact with the specific table handles: + let session = PlayerSessionData { + player_id: ctx.sender, // Example: Use sender identity + session_id: 0, // Assuming auto_inc + last_activity: ctx.timestamp, + }; + + // Insert into the 'logged_in_players' table + match ctx.db.logged_in_players().try_insert(session.clone()) { + Ok(inserted) => spacetimedb::log::info!("Player {} logged in, session {}", inserted.player_id, inserted.session_id), + Err(e) => spacetimedb::log::error!("Failed to insert into logged_in_players: {}", e), + } + + // Find a player in the 'players_in_lobby' table by primary key + if let Some(lobby_player) = ctx.db.players_in_lobby().player_id().find(&ctx.sender) { + spacetimedb::log::info!("Player {} found in lobby.", lobby_player.player_id); + } + + // Delete from the 'logged_in_players' table using the PK index + ctx.db.logged_in_players().player_id().delete(&ctx.sender); +} +``` + +##### Browsing Generated Table APIs + +The `#[table]` macro generates specific accessor methods based on your table definition (name, fields, indexes, constraints). To see the exact API generated for your tables: + +1. Run `cargo doc --open` in your module project directory. +2. This compiles your code and opens the generated documentation in your web browser. +3. Navigate to your module's documentation. You will find: + * The struct you defined (e.g., `PlayerState`). + * A generated struct representing the table handle (e.g., `player_state__TableHandle`), which implements `spacetimedb::Table` and contains methods for accessing indexes and unique columns. + * A generated trait (e.g., `player_state`) used to access the table handle via `ctx.db.{table_name}()`. + +Reviewing this generated documentation is the best way to understand the specific methods available for interacting with your defined tables and their indexes. + +#### Defining Reducers + +Reducers are the functions within your server module responsible for atomically modifying the database state in response to client requests or internal events (like lifecycle triggers or schedules). + +* **Core Attribute:** Reducers are defined as standard Rust functions annotated with `#[reducer]`. +* **Signature:** Every reducer function must accept `&ReducerContext` as its first argument. Subsequent arguments represent data passed from the client caller or scheduler, and their types must derive `SpacetimeType`. +* **Return Type:** Reducers typically return `()` for success or `Result<(), E>` (where `E: Display`) to signal recoverable errors. +* **Necessary Imports:** To perform table operations (insert, update, delete, query indexes), the `spacetimedb::Table` trait must be in scope. Add `use spacetimedb::Table;` to the top of your `lib.rs`. +* **Reducer Context:** The `ReducerContext` (`ctx`) provides access to: + * `ctx.db`: Handles for interacting with database tables. + * `ctx.sender`: The `Identity` of the caller. + * `ctx.identity`: The `Identity` of the module itself. + * `ctx.timestamp`: The `Timestamp` of the invocation. + * `ctx.connection_id`: The optional `ConnectionId` of the caller. + * `ctx.rng`: A source for deterministic random number generation (if needed). +* **Transactionality:** Each reducer call executes within a single, atomic database transaction. If the function returns `()` or `Ok(())`, all database changes are committed. If it returns `Err(...)` or panics, the transaction is aborted, and **all changes are rolled back**, preserving data integrity. +* **Execution Environment:** Reducers run in a sandbox and **cannot** directly perform network I/O (`std::net`) or filesystem operations (`std::fs`, `std::io`). External interaction primarily occurs through database table modifications (observed by clients) and logging (`spacetimedb::log`). +* **Calling Other Reducers:** A reducer can directly call another reducer defined in the same module. This is a standard function call and executes within the *same* transaction; it does not create a sub-transaction. + +```rust +use spacetimedb::{reducer, ReducerContext, Table, Identity, Timestamp, log}; + +// Assume User and Message tables are defined as previously +#[table(name = user, public)] +#[derive(Clone, Debug)] pub struct User { #[primary_key] identity: Identity, name: Option, online: bool } +#[table(name = message, public)] +#[derive(Clone, Debug)] pub struct Message { #[primary_key] #[auto_inc] id: u64, sender: Identity, text: String, sent: Timestamp } + +// Example: Basic reducer to set a user's name +#[reducer] +pub fn set_name(ctx: &ReducerContext, name: String) -> Result<(), String> { + let sender_id = ctx.sender; + let name = validate_name(name)?; // Use helper for validation + + // Find the user row by primary key + if let Some(mut user) = ctx.db.user().identity().find(&sender_id) { + // Update the field + user.name = Some(name); + // Persist the change using the PK index update method + ctx.db.user().identity().update(user); + log::info!("User {} set name", sender_id); + Ok(()) + } else { + Err(format!("User not found: {}", sender_id)) + } +} + +// Example: Basic reducer to send a message +#[reducer] +pub fn send_message(ctx: &ReducerContext, text: String) -> Result<(), String> { + let text = validate_message(text)?; // Use helper for validation + log::info!("User {} sent message: {}", ctx.sender, text); + + // Insert a new row into the Message table + // Note: id is auto_inc, so we provide 0. insert() panics on constraint violation. + let new_message = Message { + id: 0, + sender: ctx.sender, + text, + sent: ctx.timestamp, + }; + ctx.db.message().insert(new_message); + // For Result-based error handling on insert, use try_insert() - see below + + Ok(()) +} + +// Helper validation functions (example) +fn validate_name(name: String) -> Result { + if name.is_empty() { Err("Name cannot be empty".to_string()) } else { Ok(name) } +} + +fn validate_message(text: String) -> Result { + if text.is_empty() { Err("Message cannot be empty".to_string()) } else { Ok(text) } +} +``` + +##### Error Handling: `Result` vs. Panic + +Reducers can indicate failure either by returning `Err` from a function with a `Result` return type or by panicking (e.g., using `panic!`, `unwrap`, `expect`). Both methods trigger a transaction rollback, ensuring atomicity. + +* **Returning `Err(E)**:** + * This is generally preferred for handling *expected* or recoverable failures (e.g., invalid input, failed validation checks). + * The error value `E` (which must implement `Display`) is propagated back to the calling client and can be observed in the `ReducerEventContext` status. + * Crucially, returning `Err` does **not** destroy the underlying WebAssembly (WASM) instance. + +* **Panicking:** + * This typically represents an *unexpected* bug, violated invariant, or unrecoverable state (e.g., assertion failure, unexpected `None` value). + * The client **will** receive an error message derived from the panic payload (the argument provided to `panic!`, or the messages from `unwrap`/`expect`). + * Panicking does **not** cause the client to be disconnected. + * However, a panic **destroys the current WASM instance**. This means the *next* reducer call (from any client) that runs on this module will incur additional latency as SpacetimeDB needs to create and initialize a fresh WASM instance. + +**Choosing between them:** While both ensure data consistency via rollback, returning `Result::Err` is generally better for predictable error conditions as it avoids the performance penalty associated with WASM instance recreation caused by panics. Use `panic!` for truly exceptional circumstances where state is considered unrecoverable or an unhandled bug is detected. + +##### Lifecycle Reducers + +Special reducers handle specific events: +* `#[reducer(init)]`: Runs once when the module is first published **and** any time the database is manually cleared (e.g., via `spacetime publish -c` or `spacetime server clear`). Failure prevents publishing or clearing. Often used for initial data setup. +* `#[reducer(client_connected)]`: Runs when any distinct client connection (e.g., WebSocket, HTTP call) is established. Failure disconnects the client. `ctx.connection_id` is guaranteed to be `Some(...)` within this reducer. +* `#[reducer(client_disconnected)]`: Runs when any distinct client connection terminates. Failure is logged but does not prevent disconnection. `ctx.connection_id` is guaranteed to be `Some(...)` within this reducer. + +These reducers cannot take arguments beyond `&ReducerContext`. + +```rust +use spacetimedb::{reducer, table, ReducerContext, Table, log}; + +#[table(name = settings)] +#[derive(Clone, Debug)] +pub struct Settings { + #[primary_key] + key: String, + value: String, +} + +// Example init reducer: Insert default settings if the table is empty +#[reducer(init)] +pub fn initialize_database(ctx: &ReducerContext) { + log::info!( + "Database Initializing! Module Identity: {}, Timestamp: {}", + ctx.identity(), + ctx.timestamp + ); + // Check if settings table is empty + if ctx.db.settings().count() == 0 { + log::info!("Settings table is empty, inserting default values..."); + // Insert default settings + ctx.db.settings().insert(Settings { + key: "welcome_message".to_string(), + value: "Hello from SpacetimeDB!".to_string(), + }); + ctx.db.settings().insert(Settings { + key: "default_score".to_string(), + value: "0".to_string(), + }); + } else { + log::info!("Settings table already contains data."); + } +} + +// Example client_connected reducer +#[reducer(client_connected)] +pub fn handle_connect(ctx: &ReducerContext) { + log::info!("Client connected: {}, Connection ID: {:?}", ctx.sender, ctx.connection_id); + // ... setup initial state for ctx.sender ... +} + +// Example client_disconnected reducer +#[reducer(client_disconnected)] +pub fn handle_disconnect(ctx: &ReducerContext) { + log::info!("Client disconnected: {}, Connection ID: {:?}", ctx.sender, ctx.connection_id); + // ... cleanup state for ctx.sender ... +} +``` + +##### Filtering and Deleting with Indexes + +SpacetimeDB provides powerful ways to filter and delete table rows using B-tree indexes. The generated accessor methods accept various argument types: + +* **Single Value (Equality):** + * For columns of type `String`, you can pass `&String` or `&str`. + * For columns of a type `T` that implements `Copy`, you can pass `&T` or an owned `T`. + * For other column types `T`, pass a reference `&T`. +* **Ranges:** Use Rust's range syntax (`start..end`, `start..=end`, `..end`, `..=end`, `start..`). Values within the range can typically be owned or references. +* **Multi-Column Indexes:** + * To filter on an exact match for a *prefix* of the index columns, provide a tuple containing single values (following the rules above) for that prefix (e.g., `filter((val_a, val_b))` for an index on `[a, b, c]`). + * To filter using a range, you **must** provide single values for all preceding columns in the index, and the range can **only** be applied to the *last* column in your filter tuple (e.g., `filter((val_a, val_b, range_c))` is valid, but `filter((val_a, range_b, val_c))` or `filter((range_a, val_b))` are **not** valid tuple filters). + * Filtering or deleting using a range on *only the first column* of the index (without using a tuple) remains valid (e.g., `filter(range_a)`). + +```rust +use spacetimedb::{table, reducer, ReducerContext, Table, log}; + +#[table(name = points, index(name = idx_xy, btree(columns = [x, y])))] +#[derive(Clone, Debug)] +pub struct Point { #[primary_key] id: u64, x: i64, y: i64 } +#[table(name = items, index(btree(columns = [name])))] +#[derive(Clone, Debug)] // No SpacetimeType derive +pub struct Item { #[primary_key] item_key: u32, name: String } + +#[reducer] +fn index_operations(ctx: &ReducerContext) { + // Example: Find items named "Sword" using the generated 'name' index handle + // Passing &str for a String column is allowed. + for item in ctx.db.items().name().filter("Sword") { + // ... + } + + // Example: Delete points where x is between 5 (inclusive) and 10 (exclusive) + // using the multi-column index 'idx_xy' - filtering on first column range is OK. + let num_deleted = ctx.db.points().idx_xy().delete(5i64..10i64); + log::info!("Deleted {} points", num_deleted); + + // Example: Find points where x = 3 and y >= 0 + // using the multi-column index 'idx_xy' - (value, range) is OK. + // Note: x is i64 which is Copy, so passing owned 3i64 is allowed. + for point in ctx.db.points().idx_xy().filter((3i64, 0i64..)) { + // ... + } + + // Example: Find points where x > 5 and y = 1 + // This is INVALID: Cannot use range on non-last element of tuple filter. + // for point in ctx.db.points().idx_xy().filter((5i64.., 1i64)) { ... } + + // Example: Delete all points where x = 7 (filtering on index prefix with single value) + // using the multi-column index 'idx_xy'. Passing owned 7i64 is allowed (Copy type). + ctx.db.points().idx_xy().delete(7i64); + + // Example: Delete a single item by its primary key 'item_key' + // Use the PK field name as the method to get the PK index handle, then call delete. + // item_key is u32 (Copy), passing owned value is allowed. + let item_id_to_delete = 101u32; + ctx.db.items().item_key().delete(item_id_to_delete); + + // Using references for a range filter on the first column - OK + let min_x = 100i64; + let max_x = 200i64; + for point in ctx.db.points().idx_xy().filter(&min_x..=&max_x) { + // ... + } +} +``` + +##### Using `try_insert()` + +Instead of `insert()`, which panics or throws if a constraint (like a primary key or unique index violation) occurs, Rust modules can use `try_insert()`. This method returns a `Result>`, allowing you to gracefully handle potential insertion failures without aborting the entire reducer transaction due to a panic. + +The `TryInsertError` enum provides specific variants detailing the cause of failure, such as `UniqueConstraintViolation` or `AutoIncOverflow`. These variants contain associated types specific to the table's constraints (e.g., `TableHandleType::UniqueConstraintViolation`). If a table lacks a certain constraint (like a unique index), the corresponding associated type might be uninhabited. + +```rust +use spacetimedb::{table, reducer, ReducerContext, Table, log, TryInsertError}; + +#[table(name = items)] +#[derive(Clone, Debug)] +pub struct Item { + #[primary_key] #[auto_inc] id: u64, + #[unique] name: String +} + +#[reducer] +pub fn try_add_item(ctx: &ReducerContext, name: String) -> Result<(), String> { + // Assume Item has an auto-incrementing primary key 'id' and a unique 'name' + let new_item = Item { id: 0, name }; // Provide 0 for auto_inc + + // try_insert returns Result> + match ctx.db.items().try_insert(new_item) { + Ok(inserted_item) => { + // try_insert returns the inserted row (with assigned PK if auto_inc) on success + log::info!("Successfully inserted item with ID: {}", inserted_item.id); + Ok(()) + } + Err(e) => { + // Match on the specific TryInsertError variant + match e { + TryInsertError::UniqueConstraintViolation(constraint_error) => { + // constraint_error is of type items__TableHandle::UniqueConstraintViolation + // This type often provides details about the violated constraint. + // For simplicity, we just log a generic message here. + let error_msg = format!("Failed to insert item: Name '{}' already exists.", name); + log::error!("{}", error_msg); + // Return an error to the calling client + Err(error_msg) + } + TryInsertError::AutoIncOverflow(_) => { + // Handle potential overflow of the auto-incrementing key + let error_msg = "Failed to insert item: Auto-increment counter overflow.".to_string(); + log::error!("{}", error_msg); + Err(error_msg) + } + // Use a wildcard for other potential errors or uninhabited variants + _ => { + let error_msg = format!("Failed to insert item: Unknown constraint violation."); + log::error!("{}", error_msg); + Err(error_msg) + } + } + } + } +} + +#### Scheduled Reducers (Rust) + +In addition to lifecycle annotations, reducers can be scheduled. This allows calling the reducers at a particular time, or in a loop. This can be used for game loops. + +The scheduling information for a reducer is stored in a table. This table has two mandatory fields: + +* A primary key that identifies scheduled reducer calls (often using `#[auto_inc]`). +* A field of type `spacetimedb::ScheduleAt` that says when to call the reducer. + +The table definition itself links to the reducer function using the `scheduled(reducer_function_name)` parameter within the `#[table(...)]` attribute. + +Managing timers with a scheduled table is as simple as inserting or deleting rows from the table. This makes scheduling transactional in SpacetimeDB. If a reducer A first schedules B but then errors for some other reason, B will not be scheduled to run. + +A `ScheduleAt` value can be created using `.into()` from: + +* A `spacetimedb::Timestamp`: Schedules the reducer to run **once** at that specific time. +* A `spacetimedb::TimeDuration` or `std::time::Duration`: Schedules the reducer to run **periodically** with that duration as the interval. + +The scheduled reducer function itself is defined like a normal reducer (`#[reducer]`), taking `&ReducerContext` and an instance of the schedule table struct as arguments. + +```rust +use spacetimedb::{table, reducer, ReducerContext, Timestamp, TimeDuration, ScheduleAt, Table}; +use log::debug; + +// 1. Declare the table with scheduling information, linking it to `send_message`. +#[table(name = send_message_schedule, scheduled(send_message))] +struct SendMessageSchedule { + // Mandatory fields: + // ============================ + + /// An identifier for the scheduled reducer call. + #[primary_key] + #[auto_inc] + scheduled_id: u64, + + /// Information about when the reducer should be called. + scheduled_at: ScheduleAt, + + // In addition to the mandatory fields, any number of fields can be added. + // These can be used to provide extra information to the scheduled reducer. + + // Custom fields: + // ============================ + + /// The text of the scheduled message to send. + text: String, +} + +// 2. Declare the scheduled reducer. +// The second argument is a row of the scheduling information table. +#[reducer] +fn send_message(ctx: &ReducerContext, args: SendMessageSchedule) -> Result<(), String> { + // Security check is important! + if ctx.sender != ctx.identity() { + return Err("Reducer `send_message` may not be invoked by clients, only via scheduling.".into()); + } + + let message_to_send = &args.text; + log::info!("Scheduled SendMessage: {}", message_to_send); + + // ... potentially send the message or perform other actions ... + + Ok(()) +} + +// 3. Example of scheduling reducers (e.g., in init) +#[reducer(init)] +fn init(ctx: &ReducerContext) -> Result<(), String> { + + let current_time = ctx.timestamp; + let ten_seconds = TimeDuration::from_micros(10_000_000); + let future_timestamp: Timestamp = ctx.timestamp + ten_seconds; + + // Schedule a one-off message + ctx.db.send_message_schedule().insert(SendMessageSchedule { + scheduled_id: 0, // Use 0 for auto_inc + text: "I'm a bot sending a message one time".to_string(), + // Creating a `ScheduleAt` from a `Timestamp` results in the reducer + // being called once, at exactly the time `future_timestamp`. + scheduled_at: future_timestamp.into() + }); + log::info!("Scheduled one-off message."); + + // Schedule a periodic message (every 10 seconds) + let loop_duration: TimeDuration = ten_seconds; + ctx.db.send_message_schedule().insert(SendMessageSchedule { + scheduled_id: 0, // Use 0 for auto_inc + text: "I'm a bot sending a message every 10 seconds".to_string(), + // Creating a `ScheduleAt` from a `Duration`/`TimeDuration` results in the reducer + // being called in a loop, once every `loop_duration`. + scheduled_at: loop_duration.into() + }); + log::info!("Scheduled periodic message."); + + Ok(()) +} +``` + +Refer to the [official Rust Module SDK documentation on docs.rs](https://docs.rs/spacetimedb/latest/spacetimedb/attr.reducer.html#scheduled-reducers) for more detailed syntax and alternative scheduling approaches (like using `schedule::periodic`). + +##### Scheduled Reducer Details + +* **Best-Effort Scheduling:** Scheduled reducers are called on a best-effort basis and may be slightly delayed in their execution when a database is under heavy load. + +* **Restricting Access (Security):** Scheduled reducers are normal reducers and *can* still be called directly by clients. If a scheduled reducer should *only* be called by the scheduler, it is crucial to begin the reducer with a check comparing the caller's identity (`ctx.sender`) to the module's own identity (`ctx.identity()`). + ```rust + use spacetimedb::{reducer, ReducerContext}; + // Assuming MyScheduleArgs table is defined + struct MyScheduleArgs {/*...*/} + + #[reducer] + fn my_scheduled_reducer(ctx: &ReducerContext, args: MyScheduleArgs) -> Result<(), String> { + if ctx.sender != ctx.identity() { + return Err("Reducer `my_scheduled_reducer` may not be invoked by clients, only via scheduling.".into()); + } + // ... Reducer body proceeds only if called by scheduler ... + Ok(()) + } + ``` + +:::info Scheduled Reducers and Connections +Scheduled reducer calls originate from the SpacetimeDB scheduler itself, not from an external client connection. Therefore, within a scheduled reducer, `ctx.sender` will be the module's own identity, and `ctx.connection_id` will be `None`. +::: + +#### Row-Level Security (Client Visibility Filters) + +(Unstable Feature) + +SpacetimeDB allows defining row-level security rules using the `#[spacetimedb::client_visibility_filter]` attribute. This attribute is applied to a `const` binding of type `Filter` and defines an SQL-like query that determines which rows of a table are visible to clients making subscription requests. + +* The query uses `:sender` to refer to the identity of the subscribing client. +* Multiple filters on the same table are combined with `OR` logic. +* Query errors (syntax, type errors, unknown tables) are reported during `spacetime publish`. + +```rust +use spacetimedb::{client_visibility_filter, Filter, table, Identity}; + +#[table(name = "location_state")] +struct LocationState { #[primary_key] entity_id: u64, chunk_index: u32 } +#[table(name = "user_state")] +struct UserState { #[primary_key] identity: Identity, entity_id: u64 } + +/// Players can only see entities located in the same chunk as their own entity. +#[client_visibility_filter] +const PLAYERS_SEE_ENTITIES_IN_SAME_CHUNK: Filter = Filter::Sql(" + SELECT * FROM LocationState WHERE chunk_index IN ( + SELECT chunk_index FROM LocationState WHERE entity_id IN ( + SELECT entity_id FROM UserState WHERE identity = :sender + ) + ) +"); +``` + +:::info Version-Specific Status and Usage + +* **SpacetimeDB 1.0:** The Row-Level Security feature was not fully implemented or enforced in version 1.0. Modules developed for SpacetimeDB 1.0 should **not** use this feature. +* **SpacetimeDB 1.1:** The feature is available but considered **unstable** in version 1.1. To use it, you must explicitly opt-in by enabling the `unstable` feature flag for the `spacetimedb` crate in your module's `Cargo.toml`: + ```toml + [dependencies] + spacetimedb = { version = "1.1", features = ["unstable"] } + # ... other dependencies + ``` + Modules developed for 1.1 can use row-level security only if this feature flag is enabled. +::: + +### Client SDK (Rust) + +This section details how to build native Rust client applications that interact with a SpacetimeDB module. + +#### 1. Project Setup + +Start by creating a standard Rust binary project and adding the `spacetimedb_sdk` crate as a dependency: + +```bash +cargo new my_rust_client +cd my_rust_client +cargo add spacetimedb_sdk # Ensure version matches your SpacetimeDB installation +``` + +#### 2. Generate Module Bindings + +Client code relies on generated bindings specific to your server module. Use the `spacetime generate` command, pointing it to your server module project: + +```bash +# From your client project directory +mkdir -p src/module_bindings +spacetime generate --lang rust \ + --out-dir src/module_bindings \ + --project-path ../path/to/your/server_module +``` + +Then, declare the generated module in your `main.rs` or `lib.rs`: + +```rust +mod module_bindings; +// Optional: bring generated types into scope +// use module_bindings::*; +``` + +#### 3. Connecting to the Database + +The core type for managing a connection is `module_bindings::DbConnection`. You configure and establish a connection using a builder pattern. + +* **Builder:** Start with `DbConnection::builder()`. +* **URI & Name:** Specify the SpacetimeDB instance URI (`.with_uri("http://localhost:3000")`) and the database name or identity (`.with_module_name("my_database")`). +* **Authentication:** Provide an identity token using `.with_token(Option)`. If `None` or omitted for the first connection, the server issues a new identity and token (retrieved via the `on_connect` callback). +* **Callbacks:** Register callbacks for connection lifecycle events: + * `.on_connect(|conn, identity, token| { ... })`: Runs on successful connection. Often used to store the `token` for future connections. + * `.on_connect_error(|err_ctx, error| { ... })`: Runs if connection fails. + * `.on_disconnect(|err_ctx, maybe_error| { ... })`: Runs when the connection closes, either gracefully or due to an error. +* **Build:** Call `.build()` to initiate the connection attempt. + +```rust +use spacetimedb_sdk::{identity, DbContext, Identity, credentials}; +use crate::module_bindings::{DbConnection, connect_event_callbacks, table_update_callbacks}; + +const HOST: &str = "http://localhost:3000"; +const DB_NAME: &str = "my_database"; // Or your specific DB name/identity + +fn connect_to_db() -> DbConnection { + // Helper for storing/loading auth token + fn creds_store() -> credentials::File { + credentials::File::new(".my_client_creds") // Unique filename + } + + DbConnection::builder() + .with_uri(HOST) + .with_module_name(DB_NAME) + .with_token(creds_store().load().ok()) // Load token if exists + .on_connect(|conn, identity, auth_token| { + println!("Connected. Identity: {}", identity.to_hex()); + // Save the token for future connections + if let Err(e) = creds_store().save(auth_token) { + eprintln!("Failed to save auth token: {}", e); + } + // Register other callbacks *after* successful connection + connect_event_callbacks(conn); + table_update_callbacks(conn); + // Initiate subscriptions + subscribe_to_tables(conn); + }) + .on_connect_error(|err_ctx, err| { + eprintln!("Connection Error: {}", err); + std::process::exit(1); + }) + .on_disconnect(|err_ctx, maybe_err| { + println!("Disconnected. Reason: {:?}", maybe_err); + std::process::exit(0); + }) + .build() + .expect("Failed to connect") +} +``` + +#### 4. Managing the Connection Loop + +After establishing the connection, you need to continuously process incoming messages and trigger callbacks. The SDK offers several ways: + +* **Threaded:** `connection.run_threaded()`: Spawns a dedicated background thread that automatically handles message processing. +* **Async:** `async connection.run_async()`: Integrates with async runtimes like Tokio or async-std. +* **Manual Tick:** `connection.frame_tick()`: Processes pending messages without blocking. Suitable for integrating into game loops or other manual polling scenarios. You must call this repeatedly. + +```rust +// Example using run_threaded +fn main() { + let connection = connect_to_db(); + let handle = connection.run_threaded(); // Spawns background thread + + // Main thread can now do other work, like handling user input + // handle_user_input(&connection); + + handle.join().expect("Connection thread panicked"); +} +``` + +#### 5. Subscribing to Data + +Clients receive data by subscribing to SQL queries against the database's public tables. + +* **Builder:** Start with `connection.subscription_builder()`. +* **Callbacks:** + * `.on_applied(|sub_ctx| { ... })`: Runs when the initial data for the subscription arrives. + * `.on_error(|err_ctx, error| { ... })`: Runs if the subscription fails (e.g., invalid SQL). +* **Subscribe:** Call `.subscribe(vec!["SELECT * FROM table_a", "SELECT * FROM table_b WHERE some_col > 10"])` with a list of query strings. This returns a `SubscriptionHandle`. +* **All Tables:** `.subscribe_to_all_tables()` is a convenience for simple clients but cannot be easily unsubscribed. +* **Unsubscribing:** Use `handle.unsubscribe()` or `handle.unsubscribe_then(|sub_ctx| { ... })` to stop receiving updates for specific queries. + +```rust +use crate::module_bindings::{SubscriptionEventContext, ErrorContext}; + +fn subscribe_to_tables(conn: &DbConnection) { + println!("Subscribing to tables..."); + conn.subscription_builder() + .on_applied(on_subscription_applied) + .on_error(|err_ctx, err| { + eprintln!("Subscription failed: {}", err); + }) + // Example: Subscribe to all rows from 'player' and 'message' tables + .subscribe(vec!["SELECT * FROM player", "SELECT * FROM message"]); +} + +fn on_subscription_applied(ctx: &SubscriptionEventContext) { + println!("Subscription applied! Initial data received."); + // Example: Print initial messages sorted by time + let mut messages: Vec<_> = ctx.db().message().iter().collect(); + messages.sort_by_key(|m| m.sent); + for msg in messages { + // print_message(ctx.db(), &msg); // Assuming a print_message helper + } +} +``` + +#### 6. Accessing Cached Data & Handling Row Callbacks + +Subscribed data is stored locally in the client cache, accessible via `ctx.db()` (where `ctx` can be a `DbConnection` or any event context). + +* **Accessing Tables:** Use `ctx.db().table_name()` to get a handle to a table. +* **Iterating:** `table_handle.iter()` returns an iterator over all cached rows. +* **Filtering/Finding:** Use index accessors like `table_handle.primary_key_field().find(&pk_value)` or `table_handle.indexed_field().filter(value_or_range)` for efficient lookups (similar to server-side). +* **Row Callbacks:** Register callbacks to react to changes in the cache: + * `table_handle.on_insert(|event_ctx, inserted_row| { ... })` + * `table_handle.on_delete(|event_ctx, deleted_row| { ... })` + * `table_handle.on_update(|event_ctx, old_row, new_row| { ... })` (Only for tables with a `#[primary_key]`) + +```rust +use crate::module_bindings::{Player, Message, EventContext, Event, DbView}; + +// Placeholder for where other callbacks are registered +fn table_update_callbacks(conn: &DbConnection) { + conn.db().player().on_insert(handle_player_insert); + conn.db().player().on_update(handle_player_update); + conn.db().message().on_insert(handle_message_insert); +} + +fn handle_player_insert(ctx: &EventContext, player: &Player) { + // Only react to updates caused by reducers, not initial subscription load + if let Event::Reducer(_) = ctx.event { + println!("Player joined: {}", player.name.as_deref().unwrap_or("Unknown")); + } +} + +fn handle_player_update(ctx: &EventContext, old: &Player, new: &Player) { + if old.name != new.name { + println!("Player renamed: {} -> {}", + old.name.as_deref().unwrap_or("??"), + new.name.as_deref().unwrap_or("??") + ); + } + // ... handle other changes like online status ... +} + +fn handle_message_insert(ctx: &EventContext, message: &Message) { + if let Event::Reducer(_) = ctx.event { + // Find sender name from cache + let sender_name = ctx.db().player().identity().find(&message.sender) + .map_or("Unknown".to_string(), |p| p.name.clone().unwrap_or("??".to_string())); + println!("{}: {}", sender_name, message.text); + } +} +``` + +:::info Handling Initial Data vs. Live Updates in Callbacks +Callbacks like `on_insert` and `on_update` are triggered for both the initial data received when a subscription is first applied *and* for subsequent live changes caused by reducers. If you need to differentiate (e.g., only react to *new* messages, not the backlog), you can inspect the `ctx.event` type. For example, `if let Event::Reducer(_) = ctx.event { ... }` checks if the change came from a reducer call. +::: + +#### 7. Invoking Reducers & Handling Reducer Callbacks + +Clients trigger state changes by calling reducers defined in the server module. + +* **Invoking:** Access generated reducer functions via `ctx.reducers().reducer_name(arg1, arg2, ...)`. +* **Reducer Callbacks:** Register callbacks to react to the *outcome* of reducer calls (especially useful for handling failures or confirming success if not directly observing table changes): + * `ctx.reducers().on_reducer_name(|reducer_event_ctx, arg1, ...| { ... })` + * The `reducer_event_ctx.event` contains: + * `reducer`: The specific reducer variant and its arguments. + * `status`: `Status::Committed`, `Status::Failed(reason)`, or `Status::OutOfEnergy`. + * `caller_identity`, `timestamp`, etc. + +```rust +use crate::module_bindings::{ReducerEventContext, Status}; + +// Placeholder for where other callbacks are registered +fn connect_event_callbacks(conn: &DbConnection) { + conn.reducers().on_set_name(handle_set_name_result); + conn.reducers().on_send_message(handle_send_message_result); +} + +fn handle_set_name_result(ctx: &ReducerContext, name: &String) { + if let Status::Failed(reason) = &ctx.event.status { + // Check if the failure was for *our* call (important in multi-user contexts) + if ctx.event.caller_identity == ctx.identity() { + eprintln!("Error setting name to '{}': {}", name, reason); + } + } +} + +fn handle_send_message_result(ctx: &ReducerContext, text: &String) { + if let Status::Failed(reason) = &ctx.event.status { + if ctx.event.caller_identity == ctx.identity() { // Our call failed + eprintln!("[Error] Failed to send message '{}': {}", text, reason); + } + } +} + +// Example of calling a reducer (e.g., from user input handler) +fn send_chat_message(conn: &DbConnection, message: String) { + if !message.is_empty() { + conn.reducers().send_message(message); // Fire-and-forget style + } +} +``` + +// ... (Keep the second info box about C# callbacks, it will be moved later) ... +:::info Handling Initial Data vs. Live Updates in Callbacks +Callbacks like `OnInsert` and `OnUpdate` are triggered for both the initial data received when a subscription is first applied *and* for subsequent live changes caused by reducers. If you need to differentiate (e.g., only react to *new* messages, not the backlog), you can inspect the `ctx.Event` type. For example, checking `if (ctx.Event is not Event.SubscribeApplied) { ... }` ensures the code only runs for events triggered by reducers, not the initial subscription data load. +::: + +### Server Module (C#) + +#### Defining Types + +Custom classes, structs, or records intended for use as fields within database tables or as parameters/return types in reducers must be marked with the `[Type]` attribute. This attribute enables SpacetimeDB to handle the serialization and deserialization of these types. + +* **Basic Usage:** Apply `[Type]` to your classes, structs, or records. Use the `partial` modifier to allow SpacetimeDB's source generators to augment the type definition. +* **Cross-Language Naming:** Currently, the C# module SDK does **not** provide a direct equivalent to Rust's `#[sats(name = "...")]` attribute for controlling the generated names in *other* client languages (like TypeScript). The C# type name itself (including its namespace) is typically used. Standard C# namespacing (`namespace MyGame.SharedTypes { ... }`) is the primary way to organize and avoid collisions. +* **Enums:** Standard C# enums can be marked with `[Type]`. For "tagged unions" or "discriminated unions" (like Rust enums with associated data), use the pattern of an abstract base record/class with the `[Type]` attribute, and derived records/classes for each variant, also marked with `[Type]`. Then, define a final `[Type]` record that inherits from `TaggedEnum<(...)>` listing the variants. +* **Type Aliases:** Use standard C# `using` aliases for clarity (e.g., `using PlayerScore = System.UInt32;`). The underlying primitive type must still be serializable by SpacetimeDB. + +```csharp +using SpacetimeDB; +using System; // Required for System.UInt32 if using aliases like below + +// Example Struct +[Type] +public partial struct Position { public int X; public int Y; } + +// Example Tagged Union (Enum with Data) Pattern: +// 1. Base abstract record +[Type] public abstract partial record PlayerStatusBase { } +// 2. Derived records for variants +[Type] public partial record IdleStatus : PlayerStatusBase { } +[Type] public partial record WalkingStatus : PlayerStatusBase { public Position Target; } +[Type] public partial record FightingStatus : PlayerStatusBase { public Identity OpponentId; } +// 3. Final type inheriting from TaggedEnum +[Type] +public partial record PlayerStatus : TaggedEnum<( + IdleStatus Idle, + WalkingStatus Walking, + FightingStatus Fighting +)> { } + +// Example Standard Enum +[Type] +public enum ItemType { Weapon, Armor, Potion } + +// Example Type Alias +using PlayerScore = System.UInt32; + +``` + +:::info C# `partial` Keyword +Table and Type definitions in C# should use the `partial` keyword (e.g., `public partial class MyTable`). This allows the SpacetimeDB source generator to add necessary internal methods and serialization logic to your types without requiring you to write boilerplate code. +::: + +#### Defining Tables + +Database tables store the application's persistent state. They are defined using C# classes or structs marked with the `[Table]` attribute. + +* **Core Attribute:** `[Table(Name = "my_table_name", ...)]` marks a class or struct as a database table definition. The specified string `Name` is how the table will be referenced in SQL queries and generated APIs. +* **Partial Modifier:** Use the `partial` keyword (e.g., `public partial class MyTable`) to allow SpacetimeDB's source generators to add necessary methods and logic to your definition. +* **Public vs. Private:** By default, tables are **private**, accessible only by server-side reducer code. To allow clients to read or subscribe to a table's data, set `Public = true` within the attribute: `[Table(..., Public = true)]`. This is a common source of errors if forgotten. +* **Primary Keys:** Designate a single **public field** as the primary key using `[PrimaryKey]`. This ensures uniqueness, creates an efficient index, and allows clients to track row updates. +* **Auto-Increment:** Mark an integer-typed primary key **public field** with `[AutoInc]` to have SpacetimeDB automatically assign unique, sequentially increasing values upon insertion. Provide `0` as the value for this field when inserting a new row to trigger the auto-increment mechanism. +* **Unique Constraints:** Enforce uniqueness on non-primary key **public fields** using `[Unique]`. Attempts to insert or update rows violating this constraint will fail (throw an exception). +* **Indexes:** Create B-tree indexes for faster lookups on specific **public fields** or combinations of fields. Use `[Index.BTree]` on a single field for a simple index, or define indexes at the class/struct level using `[Index.BTree(Name = "MyIndexName", Columns = new[] { nameof(ColA), nameof(ColB) })]`. +* **Nullable Fields:** Use standard C# nullable reference types (`string?`) or nullable value types (`int?`, `Timestamp?`) for fields that can hold null values. +* **Instances vs. Database:** Remember that table class/struct instances (e.g., `var player = new PlayerState { ... };`) are just data objects. Modifying an instance does **not** automatically update the database. Interaction happens through generated handles accessed via the `ReducerContext` (e.g., `ctx.Db.player_state.Insert(...)`). +* **Case Sensitivity:** Table names specified via `Name = "..."` are case-sensitive and must be matched exactly in SQL queries. +* **Pitfalls:** + * SpacetimeDB attributes (`[PrimaryKey]`, `[AutoInc]`, `[Unique]`, `[Index.BTree]`) **must** be applied to **public fields**, not properties (`{ get; set; }`). Using properties can cause build errors or runtime issues. + * Avoid manually inserting values into `[AutoInc]` fields that are also `[Unique]`, especially values larger than the current sequence counter, as this can lead to future unique constraint violations when the counter catches up. + * Ensure `Public = true` is set if clients need access. + * Always use the `partial` keyword on table definitions. + * Define indexes *within* the main `#[table(name=..., index=...)]` attribute. Each `#[table]` macro invocation defines a *distinct* table and requires a `name`; separate `#[table]` attributes cannot be used solely to add indexes to a previously named table. + +```csharp +using SpacetimeDB; +using System; // For Nullable types if needed + +// Assume Position, PlayerStatus, ItemType are defined as types + +// Example Table Definition +[Table(Name = "player_state", Public = true)] +[Index.BTree(Name = "idx_level", Columns = new[] { nameof(Level) })] // Table-level index +public partial class PlayerState +{ + [PrimaryKey] + public Identity PlayerId; // Public field + [Unique] + public string Name = ""; // Public field (initialize to avoid null warnings if needed) + public uint Health; // Public field + public ushort Level; // Public field + public Position Position; // Public field (custom struct type) + public PlayerStatus Status; // Public field (custom record type) + public Timestamp? LastLogin; // Public field, nullable struct +} + +[Table(Name = "inventory_item", Public = true)] +public partial class InventoryItem +{ + [PrimaryKey] + [AutoInc] // Automatically generate IDs + public ulong ItemId; // Public field + public Identity OwnerId; // Public field + [Index.BTree] // Simple index on this field + public ItemType ItemType; // Public field + public uint Quantity; // Public field +} + +// Example of a private table +[Table(Name = "internal_game_data")] // Public = false is default +public partial class InternalGameData +{ + [PrimaryKey] + public string Key = ""; // Public field + public string Value = ""; // Public field +} +``` + +##### Multiple Tables from One Class + +You can use the same underlying data class for multiple tables, often using inheritance. Ensure SpacetimeDB attributes like `[PrimaryKey]` are applied to **public fields**, not properties. + +```csharp +using SpacetimeDB; + +// Define the core data structure (must be [Type] if used elsewhere) +[Type] +public partial class CharacterInfo +{ + [PrimaryKey] + public ulong CharacterId; // Use public field + public string Name = ""; // Use public field + public ushort Level; // Use public field +} + +// Define derived classes, each with its own table attribute +[Table(Name = "active_characters")] +public partial class ActiveCharacter : CharacterInfo { + // Can add specific public fields if needed + public bool IsOnline; +} + +[Table(Name = "deleted_characters")] +public partial class DeletedCharacter : CharacterInfo { + // Can add specific public fields if needed + public Timestamp DeletionTime; +} + +// Reducers would interact with ActiveCharacter or DeletedCharacter tables +// E.g., ctx.Db.active_characters.Insert(new ActiveCharacter { CharacterId = 1, Name = "Hero", Level = 10, IsOnline = true }); +``` + +Alternatively, you can define multiple `[Table]` attributes directly on a single class or struct. This maps the same underlying type to multiple distinct tables: + +```csharp +using SpacetimeDB; + +// Define the core data structure once +// Apply multiple [Table] attributes to map it to different tables +[Type] // Mark as a type if used elsewhere (e.g., reducer args) +[Table(Name = "logged_in_players", Public = true)] +[Table(Name = "players_in_lobby", Public = true)] +public partial class PlayerSessionData +{ + [PrimaryKey] + public Identity PlayerId; // Use public field + [Unique] + [AutoInc] + public ulong SessionId; // Use public field + public Timestamp LastActivity; +} + +// Reducers would interact with the specific table handles: +// E.g., ctx.Db.logged_in_players.Insert(new PlayerSessionData { ... }); +// E.g., var lobbyPlayer = ctx.Db.players_in_lobby.PlayerId.Find(someId); +``` + +#### Defining Reducers + +Reducers are the functions within your server module responsible for atomically modifying the database state in response to client requests or internal events (like lifecycle triggers or schedules). + +* **Core Attribute:** Reducers are defined as `static` methods within a (typically `static partial`) class, annotated with `[SpacetimeDB.Reducer]`. +* **Signature:** Every reducer method must accept `ReducerContext` as its first argument. Subsequent arguments represent data passed from the client caller or scheduler, and their types must be marked with `[Type]`. +* **Return Type:** Reducers should typically return `void`. Errors are signaled by throwing exceptions. +* **Reducer Context:** The `ReducerContext` (`ctx`) provides access to: + * `ctx.Db`: Handles for interacting with database tables. + * `ctx.Sender`: The `Identity` of the caller. + * `ctx.Identity`: The `Identity` of the module itself. + * `ctx.Timestamp`: The `Timestamp` of the invocation. + * `ctx.ConnectionId`: The nullable `ConnectionId` of the caller. + * `ctx.Rng`: A `System.Random` instance for deterministic random number generation (if needed). +* **Transactionality:** Each reducer call executes within a single, atomic database transaction. If the method completes without an unhandled exception, all database changes are committed. If an exception is thrown, the transaction is aborted, and **all changes are rolled back**, preserving data integrity. +* **Execution Environment:** Reducers run in a sandbox and **cannot** directly perform network I/O (`System.Net`) or filesystem operations (`System.IO`). External interaction primarily occurs through database table modifications (observed by clients) and logging (`SpacetimeDB.Log`). +* **Calling Other Reducers:** A reducer can directly call another static reducer method defined in the same module. This is a standard method call and executes within the *same* transaction; it does not create a sub-transaction. + +```csharp +using SpacetimeDB; +using System; +using System.Linq; // Used in more complex examples later + +public static partial class Module +{ + // Assume PlayerState and InventoryItem tables are defined as previously + [Table(Name = "player_state", Public = true)] public partial class PlayerState { + [PrimaryKey] public Identity PlayerId; + [Unique] public string Name = ""; + public uint Health; public ushort Level; /* ... other fields */ } + [Table(Name = "inventory_item", Public = true)] public partial class InventoryItem { + [PrimaryKey] #[AutoInc] public ulong ItemId; + public Identity OwnerId; /* ... other fields */ } + + // Example: Basic reducer to update player data + [Reducer] + public static void UpdatePlayerData(ReducerContext ctx, string? newName) + { + var playerId = ctx.Sender; + + // Find player by primary key + var player = ctx.Db.player_state.PlayerId.Find(playerId); + if (player == null) + { + throw new Exception($"Player not found: {playerId}"); + } + + // Update fields conditionally + bool requiresUpdate = false; + if (!string.IsNullOrWhiteSpace(newName)) + { + // Basic check for name uniqueness (simplified) + var existing = ctx.Db.player_state.Name.Find(newName); + if(existing != null && !existing.PlayerId.Equals(playerId)) { + throw new Exception($"Name '{newName}' already taken."); + } + if (player.Name != newName) { + player.Name = newName; + requiresUpdate = true; + } + } + + if (player.Level < 100) { // Example simple update + player.Level += 1; + requiresUpdate = true; + } + + // Persist changes if any were made + if (requiresUpdate) { + ctx.Db.player_state.PlayerId.Update(player); + Log.Info($"Updated player data for {playerId}"); + } + } + + // Example: Basic reducer to register a player + [Reducer] + public static void RegisterPlayer(ReducerContext ctx, string name) + { + if (string.IsNullOrWhiteSpace(name)) { + throw new ArgumentException("Name cannot be empty."); + } + Log.Info($"Attempting to register player: {name} ({ctx.Sender})"); + + // Check if player identity or name already exists + if (ctx.Db.player_state.PlayerId.Find(ctx.Sender) != null || ctx.Db.player_state.Name.Find(name) != null) + { + throw new Exception("Player already registered or name taken."); + } + + // Create new player instance + var newPlayer = new PlayerState + { + PlayerId = ctx.Sender, + Name = name, + Health = 100, + Level = 1, + // Initialize other fields as needed... + }; + + // Insert the new player. This will throw on constraint violation. + ctx.Db.player_state.Insert(newPlayer); + Log.Info($"Player registered successfully: {ctx.Sender}"); + } + + // Example: Basic reducer showing deletion + [Reducer] + public static void DeleteMyItems(ReducerContext ctx) + { + var ownerId = ctx.Sender; + int deletedCount = 0; + + // Find items by owner (Requires an index on OwnerId for efficiency) + // This example iterates if no index exists. + var itemsToDelete = ctx.Db.inventory_item.Iter() + .Where(item => item.OwnerId.Equals(ownerId)) + .ToList(); // Collect IDs to avoid modification during iteration + + foreach(var item in itemsToDelete) + { + // Delete using the primary key index + if (ctx.Db.inventory_item.ItemId.Delete(item.ItemId)) { + deletedCount++; + } + } + Log.Info($"Deleted {deletedCount} items for player {ownerId}."); + } +} +``` + +##### Handling Insert Constraint Violations + +Unlike Rust's `try_insert` which returns a `Result`, the C# `Insert` method throws an exception if a constraint (like a primary key or unique index violation) occurs. There are two main ways to handle this in C# reducers: + +1. **Pre-checking:** Before calling `Insert`, explicitly query the database using the relevant indexes to check if the insertion would violate any constraints (e.g., check if a user with the same ID or unique name already exists). This is often cleaner if the checks are straightforward. The `RegisterPlayer` example above demonstrates this pattern. + +2. **Using `try-catch`:** Wrap the `Insert` call in a `try-catch` block. This allows you to catch the specific exception (often a `SpacetimeDB.ConstraintViolationException` or potentially a more general `Exception` depending on the SDK version and error type) and handle the failure gracefully (e.g., log an error, return a specific error message to the client via a different mechanism if applicable, or simply allow the transaction to roll back cleanly without crashing the reducer unexpectedly). + +```csharp +using SpacetimeDB; +using System; + +public static partial class Module +{ + [Table(Name = "unique_items")] + public partial class UniqueItem { + [PrimaryKey] public string ItemName; + public int Value; + } + + // Example using try-catch for insertion + [Reducer] + public static void AddUniqueItemWithCatch(ReducerContext ctx, string name, int value) + { + var newItem = new UniqueItem { ItemName = name, Value = value }; + try + { + // Attempt to insert + ctx.Db.unique_items.Insert(newItem); + Log.Info($"Successfully inserted item: {name}"); + } + catch (Exception ex) // Catch a general exception or a more specific one if available + { + // Log the specific error + Log.Error($"Failed to insert item '{name}': Constraint violation or other error. Details: {ex.Message}"); + // Optionally, re-throw a custom exception or handle differently + // Throwing ensures the transaction is rolled back + throw new Exception($"Item name '{name}' might already exist."); + } + } +} +``` +Choosing between pre-checking and `try-catch` depends on the complexity of the constraints and the desired flow. Pre-checking can avoid the overhead of exception handling for predictable violations, while `try-catch` provides a direct way to handle unexpected insertion failures. + +:::note C# `Insert` vs Rust `try_insert` +Unlike Rust, the C# SDK does not currently provide a `TryInsert` method that returns a result. The standard `Insert` method will throw an exception if a constraint (primary key, unique index) is violated. Therefore, C# reducers should typically check for potential constraint violations *before* calling `Insert`, or be prepared to handle the exception (which will likely roll back the transaction). +::: + +##### Lifecycle Reducers + +Special reducers handle specific events: +* `[Reducer(ReducerKind.Init)]`: Runs once when the module is first published **and** any time the database is manually cleared (e.g., via `spacetime publish -c` or `spacetime server clear`). Failure prevents publishing or clearing. Often used for initial data setup. +* `[Reducer(ReducerKind.ClientConnected)]`: Runs when any distinct client connection (e.g., WebSocket, HTTP call) is established. Failure disconnects the client. `ctx.connection_id` is guaranteed to have a value within this reducer. +* `[Reducer(ReducerKind.ClientDisconnected)]`: Runs when any distinct client connection terminates. Failure is logged but does not prevent disconnection. `ctx.connection_id` is guaranteed to have a value within this reducer. + +These reducers cannot take arguments beyond `&ReducerContext`. + +```csharp +// Example init reducer is shown in Scheduled Reducers section +[Reducer(ReducerKind.ClientConnected)] +public static void HandleConnect(ReducerContext ctx) { + Log.Info($"Client connected: {ctx.Sender}"); + // ... setup initial state for ctx.sender ... +} + +[Reducer(ReducerKind.ClientDisconnected)] +public static void HandleDisconnect(ReducerContext ctx) { + Log.Info($"Client disconnected: {ctx.Sender}"); + // ... cleanup state for ctx.sender ... +} +``` + +#### Scheduled Reducers (C#) + +In addition to lifecycle annotations, reducers can be scheduled. This allows calling the reducers at a particular time, or periodically for loops (e.g., game loops). + +The scheduling information for a reducer is stored in a table. This table links to the reducer function and has specific mandatory fields: + +1. **Define the Schedule Table:** Create a table class/struct using `[Table(Name = ..., Scheduled = nameof(YourReducerName), ScheduledAt = nameof(YourScheduleAtColumnName))]`. + * The `Scheduled` parameter links this table to the static reducer method `YourReducerName`. + * The `ScheduledAt` parameter specifies the name of the field within this table that holds the scheduling information. This field **must** be of type `SpacetimeDB.ScheduleAt`. + * The table **must** also have a primary key field (often `[AutoInc] ulong Id`). + * Additional fields can be included to pass arguments to the scheduled reducer. +2. **Define the Scheduled Reducer:** Create the `static` reducer method (`YourReducerName`) specified in the table attribute. It takes `ReducerContext` and an instance of the schedule table class/struct as arguments. +3. **Schedule an Invocation:** Inside another reducer, create an instance of your schedule table struct. + * Set the `ScheduleAt` field (using the name specified in the `ScheduledAt` parameter) to either: + * `new ScheduleAt.Time(timestamp)`: Schedules the reducer to run **once** at the specified `Timestamp`. + * `new ScheduleAt.Interval(timeDuration)`: Schedules the reducer to run **periodically** with the specified `TimeDuration` interval. + * Set the primary key (e.g., to `0` if using `[AutoInc]`) and any other argument fields. + * Insert this instance into the schedule table using `ctx.Db.your_schedule_table_name.Insert(...)`. + +Managing timers with a scheduled table is as simple as inserting or deleting rows. This makes scheduling transactional in SpacetimeDB. If a reducer A schedules B but then throws an exception, B will not be scheduled. + +```csharp +using SpacetimeDB; +using System; + +public static partial class Module +{ + // 1. Define the table with scheduling information, linking to `SendMessage` reducer. + // Specifies that the `ScheduledAt` field holds the schedule info. + [Table(Name = "send_message_schedule", Scheduled = nameof(SendMessage), ScheduledAt = nameof(ScheduledAt))] + public partial struct SendMessageSchedule + { + // Mandatory fields: + [PrimaryKey] + [AutoInc] + public ulong Id; // Identifier for the scheduled call + + public ScheduleAt ScheduledAt; // Holds the schedule timing + + // Custom fields (arguments for the reducer): + public string Message; + } + + // 2. Define the scheduled reducer. + // It takes the schedule table struct as its second argument. + [Reducer] + public static void SendMessage(ReducerContext ctx, SendMessageSchedule scheduleArgs) + { + // Security check! + if (!ctx.Sender.Equals(ctx.Identity)) + { + throw new Exception("Reducer SendMessage may not be invoked by clients, only via scheduling."); + } + + Log.Info($"Scheduled SendMessage: {scheduleArgs.Message}"); + // ... perform action with scheduleArgs.Message ... + } + + // 3. Example of scheduling reducers (e.g., in Init) + [Reducer(ReducerKind.Init)] + public static void Init(ReducerContext ctx) + { + // Avoid rescheduling if Init runs again + if (ctx.Db.send_message_schedule.Count > 0) { + return; + } + + var tenSeconds = new TimeDuration { Microseconds = 10_000_000 }; + var futureTimestamp = ctx.Timestamp + tenSeconds; + + // Schedule a one-off message + ctx.Db.send_message_schedule.Insert(new SendMessageSchedule + { + Id = 0, // Let AutoInc assign ID + // Use ScheduleAt.Time for one-off execution at a specific Timestamp + ScheduledAt = new ScheduleAt.Time(futureTimestamp), + Message = "I'm a bot sending a message one time!" + }); + Log.Info("Scheduled one-off message."); + + // Schedule a periodic message (every 10 seconds) + ctx.Db.send_message_schedule.Insert(new SendMessageSchedule + { + Id = 0, // Let AutoInc assign ID + // Use ScheduleAt.Interval for periodic execution with a TimeDuration + ScheduledAt = new ScheduleAt.Interval(tenSeconds), + Message = "I'm a bot sending a message every 10 seconds!" + }); + Log.Info("Scheduled periodic message."); + } +} +``` + +##### Scheduled Reducer Details + +* **Best-Effort Scheduling:** Scheduled reducers are called on a best-effort basis and may be slightly delayed in their execution when a database is under heavy load. + +* **Restricting Access (Security):** Scheduled reducers are normal reducers and *can* still be called directly by clients. If a scheduled reducer should *only* be called by the scheduler, it is crucial to begin the reducer with a check comparing the caller's identity (`ctx.Sender`) to the module's own identity (`ctx.Identity`). + ```csharp + [Reducer] // Assuming linked via [Table(Scheduled=...)] + public static void MyScheduledTask(ReducerContext ctx, MyScheduleArgs args) + { + if (!ctx.Sender.Equals(ctx.Identity)) + { + throw new Exception("Reducer MyScheduledTask may not be invoked by clients, only via scheduling."); + } + // ... Reducer body proceeds only if called by scheduler ... + Log.Info("Executing scheduled task..."); + } + // Define MyScheduleArgs table elsewhere with [Table(Scheduled=nameof(MyScheduledTask), ...)] + public partial struct MyScheduleArgs { /* ... fields including ScheduleAt ... */ } + ``` + +:::info Scheduled Reducers and Connections +Scheduled reducer calls originate from the SpacetimeDB scheduler itself, not from an external client connection. Therefore, within a scheduled reducer, `ctx.Sender` will be the module's own identity, and `ctx.ConnectionId` will be `null`. +::: + +##### Error Handling: Exceptions + +Throwing an unhandled exception within a C# reducer will cause the transaction to roll back. +* **Expected Failures:** For predictable errors (e.g., invalid arguments, state violations), explicitly `throw` an `Exception`. The exception message can be observed by the client in the `ReducerEventContext` status. +* **Unexpected Errors:** Unhandled runtime exceptions (e.g., `NullReferenceException`) also cause rollbacks but might provide less informative feedback to the client, potentially just indicating a general failure. + +It's generally good practice to validate input and state early in the reducer and `throw` specific exceptions for handled error conditions. + +### Client SDK (C#) + +This section details how to build native C# client applications (including Unity games) that interact with a SpacetimeDB module. + +#### 1. Project Setup + +* **For .NET Console/Desktop Apps:** Create a new project and add the `SpacetimeDB.ClientSDK` NuGet package: + ```bash + dotnet new console -o my_csharp_client + cd my_csharp_client + dotnet add package SpacetimeDB.ClientSDK + ``` +* **For Unity:** Download the latest `.unitypackage` from the [SpacetimeDB Unity SDK releases](https://github.com/clockworklabs/com.clockworklabs.spacetimedbsdk/releases/latest). In Unity, go to `Assets > Import Package > Custom Package` and import the downloaded file. + +#### 2. Generate Module Bindings + +Client code relies on generated bindings specific to your server module. Use the `spacetime generate` command, pointing it to your server module project: + +```bash +# From your client project directory +mkdir -p module_bindings # Or your preferred output location +spacetime generate --lang csharp \ + --out-dir module_bindings \ + --project-path ../path/to/your/server_module +``` + +Include the generated `.cs` files in your C# project or Unity Assets folder. + +#### 3. Connecting to the Database + +The core type for managing a connection is `SpacetimeDB.Types.DbConnection` (this type name comes from the generated bindings). You configure and establish a connection using a builder pattern. + +* **Builder:** Start with `DbConnection.Builder()`. +* **URI & Name:** Specify the SpacetimeDB instance URI (`.WithUri("http://localhost:3000")`) and the database name or identity (`.WithModuleName("my_database")`). +* **Authentication:** Provide an identity token using `.WithToken(string?)`. The SDK provides a helper `AuthToken.Token` which loads a token from a local file (initialized via `AuthToken.Init(".credentials_filename")`). If `null` or omitted for the first connection, the server issues a new identity and token (retrieved via the `OnConnect` callback). +* **Callbacks:** Register callbacks (as delegates or lambda expressions) for connection lifecycle events: + * `.OnConnect((conn, identity, token) => { ... })`: Runs on successful connection. Often used to save the `token` using `AuthToken.SaveToken(token)`. + * `.OnConnectError((exception) => { ... })`: Runs if connection fails. + * `.OnDisconnect((conn, maybeException) => { ... })`: Runs when the connection closes, either gracefully (`maybeException` is null) or due to an error. +* **Build:** Call `.Build()` to initiate the connection attempt. + +```csharp +using SpacetimeDB; +using SpacetimeDB.Types; +using System; + +public class ClientManager // Example class +{ + const string HOST = "http://localhost:3000"; + const string DB_NAME = "my_database"; // Or your specific DB name/identity + private DbConnection connection; + + public void StartConnecting() + { + // Initialize token storage (e.g., in AppData) + AuthToken.Init(".my_client_creds"); + + connection = DbConnection.Builder() + .WithUri(HOST) + .WithModuleName(DB_NAME) + .WithToken(AuthToken.Token) // Load token if exists + .OnConnect(HandleConnect) + .OnConnectError(HandleConnectError) + .OnDisconnect(HandleDisconnect) + .Build(); + + // Need to call FrameTick regularly - see next section + } + + private void HandleConnect(DbConnection conn, Identity identity, string authToken) + { + Console.WriteLine($"Connected. Identity: {identity}"); + AuthToken.SaveToken(authToken); // Save token for future connections + + // Register other callbacks after connecting + RegisterEventCallbacks(conn); + + // Subscribe to data + SubscribeToTables(conn); + } + + private void HandleConnectError(Exception e) + { + Console.WriteLine($"Connection Error: {e.Message}"); + // Handle error, e.g., retry or exit + } + + private void HandleDisconnect(DbConnection conn, Exception? e) + { + Console.WriteLine($"Disconnected. Reason: {(e == null ? "Requested" : e.Message)}"); + // Handle disconnection + } + + // Placeholder methods - implementations shown in later sections + private void RegisterEventCallbacks(DbConnection conn) { /* ... */ } + private void SubscribeToTables(DbConnection conn) { /* ... */ } +} +``` + +#### 4. Managing the Connection Loop + +Unlike the Rust SDK's `run_threaded` or `run_async`, the C# SDK primarily uses a manual update loop. You **must** call `connection.FrameTick()` regularly (e.g., every frame in Unity's `Update`, or in a loop in a console app) to process incoming messages and trigger callbacks. + +* **`FrameTick()`:** Processes all pending network messages, updates the local cache, and invokes registered callbacks. +* **Threading:** It is generally **not recommended** to call `FrameTick()` on a background thread if your main thread also accesses the connection's data (`connection.Db`), as this can lead to race conditions. Handle computationally intensive logic triggered by callbacks separately if needed. + +```csharp +// Example in a simple console app loop: +public void RunUpdateLoop() +{ + Console.WriteLine("Running update loop..."); + bool isRunning = true; + while(isRunning && connection != null && connection.IsConnected) + { + connection.FrameTick(); // Process messages + + // Check for user input or other app logic... + if (Console.KeyAvailable) { + var key = Console.ReadKey(true).Key; + if (key == ConsoleKey.Escape) isRunning = false; + // Handle other input... + } + + System.Threading.Thread.Sleep(16); // Avoid busy-waiting + } + connection?.Disconnect(); + Console.WriteLine("Update loop stopped."); +} +``` + +#### 5. Subscribing to Data + +Clients receive data by subscribing to SQL queries against the database's public tables. + +* **Builder:** Start with `connection.SubscriptionBuilder()`. +* **Callbacks:** + * `.OnApplied((subCtx) => { ... })`: Runs when the initial data for the subscription arrives. + * `.OnError((errCtx, exception) => { ... })`: Runs if the subscription fails (e.g., invalid SQL). +* **Subscribe:** Call `.Subscribe(new string[] {"SELECT * FROM table_a", "SELECT * FROM table_b WHERE some_col > 10"})` with a list of query strings. This returns a `SubscriptionHandle`. +* **All Tables:** `.SubscribeToAllTables()` is a convenience for simple clients but cannot be easily unsubscribed. +* **Unsubscribing:** Use `handle.Unsubscribe()` or `handle.UnsubscribeThen((subCtx) => { ... })` to stop receiving updates for specific queries. + +```csharp +using SpacetimeDB.Types; // For SubscriptionEventContext, ErrorContext +using System.Linq; + +// In ClientManager or similar class... +private void SubscribeToTables(DbConnection conn) +{ + Console.WriteLine("Subscribing to tables..."); + conn.SubscriptionBuilder() + .OnApplied(OnSubscriptionApplied) + .OnError((errCtx, err) => { + Console.WriteLine($"Subscription failed: {err.Message}"); + }) + // Example: Subscribe to all rows from 'Player' and 'Message' tables + .Subscribe(new string[] { "SELECT * FROM Player", "SELECT * FROM Message" }); +} + +private void OnSubscriptionApplied(SubscriptionEventContext ctx) +{ + Console.WriteLine("Subscription applied! Initial data received."); + // Example: Print initial messages sorted by time + var messages = ctx.Db.Message.Iter().ToList(); + messages.Sort((a, b) => a.Sent.CompareTo(b.Sent)); + foreach (var msg in messages) + { + // PrintMessage(ctx.Db, msg); // Assuming a PrintMessage helper + } +} +``` + +#### 6. Accessing Cached Data & Handling Row Callbacks + +Subscribed data is stored locally in the client cache, accessible via `ctx.Db` (where `ctx` can be a `DbConnection` or any event context like `EventContext`, `SubscriptionEventContext`). + +* **Accessing Tables:** Use `ctx.Db.TableName` (e.g., `ctx.Db.Player`) to get a handle to a table's cache. +* **Iterating:** `tableHandle.Iter()` returns an `IEnumerable` over all cached rows. +* **Filtering/Finding:** Use LINQ methods (`.Where()`, `.FirstOrDefault()`, etc.) on the result of `Iter()`, or use generated index accessors like `tableHandle.FindByPrimaryKeyField(pkValue)` or `tableHandle.FilterByIndexField(value)` for efficient lookups. +* **Row Callbacks:** Register callbacks using C# events to react to changes in the cache: + * `tableHandle.OnInsert += (eventCtx, insertedRow) => { ... };` + * `tableHandle.OnDelete += (eventCtx, deletedRow) => { ... };` + * `tableHandle.OnUpdate += (eventCtx, oldRow, newRow) => { ... };` (Only for tables with a `[PrimaryKey]`) + +```csharp +using SpacetimeDB.Types; // For EventContext, Event, Reducer +using System.Linq; + +// In ClientManager or similar class... +private void RegisterEventCallbacks(DbConnection conn) +{ + conn.Db.Player.OnInsert += HandlePlayerInsert; + conn.Db.Player.OnUpdate += HandlePlayerUpdate; + conn.Db.Message.OnInsert += HandleMessageInsert; + // Remember to unregister callbacks on disconnect/cleanup: -= HandlePlayerInsert; +} + +private void HandlePlayerInsert(EventContext ctx, Player insertedPlayer) +{ + // Only react to updates caused by reducers, not initial subscription load + if (ctx.Event is not Event.SubscribeApplied) + { + Console.WriteLine($"Player joined: {insertedPlayer.Name ?? "Unknown"}"); + } +} + +private void HandlePlayerUpdate(EventContext ctx, Player oldPlayer, Player newPlayer) +{ + if (oldPlayer.Name != newPlayer.Name) + { + Console.WriteLine($"Player renamed: {oldPlayer.Name ?? "??"} -> {newPlayer.Name ?? "??"}"); + } + // ... handle other changes like online status ... +} + +private void HandleMessageInsert(EventContext ctx, Message insertedMessage) +{ + if (ctx.Event is not Event.SubscribeApplied) + { + // Find sender name from cache + var sender = ctx.Db.Player.FindByPlayerId(insertedMessage.Sender); + string senderName = sender?.Name ?? "Unknown"; + Console.WriteLine($"{senderName}: {insertedMessage.Text}"); + } +} +``` + +:::info Handling Initial Data vs. Live Updates in Callbacks +Callbacks like `OnInsert` and `OnUpdate` are triggered for both the initial data received when a subscription is first applied *and* for subsequent live changes caused by reducers. If you need to differentiate (e.g., only react to *new* messages, not the backlog), you can inspect the `ctx.Event` type. For example, checking `if (ctx.Event is not Event.SubscribeApplied) { ... }` ensures the code only runs for events triggered by reducers, not the initial subscription data load. +::: + +#### 7. Invoking Reducers & Handling Reducer Callbacks + +Clients trigger state changes by calling reducers defined in the server module. + +* **Invoking:** Access generated static reducer methods via `SpacetimeDB.Types.Reducer.ReducerName(arg1, arg2, ...)`. +* **Reducer Callbacks:** Register callbacks using C# events to react to the *outcome* of reducer calls: + * `Reducer.OnReducerName += (reducerEventCtx, arg1, ...) => { ... };` + * The `reducerEventCtx.Event` contains: + * `Reducer`: The specific reducer variant record and its arguments. + * `Status`: A tagged union record: `Status.Committed`, `Status.Failed(reason)`, or `Status.OutOfEnergy`. + * `CallerIdentity`, `Timestamp`, etc. + +```csharp +using SpacetimeDB.Types; + +// In ClientManager or similar class, likely where HandleConnect is... +private void RegisterEventCallbacks(DbConnection conn) // Updated registration point +{ + // Table callbacks (from previous section) + conn.Db.Player.OnInsert += HandlePlayerInsert; + conn.Db.Player.OnUpdate += HandlePlayerUpdate; + conn.Db.Message.OnInsert += HandleMessageInsert; + + // Reducer callbacks + Reducer.OnSetName += HandleSetNameResult; + Reducer.OnSendMessage += HandleSendMessageResult; +} + +private void HandleSetNameResult(ReducerEventContext ctx, string name) +{ + // Check if the status is Failed + if (ctx.Event.Status is Status.Failed failedStatus) + { + // Check if the failure was for *our* call + if (ctx.Event.CallerIdentity == ctx.Identity) { + Console.WriteLine($"Error setting name to '{name}': {failedStatus.Reason}"); + } + } +} + +private void HandleSendMessageResult(ReducerEventContext ctx, string text) +{ + if (ctx.Event.Status is Status.Failed failedStatus) + { + if (ctx.Event.CallerIdentity == ctx.Identity) { // Our call failed + Console.WriteLine($"[Error] Failed to send message '{text}': {failedStatus.Reason}"); + } + } +} + +// Example of calling a reducer (e.g., from user input handler) +public void SendChatMessage(string message) +{ + if (!string.IsNullOrEmpty(message)) + { + Reducer.SendMessage(message); // Static method call + } +} + +``` + +### Client SDK (TypeScript) + +This section details how to build TypeScript/JavaScript client applications (for web browsers or Node.js) that interact with a SpacetimeDB module, using a framework-agnostic approach. + +#### 1. Project Setup + +Install the SDK package into your project: + +```bash +# Using npm +npm install @clockworklabs/spacetimedb-sdk + +# Or using yarn +yarn add @clockworklabs/spacetimedb-sdk +``` + +#### 2. Generate Module Bindings + +Generate the module-specific bindings using the `spacetime generate` command: + +```bash +mkdir -p src/module_bindings +spacetime generate --lang typescript \ + --out-dir src/module_bindings \ + --project-path ../path/to/your/server_module +``` + +Import the necessary generated types and SDK components: + +```typescript +// Import SDK core types +import { Identity, Status } from "@clockworklabs/spacetimedb-sdk"; +// Import generated connection class, event contexts, and table types +import { DbConnection, EventContext, ReducerEventContext, Message, User } from "./module_bindings"; +// Reducer functions are accessed via conn.reducers +``` + +#### 3. Connecting to the Database + +Use the generated `DbConnection` class and its builder pattern to establish a connection. + +```typescript +import { DbConnection, EventContext, ReducerEventContext, Message, User } from './module_bindings'; +import { Identity, Status } from '@clockworklabs/spacetimedb-sdk'; + +const HOST = "ws://localhost:3000"; +const DB_NAME = "quickstart-chat"; +const CREDS_KEY = "auth_token"; + +class ChatClient { + public conn: DbConnection | null = null; + public identity: Identity | null = null; + public connected: boolean = false; + // Client-side cache for user lookups + private userMap: Map = new Map(); + + constructor() { + // Bind methods to ensure `this` is correct in callbacks + this.handleConnect = this.handleConnect.bind(this); + this.handleDisconnect = this.handleDisconnect.bind(this); + this.handleConnectError = this.handleConnectError.bind(this); + this.registerTableCallbacks = this.registerTableCallbacks.bind(this); + this.registerReducerCallbacks = this.registerReducerCallbacks.bind(this); + this.subscribeToTables = this.subscribeToTables.bind(this); + this.handleMessageInsert = this.handleMessageInsert.bind(this); + this.handleUserInsert = this.handleUserInsert.bind(this); + this.handleUserUpdate = this.handleUserUpdate.bind(this); + this.handleUserDelete = this.handleUserDelete.bind(this); + this.handleSendMessageResult = this.handleSendMessageResult.bind(this); + } + + public connect() { + console.log("Attempting to connect..."); + const token = localStorage.getItem(CREDS_KEY) || null; + + const connectionInstance = DbConnection.builder() + .withUri(HOST) + .withModuleName(DB_NAME) + .withToken(token) + .onConnect(this.handleConnect) + .onDisconnect(this.handleDisconnect) + .onConnectError(this.handleConnectError) + .build(); + + this.conn = connectionInstance; + } + + private handleConnect(conn: DbConnection, identity: Identity, token: string) { + this.identity = identity; + this.connected = true; + localStorage.setItem(CREDS_KEY, token); // Save new/refreshed token + console.log('Connected with identity:', identity.toHexString()); + + // Register callbacks and subscribe now that we are connected + this.registerTableCallbacks(); + this.registerReducerCallbacks(); + this.subscribeToTables(); + } + + private handleDisconnect() { + console.log('Disconnected'); + this.connected = false; + this.identity = null; + this.conn = null; + this.userMap.clear(); // Clear local cache on disconnect + } + + private handleConnectError(err: Error) { + console.error('Connection Error:', err); + localStorage.removeItem(CREDS_KEY); // Clear potentially invalid token + this.conn = null; // Ensure connection is marked as unusable + } + + // Placeholder implementations for callback registration and subscription + private registerTableCallbacks() { /* See Section 6 */ } + private registerReducerCallbacks() { /* See Section 7 */ } + private subscribeToTables() { /* See Section 5 */ } + + // Placeholder implementations for table callbacks + private handleMessageInsert(ctx: EventContext | undefined, message: Message) { /* See Section 6 */ } + private handleUserInsert(ctx: EventContext | undefined, user: User) { /* See Section 6 */ } + private handleUserUpdate(ctx: EventContext | undefined, oldUser: User, newUser: User) { /* See Section 6 */ } + private handleUserDelete(ctx: EventContext, user: User) { /* See Section 6 */ } + + // Placeholder for reducer callback + private handleSendMessageResult(ctx: ReducerEventContext, messageText: string) { /* See Section 7 */ } + + // Public methods for interaction + public sendChatMessage(message: string) { /* See Section 7 */ } + public setPlayerName(newName: string) { /* See Section 7 */ } +} + +// Example Usage: +// const client = new ChatClient(); +// client.connect(); +``` + +#### 4. Managing the Connection Loop + +The TypeScript SDK is event-driven. No manual `FrameTick()` is needed. + +#### 5. Subscribing to Data + +Subscribe to SQL queries to receive data. + +```typescript +// Part of the ChatClient class +private subscribeToTables() { + if (!this.conn) return; + + const queries = ["SELECT * FROM message", "SELECT * FROM user"]; + + console.log("Subscribing..."); + this.conn + .subscriptionBuilder() + .onApplied(() => { + console.log(`Subscription applied for: ${queries}`); + // Initial cache is now populated, process initial data if needed + this.processInitialCache(); + }) + .onError((error: Error) => { + console.error(`Subscription error:`, error); + }) + .subscribe(queries); +} + +private processInitialCache() { + if (!this.conn) return; + console.log("Processing initial cache..."); + // Populate userMap from initial cache + this.userMap.clear(); + for (const user of this.conn.db.User.iter()) { + this.handleUserInsert(undefined, user); // Pass undefined context for initial load + } + // Process initial messages, e.g., sort and display + const initialMessages = Array.from(this.conn.db.Message.iter()); + initialMessages.sort((a, b) => a.sent.getTime() - b.sent.getTime()); + for (const message of initialMessages) { + this.handleMessageInsert(undefined, message); // Pass undefined context + } +} +``` + +#### 6. Accessing Cached Data & Handling Row Callbacks + +Maintain your own collections (e.g., `Map`) updated via table callbacks for efficient lookups. + +```typescript +// Part of the ChatClient class +private registerTableCallbacks() { + if (!this.conn) return; + + this.conn.db.Message.onInsert(this.handleMessageInsert); + + // User table callbacks update the local userMap + this.conn.db.User.onInsert(this.handleUserInsert); + this.conn.db.User.onUpdate(this.handleUserUpdate); + this.conn.db.User.onDelete(this.handleUserDelete); + + // Note: In a real app, you might return a cleanup function + // to unregister these if the ChatClient is destroyed. + // e.g., return () => { this.conn?.db.Message.removeOnInsert(...) }; +} + +private handleMessageInsert(ctx: EventContext | undefined, message: Message) { + const identityStr = message.sender.toHexString(); + // Look up sender in our local map + const sender = this.userMap.get(identityStr); + const senderName = sender?.name ?? identityStr.substring(0, 8); + + if (ctx) { // Live update + console.log(`LIVE MSG: ${senderName}: ${message.text}`); + // TODO: Update UI (e.g., add to message list) + } else { // Initial load (handled in processInitialCache) + // console.log(`Initial MSG loaded: ${message.text} from ${senderName}`); + } +} + +private handleUserInsert(ctx: EventContext | undefined, user: User) { + const identityStr = user.identity.toHexString(); + this.userMap.set(identityStr, user); + const name = user.name ?? identityStr.substring(0, 8); + if (ctx) { // Live update + if (user.online) console.log(`${name} connected.`); + } else { // Initial load + // console.log(`Loaded user: ${name} (Online: ${user.online})`); + } + // TODO: Update UI (e.g., user list) +} + +private handleUserUpdate(ctx: EventContext | undefined, oldUser: User, newUser: User) { + const oldIdentityStr = oldUser.identity.toHexString(); + const newIdentityStr = newUser.identity.toHexString(); + if(oldIdentityStr !== newIdentityStr) { + this.userMap.delete(oldIdentityStr); + } + this.userMap.set(newIdentityStr, newUser); + + const name = newUser.name ?? newIdentityStr.substring(0, 8); + if (ctx) { // Live update + if (!oldUser.online && newUser.online) console.log(`${name} connected.`); + else if (oldUser.online && !newUser.online) console.log(`${name} disconnected.`); + else if (oldUser.name !== newUser.name) console.log(`Rename: ${oldUser.name ?? '...'} -> ${name}.`); + } + // TODO: Update UI (e.g., user list, messages from this user) +} + +private handleUserDelete(ctx: EventContext, user: User) { + const identityStr = user.identity.toHexString(); + const name = user.name ?? identityStr.substring(0, 8); + this.userMap.delete(identityStr); + console.log(`${name} left/deleted.`); + // TODO: Update UI +} +``` + +:::info Handling Initial Data vs. Live Updates in Callbacks +In TypeScript, the first argument (`ctx: EventContext | undefined`) to row callbacks indicates the cause. If `ctx` is defined, it's a live update. If `undefined`, it's part of the initial subscription load. +::: + +#### 7. Invoking Reducers & Handling Reducer Callbacks + +Call reducers via `conn.reducers`. Register callbacks via `conn.reducers.onReducerName(...)` to observe outcomes. + +```typescript +// Part of the ChatClient class +private registerReducerCallbacks() { + if (!this.conn) return; + + this.conn.reducers.onSendMessage(this.handleSendMessageResult); + // Register other reducer callbacks if needed + // this.conn.reducers.onSetName(handleSetNameResult); + + // Note: Consider returning a cleanup function to unregister +} + +private handleSendMessageResult(ctx: ReducerEventContext, messageText: string) { + const wasOurCall = ctx.event.callerIdentity.isEqual(this.identity); + if (!wasOurCall) return; // Only care about our own calls here + + switch(ctx.event.status.tag) { + case "Committed": + console.log(`Our message "${messageText}" sent successfully.`); + break; + case "Failed": + console.error(`Failed to send "${messageText}": ${ctx.event.status.value}`); + break; + case "OutOfEnergy": + console.error(`Failed to send "${messageText}": Out of Energy!`); + break; + } +} + +// 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/docs/nav.ts b/docs/nav.ts new file mode 100644 index 00000000000..7dff8490531 --- /dev/null +++ b/docs/nav.ts @@ -0,0 +1,115 @@ +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'), + page('Subscription Semantics', 'subscriptions/semantics', 'subscriptions/semantics.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/package.json b/docs/package.json new file mode 100644 index 00000000000..6aa3861d283 --- /dev/null +++ b/docs/package.json @@ -0,0 +1,23 @@ +{ + "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", + "remark-parse": "^11.0.0", + "tsx": "^4.19.2", + "typescript": "^5.3.2", + "unified": "^11.0.5", + "unist-util-visit": "^5.0.0" + }, + "scripts": { + "build": "tsc --project ./tsconfig.json", + "check-links": "tsx scripts/checkLinks.ts" + }, + "author": "Clockwork Labs", + "license": "ISC" +} diff --git a/docs/scripts/checkLinks.ts b/docs/scripts/checkLinks.ts new file mode 100644 index 00000000000..b7d4ead7258 --- /dev/null +++ b/docs/scripts/checkLinks.ts @@ -0,0 +1,246 @@ +import fs from 'fs'; +import path from 'path'; +import nav from '../nav'; // Import the nav object directly +import GitHubSlugger from 'github-slugger'; +import { unified } from 'unified'; +import remarkParse from 'remark-parse'; +import { visit } from 'unist-util-visit'; + +// Function to map slugs to file paths from nav.ts +function extractSlugToPathMap(nav: { items: any[] }): Map { + 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 content = fs.readFileSync(filePath, 'utf-8'); + const tree = unified().use(remarkParse).parse(content); + + const results: { link: string; type: 'image' | 'link'; line: number }[] = []; + + visit(tree, ['link', 'image', 'definition'], (node: any) => { + const link = node.url; + const line = node.position?.start?.line ?? 0; + if (link) { + results.push({ link, type: node.type === 'image' ? 'image' : 'link', line }); + } + }); + + return results; +} + +// 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()); + + // Hacky workaround because the slug for the root is /docs/index. No other slugs have a /index at the end. + validSlugs.push('/docs'); + + // Reverse map from file path to slug for current file resolution + const pathToSlug = new Map(); + slugToPath.forEach((filePath, slug) => { + 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 + } + + if (!link.startsWith('/docs')) { + 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 + let [baseLink, fragmentRaw] = resolvedLink.split('#'); + if (baseLink.endsWith('/')) { + baseLink = baseLink.slice(0, -1); + } + 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/docs/tsconfig.json b/docs/tsconfig.json new file mode 100644 index 00000000000..d3f1db7d141 --- /dev/null +++ b/docs/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/docs/yarn.lock b/docs/yarn.lock new file mode 100644 index 00000000000..1527675fd6e --- /dev/null +++ b/docs/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==