From 80a7094f6f5d075792b016f0f3f3fc4e939183c2 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Tue, 4 Mar 2025 10:00:36 -0500 Subject: [PATCH] Update `release` with 1.0 changes (#202) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Changed subscript to SubscribeToAllTables (#155) Missed updating the HandleConnect from `Subscribe("SELECT * FROM *"` to `SubscribeToAllTables()` in the updated code block on page 3. * Updated the TypeScript quickstart guide to use the new 1.0 API (#141) * Updated the quickstart guide to use the new 1.0 API * Completed quickstart rewrite * Update docs/sdks/typescript/quickstart.md Co-authored-by: Phoebe Goldman * Update docs/sdks/typescript/quickstart.md Co-authored-by: Phoebe Goldman * Update docs/sdks/typescript/quickstart.md Co-authored-by: Phoebe Goldman * Update docs/sdks/typescript/quickstart.md Co-authored-by: Phoebe Goldman * Update docs/sdks/typescript/quickstart.md Co-authored-by: Phoebe Goldman * Clarification * Update docs/sdks/typescript/quickstart.md Co-authored-by: Phoebe Goldman * Update docs/sdks/typescript/quickstart.md Co-authored-by: Phoebe Goldman * Wrong type of quotes * Update docs/sdks/typescript/quickstart.md Co-authored-by: Phoebe Goldman * Update docs/sdks/typescript/quickstart.md Co-authored-by: Phoebe Goldman * Apply suggestions from code review Co-authored-by: Phoebe Goldman Co-authored-by: rekhoff * Address review comments --------- Co-authored-by: Phoebe Goldman Co-authored-by: rekhoff * Update Rust client SDK docs for SpacetimeDB#2118 (#130) * docs(70): The 1.0 SQL spec Closes #70. * Small TS SDK Quickstart Fixes (#157) Updated quickstart url * Style guide: add formatting advice for GUI elements and menu paths (#129) * Style guide: add formatting advice for menu items * Generalize guidance to all GUI elements, not just menu paths * Remove WebSocket api docs and all references to them (#165) Closes #164. --------- Co-authored-by: Phoebe Goldman * CLI docs (#168) * [bfops/cli-docs]: CLI docs * [bfops/cli-docs]: fix? * [bfops/cli-docs]: manual backticks * [bfops/cli-docs]: manual bold * [bfops/cli-docs]: manual bold * [bfops/cli-docs]: add README for maintaining CLI reference docs * [bfops/cli-docs]: maybe fix code? * [bfops/cli-docs]: tweak * [bfops/cli-docs]: tweak code * [bfops/cli-docs]: update --------- Co-authored-by: Zeke Foppa * Add link to the `cli` (#171) * Rename satn.md -> sats-json.md (#158) * Fix auto_inc attribute name (#175) * Document LIMIT and COUNT (#178) Closes #177. * Add best practices for Spacetime SQL (#180) Closes #179. * API for mutable subscriptions (#166) Closes #78. Includes rust and csharp examples. * Remove references to SpacetimeDB 0.6 Closes #118. * Remove reference to set energy-balance in http api Closes #119. * Remove references to testnet Closes #183. * Remove 0.12 migration guide * Update to C# Quickstart-Chat Server Module and Client SDK tutorial documents (#170) * Initial code pass on updating server to 1.0.0 * Updated to work with current 1.0.0-rc4, master branches of SpacetimeDB and the CSharpSDK * Minor edit for clarity * No longer optional, ReducerContext is always the first argument Co-authored-by: Phoebe Goldman * Improved description of OnInsert and OnDelete callbacks Co-authored-by: Phoebe Goldman * Fixed capitalization. Co-authored-by: Phoebe Goldman * Fixed capitalization. Co-authored-by: Phoebe Goldman * SDK language corrected and clarified. Co-authored-by: Phoebe Goldman * Added that the example is for the C# client and does not include server examples. Co-authored-by: Phoebe Goldman * Added comma for clarity Co-authored-by: Phoebe Goldman * Added comma for clarity Co-authored-by: Phoebe Goldman * Applied requested changes to improve clarity * Revised the SDK Client Quickstart to be more-in-line with the Rust Client Quickstart flow * Added comments to code * Replaced with quickstart-chat --------- Co-authored-by: Phoebe Goldman * Move Rust Module SDK reference to docs.rs (#114) Move rust reference to rustdoc * Document reducer semantics wrt. transactionality (#185) document reducer semantics wrt. transactionality * Document behaviour of SEQUENCES (#174) * Document behaviour of SEQUENCES * Update docs/appendix.md Co-authored-by: Tyler Cloutier * Apply suggestions from code review Co-authored-by: Phoebe Goldman --------- Co-authored-by: Tyler Cloutier Co-authored-by: Phoebe Goldman * Update Rust client SDK quickstart for 1.0 API (#162) * Begin revising rust client quickstart: update the code * Revise Rust client SDK quickstart A whole bunch of stuff has changed since this document was last updated. Notably, I've chosen to re-order a bunch of sections, since the previous structure of the document doesn't make much sense after the 0.12 API rework. * Fix credentials import issue There are still warnings here but it builds now * Fix warnings after pasting all this code into a fresh project --------- Co-authored-by: John Detter <4099508+jdetter@users.noreply.github.com> * Add docs for standalone config.toml (#190) * Add docs for standalone config.toml * Update docs/cli-reference/standalone-config.md Co-authored-by: Phoebe Goldman * pre formatting --------- Co-authored-by: Phoebe Goldman * Update docs for http api (#188) * Update docs for http api * Apply suggestions from code review Co-authored-by: Phoebe Goldman * Remove energy page --------- Co-authored-by: Phoebe Goldman * Update Rust SDK ref, and also a few small fixes (#172) * *Must* accept `ReducerContext`, not *may* * Small fixes to Rust docs for database Identity and rename Address * Update Rust SDK reference for various 1.0 API changes * Fix broken links * TOC and TODOs * Rename `Address` to `ConnectionId` in index, fix some links * Minor fixes I found while working through converting this to typescript * Link to SQL ref * Additional fixups found while rewriting TS ref * Remove references to BitCraftMini We no longer use this as an example. Also, I'm pretty sure we stopped using that name ages ago. * No UB from mixing `subscribe` and `subscribe_to_all_tables` Co-authored-by: joshua-spacetime * Update TypeScript SDK reference (#181) * Begin updating TypeScript SDK ref to match the new rust one * Link to SQL ref from `subscribe` method * Fill in the rest of the TypeScript SDK ref * Fix copy-paste error: `subscribeToAllTables` should be camelCase Co-authored-by: joshua-spacetime * Copy change from Rust SDK docs: no UB in `subscribeToAllTables` Co-authored-by: joshua-spacetime * Fix casing of `withModuleName` Co-authored-by: Tyler Cloutier * Address Tyler's review --------- Co-authored-by: joshua-spacetime Co-authored-by: Tyler Cloutier --------- Co-authored-by: joshua-spacetime Co-authored-by: Tyler Cloutier * How-to: Incremental Migrations (#127) * How-to: Incremental Migrations This commit adds a how-to guide for defining "incremental migrations," a strategy for updating the schema of a database while maintaining compatibility with outdated clients and without requiring a manual migration. The code is not on GitHub yet, as I'd like review on my choice of example before pushing the repository. As such, the links to the code at the bottom of the new document are broken. * Updates following review * Updates to blackhol.io tutorials (#194) Updates to blackholio tutorials Page 3 - Fix duplicate code in Rust "disconnect reducer" instructions. Page 4 - Update use of `CallerIdentity` to `Sender` in C# instructions. * Updated with corrected table names to lower case, for compatibility w… (#195) Updated with corrected table names to lower case, for compatibility with other quickstart-chat languages. Updated with additional changes in https://github.com/clockworklabs/com.clockworklabs.spacetimedbsdk/pull/258 * Small doc fixes potpourri (#198) * Remove hedging about table access from module quickstarts We'll announce RLS when we announce it. For now, what we have is what we have. * Remove hedging about supported module languages This kind of thing belongs in a roadmap, not anywhere else in our docs. * Fix :fingers_crossed: formatting of link to scheduled reducers * Fix link * List module langs in alphabetical order Which also happens to be decreasing order of support and battle-tested-ness * Re-order various and pages in sidebar - Internals get their own section, and move down. - Appendix gets its own section, instead of joining "Subscriptions." - SQL and Subscriptions move up. * Remove outdated guidance about tokens We don't have "SpacetimeDB tokens" anymore, we just have regular OIDC JWTs. We don't need to offer any special guidance about JWT hygiene. * Fixes the typescript quickstart for the new subscription API (#161) * Updated quickstart url * DBConnection -> DbConnection for TypeScript SDK * Updated for the subscription update * Multiplayer bug fix in tutorial (#169) * Multiplayer bug fix in tutorial * Update part-4.md Small fix * removed reference to test input This test input is not used during the tutorial and causes unused variable warnings * Update part-4.md * Add instructions for deploying to maincloud (#167) * [bfops/deploying]: add instructions for deploying mainnet * [bfops/deploying]: nav.ts * [bfops/deploying]: nav.ts * [bfops/deploying]: fix link? * Update docs/deploying/maincloud.md Co-authored-by: John Detter <4099508+jdetter@users.noreply.github.com> * Update docs/deploying/maincloud.md Co-authored-by: John Detter <4099508+jdetter@users.noreply.github.com> * [bfops/deploying]: review * Update docs/deploying/maincloud.md Co-authored-by: Tyler Cloutier * Apply suggestions from code review Co-authored-by: Tyler Cloutier * Add `/profile` as a known link --------- Co-authored-by: Zeke Foppa Co-authored-by: joshua-spacetime Co-authored-by: John Detter <4099508+jdetter@users.noreply.github.com> Co-authored-by: Tyler Cloutier Co-authored-by: Phoebe Goldman * C# Module Library docs (#193) * Most of the way to C# Module SDK docs * Copy in more docs * Mostly done * Remove dead docs * Apply suggestions from code review Thanks Mazdak, also going to apply some of these to the Rust modules. Co-authored-by: Mazdak Farrokhzad Co-authored-by: joshua-spacetime * Address review comments --------- Co-authored-by: Mazdak Farrokhzad Co-authored-by: joshua-spacetime * C# sdk reference (#191) Closes #192. * Describe how a JWT's sub/iss are translated into an Identity (#204) * Rekhoff/blackholio fixes (#205) * Updates to blackholio tutorials Page 3 - Fix duplicate code in Rust "disconnect reducer" instructions. Page 4 - Update use of `CallerIdentity` to `Sender` in C# instructions. * Fixes from running through Blackholio tutorial in Rust and C# on 1.0.0 * Minor formatting updates * Minor formatting update * Another minor format change * Whitespace fix. Co-authored-by: John Detter <4099508+jdetter@users.noreply.github.com> * Whitespace fix. Co-authored-by: John Detter <4099508+jdetter@users.noreply.github.com> * Whitespace fix. Co-authored-by: John Detter <4099508+jdetter@users.noreply.github.com> --------- Co-authored-by: John Detter <4099508+jdetter@users.noreply.github.com> --------- Co-authored-by: rekhoff Co-authored-by: Tyler Cloutier Co-authored-by: joshua-spacetime Co-authored-by: Zeke Foppa <196249+bfops@users.noreply.github.com> Co-authored-by: Zeke Foppa Co-authored-by: Mario Montoya Co-authored-by: Noa Co-authored-by: james gilles Co-authored-by: Mazdak Farrokhzad Co-authored-by: John Detter <4099508+jdetter@users.noreply.github.com> --- README.md | 10 + STYLE.md | 12 +- docs/appendix.md | 61 + docs/bsatn.md | 2 +- docs/cli-reference.md | 589 +++++++++ docs/cli-reference/standalone-config.md | 44 + docs/deploying/maincloud.md | 30 + docs/deploying/testnet.md | 34 - docs/how-to/incremental-migrations.md | 369 ++++++ docs/http/database.md | 621 ++++------ docs/http/energy.md | 76 -- docs/http/identity.md | 72 +- docs/http/index.md | 41 +- docs/index.md | 130 +- docs/migration/v0.12.md | 341 ------ docs/modules/c-sharp/index.md | 1468 +++++++++++++++++++---- docs/modules/c-sharp/quickstart.md | 109 +- docs/modules/index.md | 11 +- docs/modules/rust/index.md | 525 +------- docs/modules/rust/quickstart.md | 16 +- docs/nav.js | 37 +- docs/{satn.md => sats-json.md} | 14 +- docs/sdks/c-sharp/index.md | 1191 +++++++++--------- docs/sdks/c-sharp/quickstart.md | 400 +++--- docs/sdks/rust/index.md | 555 ++++++++- docs/sdks/rust/quickstart.md | 314 ++--- docs/sdks/typescript/index.md | 1191 +++++++++--------- docs/sdks/typescript/quickstart.md | 769 +++++++----- docs/sql/index.md | 723 +++++++---- docs/subscriptions/index.md | 446 +++++++ docs/unity/part-2.md | 39 +- docs/unity/part-3.md | 35 +- docs/unity/part-4.md | 97 +- docs/ws/index.md | 318 ----- nav.ts | 54 +- scripts/checkLinks.ts | 2 +- 36 files changed, 6449 insertions(+), 4297 deletions(-) create mode 100644 docs/appendix.md create mode 100644 docs/cli-reference.md create mode 100644 docs/cli-reference/standalone-config.md create mode 100644 docs/deploying/maincloud.md delete mode 100644 docs/deploying/testnet.md create mode 100644 docs/how-to/incremental-migrations.md delete mode 100644 docs/http/energy.md delete mode 100644 docs/migration/v0.12.md rename docs/{satn.md => sats-json.md} (86%) create mode 100644 docs/subscriptions/index.md delete mode 100644 docs/ws/index.md diff --git a/README.md b/README.md index 2165ae62..b5c66551 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,16 @@ git push -u origin a-branch-name-that-describes-my-change > NOTE! If you make a change to `nav.ts` you will have to run `npm run build` to generate a new `docs/nav.js` file. +#### CLI Reference Section +1. Make sure that https://github.com/clockworklabs/SpacetimeDB/pull/2276 is included in your `spacetimedb-cli` binary +1. Run `cargo run --features markdown-docs -p spacetimedb-cli > cli-reference.md` + +We currently don't properly render markdown backticks and bolding that are inside of headers, so do these two manual replacements to make them look okay (these have only been tested on Linux): +```bash +sed -i'' -E 's!^(##) `(.*)`$!\1 \2!' docs/cli-reference.md +sed -i'' -E 's!^(######) \*\*(.*)\*\*$!\1 \2!' docs/cli-reference.md +``` + ### Checking Links We have a CI job which validates internal links. You can run it locally with `npm run check-links`. This will print any internal links (i.e. links to other docs pages) whose targets do not exist, including fragment links (i.e. `#`-ey links to anchors). diff --git a/STYLE.md b/STYLE.md index 96baef43..4fe1f676 100644 --- a/STYLE.md +++ b/STYLE.md @@ -73,6 +73,14 @@ Don't make promises, even weak ones, about what we plan to do in the future, wit If your document needs to describe a feature that isn't implemented yet, either rewrite to not depend on that feature, or just say that it's a "current limitation" without elaborating further. Include a workaround if there is one. +### Menu items and paths + +When describing GUI elements and menu items, like the **Unity Registry** tab, use bolded text to draw attention to any phrases that will appear in the actual UI. Readers will see this bolded text in the documentation and look for it on their screen. Where applicable, include a short description of the type or category of element, like "tab" above, or the **File** menu. This category should not be bolded, since it is not a word the reader can expect to find on their screen. + +When describing a chain of accesses through menus and submenus, use the **->** thin arrow (that's `->`, a hyphen followed by a greater-than sign) as a separator, like **File -> Quit** or **Window -> Package Manager**. List the top-level menu first, and proceed left-to-right until you reach the option you want the user to interact with. Include all nested submenus, like **Foo -> Bar -> Baz -> Quux**. Bold the whole sequence, including the arrows. + +It's generally not necessary or desirable to tell users where to look for the top-level menu. You may be tempted to write something like, "Open the **File** menu in the upper left, and navigate **File -> Export as -> Export as PDF**." Do not include "in the upper left" unless you are absolutely confident that the menu will be located there on any combination of OS, version, desktop environment, window manager, theming configuration &c. Even within a single system, UI designers are known to move graphical elements around during updates, making statements like "upper left" obsolete and stale. We can generally trust our readers to be familiar with their own systems and the software they use, and none of our documents involve introducing readers to new GUI software. (E.g. the Unity tutorial is targeted at introducing SpacetimeDB to people who already know Unity.) "Open the **File** menu and navigate **File -> Export as -> Export as PDF**" is sufficient. + ## Key vocabulary There are a small number of key terms that we need to use consistently throughout the documentation. @@ -367,7 +375,7 @@ Start the conclusion with a sentence or paragraph that reminds the reader what t If this tutorial is part of a series, link to the next entry: -> You can use any of SpacetimDB's supported client languages to do this. Take a look at the quickstart guide for your client language of choice: [Rust](/docs/sdks/rust/quickstart), [C#](/docs/sdks/c-sharp/quickstart), or [TypeScript](/docs/sdks/typescript/quickstart). If you are planning to use SpacetimeDB with the Unity game engine, you can skip right to the [Unity Comprehensive Tutorial](/docs/unity/part-1) or check out our example game, [BitcraftMini](/docs/unity/part-3). +> You can use any of SpacetimDB's supported client languages to do this. Take a look at the quickstart guide for your client language of choice: [Rust](/docs/sdks/rust/quickstart), [C#](/docs/sdks/c-sharp/quickstart), or [TypeScript](/docs/sdks/typescript/quickstart). If you are planning to use SpacetimeDB with the Unity game engine, you can skip right to the [Unity Comprehensive Tutorial](/docs/unity/part-1). If this tutorial is about a specific component, link to its reference page: @@ -391,7 +399,7 @@ If this tutorial is the end of a series, or ends with a reasonably complete app, If the tutorial involved writing code, add a link to the complete code. This should be somewhere on GitHub, either as its own repo, or as an example project within an existing repo. Ensure the linked folder has a README.md file which includes: - The name of the tutorial project. -- How to run or interact with the tutorial project, whatever that means (e.g. publish to testnet and then `spacetime call`). +- How to run or interact with the tutorial project, whatever that means (e.g. publish to maincloud and then `spacetime call`). - Links to external dependencies (e.g. for client projects, the module which it runs against). - A back-link to the tutorial that builds this project. diff --git a/docs/appendix.md b/docs/appendix.md new file mode 100644 index 00000000..bc184c24 --- /dev/null +++ b/docs/appendix.md @@ -0,0 +1,61 @@ +# Appendix + +## SEQUENCE + +For each table containing an `#[auto_inc]` column, SpacetimeDB creates a sequence number generator behind the scenes, which functions similarly to `postgres`'s `SEQUENCE`. + +### How It Works + +* Sequences in SpacetimeDB use Rust’s `i128` integer type. +* The field type marked with `#[auto_inc]` is cast to `i128` and increments by `1` for each new row. +* Sequences are pre-allocated in chunks of `4096` to speed up number generation, and then are only persisted to disk when the pre-allocated chunk is exhausted. + +> **⚠ Warning:** Sequence number generation is not transactional. + +* Numbers are incremented even if a transaction is later rolled back. +* Unused numbers are not reclaimed, meaning sequences may have *gaps*. +* If the server restarts or a transaction rolls back, the sequence continues from the next pre-allocated chunk + `1`: + +**Example:** + +```rust +#[spacetimedb::table(name = users, public)] +struct Users { + #[auto_inc] + user_id: u64, + name: String, +} + +#[spacetimedb::reducer] +pub fn insert_user(ctx: &ReducerContext, count: u8) { + for i in 0..count { + let name = format!("User {}", i); + ctx.db.users().insert(Users { user_id: 0, name }); + } + // Query the table to see the effect of the `[auto_inc]` attribute: + for user in ctx.db.users().iter() { + log::info!("User: {:?}", user); + } +} +``` + +Then: + +```bash +❯ cargo run --bin spacetimedb-cli call sample insert_user 3 + +❯ spacetimedb-cli logs sample +... +.. User: Users { user_id: 1, name: "User 0" } +.. User: Users { user_id: 2, name: "User 1" } +.. User: Users { user_id: 3, name: "User 2" } + +# Database restart, then + +❯ cargo run --bin spacetimedb-cli call sample insert_user 1 + +❯ spacetimedb-cli logs sample +... +.. User: Users { user_id: 3, name: "User 2" } +.. User: Users { user_id: 4098, name: "User 0" } +``` \ No newline at end of file diff --git a/docs/bsatn.md b/docs/bsatn.md index e8e6d945..703e210c 100644 --- a/docs/bsatn.md +++ b/docs/bsatn.md @@ -104,6 +104,6 @@ Where All SATS types are BSATN-encoded by converting them to an `AlgebraicValue`, then BSATN-encoding that meta-value. -See [the SATN JSON Format](/docs/satn) +See [the SATN JSON Format](/docs/sats-json) for more details of the conversion to meta values. Note that these meta values are converted to BSATN and _not JSON_. diff --git a/docs/cli-reference.md b/docs/cli-reference.md new file mode 100644 index 00000000..8396f50a --- /dev/null +++ b/docs/cli-reference.md @@ -0,0 +1,589 @@ +# Command-Line Help for `spacetime` + +This document contains the help content for the `spacetime` command-line program. + +**Command Overview:** + +* [`spacetime`↴](#spacetime) +* [`spacetime publish`↴](#spacetime-publish) +* [`spacetime delete`↴](#spacetime-delete) +* [`spacetime logs`↴](#spacetime-logs) +* [`spacetime call`↴](#spacetime-call) +* [`spacetime describe`↴](#spacetime-describe) +* [`spacetime energy`↴](#spacetime-energy) +* [`spacetime energy balance`↴](#spacetime-energy-balance) +* [`spacetime sql`↴](#spacetime-sql) +* [`spacetime rename`↴](#spacetime-rename) +* [`spacetime generate`↴](#spacetime-generate) +* [`spacetime list`↴](#spacetime-list) +* [`spacetime login`↴](#spacetime-login) +* [`spacetime login show`↴](#spacetime-login-show) +* [`spacetime logout`↴](#spacetime-logout) +* [`spacetime init`↴](#spacetime-init) +* [`spacetime build`↴](#spacetime-build) +* [`spacetime server`↴](#spacetime-server) +* [`spacetime server list`↴](#spacetime-server-list) +* [`spacetime server set-default`↴](#spacetime-server-set-default) +* [`spacetime server add`↴](#spacetime-server-add) +* [`spacetime server remove`↴](#spacetime-server-remove) +* [`spacetime server fingerprint`↴](#spacetime-server-fingerprint) +* [`spacetime server ping`↴](#spacetime-server-ping) +* [`spacetime server edit`↴](#spacetime-server-edit) +* [`spacetime server clear`↴](#spacetime-server-clear) +* [`spacetime subscribe`↴](#spacetime-subscribe) +* [`spacetime start`↴](#spacetime-start) +* [`spacetime version`↴](#spacetime-version) + +## spacetime + +**Usage:** `spacetime [OPTIONS] ` + +###### Subcommands: + +* `publish` — Create and update a SpacetimeDB database +* `delete` — Deletes a SpacetimeDB database +* `logs` — Prints logs from a SpacetimeDB database +* `call` — Invokes a reducer function in a database. WARNING: This command is UNSTABLE and subject to breaking changes. +* `describe` — Describe the structure of a database or entities within it. WARNING: This command is UNSTABLE and subject to breaking changes. +* `energy` — Invokes commands related to database budgets. WARNING: This command is UNSTABLE and subject to breaking changes. +* `sql` — Runs a SQL query on the database. WARNING: This command is UNSTABLE and subject to breaking changes. +* `rename` — Rename a database +* `generate` — Generate client files for a spacetime module. +* `list` — Lists the databases attached to an identity. WARNING: This command is UNSTABLE and subject to breaking changes. +* `login` — Manage your login to the SpacetimeDB CLI +* `logout` — +* `init` — Initializes a new spacetime project. WARNING: This command is UNSTABLE and subject to breaking changes. +* `build` — Builds a spacetime module. +* `server` — Manage the connection to the SpacetimeDB server. WARNING: This command is UNSTABLE and subject to breaking changes. +* `subscribe` — Subscribe to SQL queries on the database. WARNING: This command is UNSTABLE and subject to breaking changes. +* `start` — Start a local SpacetimeDB instance +* `version` — Manage installed spacetime versions + +###### Options: + +* `--root-dir ` — The root directory to store all spacetime files in. +* `--config-path ` — The path to the cli.toml config file + + + +## spacetime publish + +Create and update a SpacetimeDB database + +**Usage:** `spacetime publish [OPTIONS] [name|identity]` + +Run `spacetime help publish` for more detailed information. + +###### Arguments: + +* `` — A valid domain or identity for this database + +###### Options: + +* `-c`, `--delete-data` — When publishing to an existing database identity, first DESTROY all data associated with the module +* `--build-options ` — Options to pass to the build command, for example --build-options='--skip-println-checks' + + Default value: `` +* `-p`, `--project-path ` — The system path (absolute or relative) to the module project + + Default value: `.` +* `-b`, `--bin-path ` — The system path (absolute or relative) to the compiled wasm binary we should publish, instead of building the project. +* `--anonymous` — Perform this action with an anonymous identity +* `-s`, `--server ` — The nickname, domain name or URL of the server to host the database. +* `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). + + + +## spacetime delete + +Deletes a SpacetimeDB database + +**Usage:** `spacetime delete [OPTIONS] ` + +Run `spacetime help delete` for more detailed information. + + +###### Arguments: + +* `` — The name or identity of the database to delete + +###### Options: + +* `-s`, `--server ` — The nickname, host name or URL of the server hosting the database +* `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). + + + +## spacetime logs + +Prints logs from a SpacetimeDB database + +**Usage:** `spacetime logs [OPTIONS] ` + +Run `spacetime help logs` for more detailed information. + + +###### Arguments: + +* `` — The name or identity of the database to print logs from + +###### Options: + +* `-s`, `--server ` — The nickname, host name or URL of the server hosting the database +* `-n`, `--num-lines ` — The number of lines to print from the start of the log of this database. If no num lines is provided, all lines will be returned. +* `-f`, `--follow` — A flag that causes logs to not stop when end of the log file is reached, but rather to wait for additional data to be appended to the input. +* `--format ` — Output format for the logs + + Default value: `text` + + Possible values: `text`, `json` + +* `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). + + + +## spacetime call + +Invokes a reducer function in a database. WARNING: This command is UNSTABLE and subject to breaking changes. + +**Usage:** `spacetime call [OPTIONS] [arguments]...` + +Run `spacetime help call` for more detailed information. + + +###### Arguments: + +* `` — The database name or identity to use to invoke the call +* `` — The name of the reducer to call +* `` — arguments formatted as JSON + +###### Options: + +* `-s`, `--server ` — The nickname, host name or URL of the server hosting the database +* `--anonymous` — Perform this action with an anonymous identity +* `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). + + + +## spacetime describe + +Describe the structure of a database or entities within it. WARNING: This command is UNSTABLE and subject to breaking changes. + +**Usage:** `spacetime describe [OPTIONS] --json [entity_type] [entity_name]` + +Run `spacetime help describe` for more detailed information. + + +###### Arguments: + +* `` — The name or identity of the database to describe +* `` — Whether to describe a reducer or table + + Possible values: `reducer`, `table` + +* `` — The name of the entity to describe + +###### Options: + +* `--json` — Output the schema in JSON format. Currently required; in the future, omitting this will give human-readable output. +* `--anonymous` — Perform this action with an anonymous identity +* `-s`, `--server ` — The nickname, host name or URL of the server hosting the database +* `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). + + + +## spacetime energy + +Invokes commands related to database budgets. WARNING: This command is UNSTABLE and subject to breaking changes. + +**Usage:** `spacetime energy + energy ` + +###### Subcommands: + +* `balance` — Show current energy balance for an identity + + + +## spacetime energy balance + +Show current energy balance for an identity + +**Usage:** `spacetime energy balance [OPTIONS]` + +###### Options: + +* `-i`, `--identity ` — The identity to check the balance for. If no identity is provided, the default one will be used. +* `-s`, `--server ` — The nickname, host name or URL of the server from which to request balance information +* `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). + + + +## spacetime sql + +Runs a SQL query on the database. WARNING: This command is UNSTABLE and subject to breaking changes. + +**Usage:** `spacetime sql [OPTIONS] ` + +###### Arguments: + +* `` — The name or identity of the database you would like to query +* `` — The SQL query to execute + +###### Options: + +* `--interactive` — Instead of using a query, run an interactive command prompt for `SQL` expressions +* `--anonymous` — Perform this action with an anonymous identity +* `-s`, `--server ` — The nickname, host name or URL of the server hosting the database +* `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). + + + +## spacetime rename + +Rename a database + +**Usage:** `spacetime rename [OPTIONS] --to ` + +Run `spacetime rename --help` for more detailed information. + + +###### Arguments: + +* `` — The database identity to rename + +###### Options: + +* `--to ` — The new name you would like to assign +* `-s`, `--server ` — The nickname, host name or URL of the server on which to set the name +* `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). + + + +## spacetime generate + +Generate client files for a spacetime module. + +**Usage:** `spacetime spacetime generate --lang --out-dir [--project-path | --bin-path ]` + +Run `spacetime help publish` for more detailed information. + +###### Options: + +* `-b`, `--bin-path ` — The system path (absolute or relative) to the compiled wasm binary we should inspect +* `-p`, `--project-path ` — The system path (absolute or relative) to the project you would like to inspect + + Default value: `.` +* `-o`, `--out-dir ` — The system path (absolute or relative) to the generate output directory +* `--namespace ` — The namespace that should be used + + Default value: `SpacetimeDB.Types` +* `-l`, `--lang ` — The language to generate + + Possible values: `csharp`, `typescript`, `rust` + +* `--build-options ` — Options to pass to the build command, for example --build-options='--skip-println-checks' + + Default value: `` +* `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). + + + +## spacetime list + +Lists the databases attached to an identity. WARNING: This command is UNSTABLE and subject to breaking changes. + +**Usage:** `spacetime list [OPTIONS]` + +###### Options: + +* `-s`, `--server ` — The nickname, host name or URL of the server from which to list databases +* `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). + + + +## spacetime login + +Manage your login to the SpacetimeDB CLI + +**Usage:** `spacetime login [OPTIONS] + login ` + +###### Subcommands: + +* `show` — Show the current login info + +###### Options: + +* `--auth-host ` — Fetch login token from a different host + + Default value: `https://spacetimedb.com` +* `--server-issued-login ` — Log in to a SpacetimeDB server directly, without going through a global auth server +* `--token ` — Bypass the login flow and use a login token directly + + + +## spacetime login show + +Show the current login info + +**Usage:** `spacetime login show [OPTIONS]` + +###### Options: + +* `--token` — Also show the auth token + + + +## spacetime logout + +**Usage:** `spacetime logout [OPTIONS]` + +###### Options: + +* `--auth-host ` — Log out from a custom auth server + + Default value: `https://spacetimedb.com` + + + +## spacetime init + +Initializes a new spacetime project. WARNING: This command is UNSTABLE and subject to breaking changes. + +**Usage:** `spacetime init --lang [project-path]` + +###### Arguments: + +* `` — The path where we will create the spacetime project + + Default value: `.` + +###### Options: + +* `-l`, `--lang ` — The spacetime module language. + + Possible values: `csharp`, `rust` + + + + +## spacetime build + +Builds a spacetime module. + +**Usage:** `spacetime build [OPTIONS]` + +###### Options: + +* `-p`, `--project-path ` — The system path (absolute or relative) to the project you would like to build + + Default value: `.` +* `--lint-dir ` — The directory to lint for nonfunctional print statements. If set to the empty string, skips linting. + + Default value: `src` +* `-d`, `--debug` — Builds the module using debug instead of release (intended to speed up local iteration, not recommended for CI) + + + +## spacetime server + +Manage the connection to the SpacetimeDB server. WARNING: This command is UNSTABLE and subject to breaking changes. + +**Usage:** `spacetime server + server ` + +###### Subcommands: + +* `list` — List stored server configurations +* `set-default` — Set the default server for future operations +* `add` — Add a new server configuration +* `remove` — Remove a saved server configuration +* `fingerprint` — Show or update a saved server's fingerprint +* `ping` — Checks to see if a SpacetimeDB host is online +* `edit` — Update a saved server's nickname, host name or protocol +* `clear` — Deletes all data from all local databases + + + +## spacetime server list + +List stored server configurations + +**Usage:** `spacetime server list` + + + +## spacetime server set-default + +Set the default server for future operations + +**Usage:** `spacetime server set-default ` + +###### Arguments: + +* `` — The nickname, host name or URL of the new default server + + + +## spacetime server add + +Add a new server configuration + +**Usage:** `spacetime server add [OPTIONS] --url ` + +###### Arguments: + +* `` — Nickname for this server + +###### Options: + +* `--url ` — The URL of the server to add +* `-d`, `--default` — Make the new server the default server for future operations +* `--no-fingerprint` — Skip fingerprinting the server + + + +## spacetime server remove + +Remove a saved server configuration + +**Usage:** `spacetime server remove [OPTIONS] ` + +###### Arguments: + +* `` — The nickname, host name or URL of the server to remove + +###### Options: + +* `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). + + + +## spacetime server fingerprint + +Show or update a saved server's fingerprint + +**Usage:** `spacetime server fingerprint [OPTIONS] ` + +###### Arguments: + +* `` — The nickname, host name or URL of the server + +###### Options: + +* `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). + + + +## spacetime server ping + +Checks to see if a SpacetimeDB host is online + +**Usage:** `spacetime server ping ` + +###### Arguments: + +* `` — The nickname, host name or URL of the server to ping + + + +## spacetime server edit + +Update a saved server's nickname, host name or protocol + +**Usage:** `spacetime server edit [OPTIONS] ` + +###### Arguments: + +* `` — The nickname, host name or URL of the server + +###### Options: + +* `--new-name ` — A new nickname to assign the server configuration +* `--url ` — A new URL to assign the server configuration +* `--no-fingerprint` — Skip fingerprinting the server +* `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). + + + +## spacetime server clear + +Deletes all data from all local databases + +**Usage:** `spacetime server clear [OPTIONS]` + +###### Options: + +* `--data-dir ` — The path to the server data directory to clear [default: that of the selected spacetime instance] +* `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). + + + +## spacetime subscribe + +Subscribe to SQL queries on the database. WARNING: This command is UNSTABLE and subject to breaking changes. + +**Usage:** `spacetime subscribe [OPTIONS] ...` + +###### Arguments: + +* `` — The name or identity of the database you would like to query +* `` — The SQL query to execute + +###### Options: + +* `-n`, `--num-updates ` — The number of subscription updates to receive before exiting +* `-t`, `--timeout ` — The timeout, in seconds, after which to disconnect and stop receiving subscription messages. If `-n` is specified, it will stop after whichever + one comes first. +* `--print-initial-update` — Print the initial update for the queries. +* `--anonymous` — Perform this action with an anonymous identity +* `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). +* `-s`, `--server ` — The nickname, host name or URL of the server hosting the database + + + +## spacetime start + +Start a local SpacetimeDB instance + +Run `spacetime start --help` to see all options. + +**Usage:** `spacetime start [OPTIONS] [args]...` + +###### Arguments: + +* `` — The args to pass to `spacetimedb-{edition} start` + +###### Options: + +* `--edition ` — The edition of SpacetimeDB to start up + + Default value: `standalone` + + Possible values: `standalone`, `cloud` + + + + +## spacetime version + +Manage installed spacetime versions + +Run `spacetime version --help` to see all options. + +**Usage:** `spacetime version [ARGS]...` + +###### Arguments: + +* `` — The args to pass to spacetimedb-update + + + +
+ + + This document was generated automatically by + clap-markdown. + + diff --git a/docs/cli-reference/standalone-config.md b/docs/cli-reference/standalone-config.md new file mode 100644 index 00000000..0ce6350d --- /dev/null +++ b/docs/cli-reference/standalone-config.md @@ -0,0 +1,44 @@ +# `spacetimedb-standalone` configuration + +A local database instance (as started by `spacetime start`) can be configured in `{data-dir}/config.toml`, where `{data-dir}` is the database's data directory. This directory is printed when you run `spacetime start`: + + +
spacetimedb-standalone version: 1.0.0
+spacetimedb-standalone path: /home/user/.local/share/spacetime/bin/1.0.0/spacetimedb-standalone
+database running in data directory /home/user/.local/share/spacetime/data
+ +On Linux and macOS, this directory is by default `~/.local/share/spacetime/data`. On Windows, it's `%LOCALAPPDATA%\SpacetimeDB\data`. + +## `config.toml` + +- [`certificate-authority`](#certificate-authority) +- [`logs`](#logs) + +### `certificate-authority` + +```toml +[certificate-authority] +jwt-priv-key-path = "/path/to/id_ecdsas" +jwt-pub-key-path = "/path/to/id_ecdsas.pub" +``` + +The `certificate-authority` table lets you configure the public and private keys used by the database to sign tokens. + +### `logs` + +```toml +[logs] +level = "error" +directives = [ + "spacetimedb=warn", + "spacetimedb_standalone=info", +] +``` + +#### `logs.level` + +Can be one of `"error"`, `"warn"`, `"info"`, `"debug"`, `"trace"`, or `"off"`, case-insensitive. Only log messages of the specified level or higher will be output; e.g. if set to `warn`, only `error` and `warn`-level messages will be logged. + +#### `logs.directives` + +A list of filtering directives controlling what messages get logged, which overwrite the global [`logs.level`](#logslevel). See [`tracing documentation`](https://docs.rs/tracing-subscriber/0.3/tracing_subscriber/filter/struct.EnvFilter.html#directives) for syntax. Note that this is primarily intended as a debugging tool, and log message fields and targets are not considered stable. diff --git a/docs/deploying/maincloud.md b/docs/deploying/maincloud.md new file mode 100644 index 00000000..ea14ebbd --- /dev/null +++ b/docs/deploying/maincloud.md @@ -0,0 +1,30 @@ +# Deploy to Maincloud + +Maincloud is a managed cloud service that provides developers an easy way to deploy their SpacetimeDB apps to the cloud. + +## Deploy via CLI + +1. Install the SpacetimeDB CLI for your platform: [Install SpacetimeDB](/install) +1. Create your module (see [Getting Started](/docs/getting-started)) +1. Publish to Maincloud: + +```bash +spacetime publish -s maincloud my-cool-module +``` + +## Connecting your Identity to the Web Dashboard + +By logging in your CLI via spacetimedb.com, you can view your published modules on the web dashboard. + +If you did not log in with spacetimedb.com when publishing your module, you can log in by running: +```bash +spacetime logout +spacetime login +``` + +1. Open the SpacetimeDB website and log in using your GitHub login. +1. You should now be able to see your published modules [by navigating to your profile on the website](/profile). + +--- + +With SpacetimeDB Maincloud, you benefit from automatic scaling, robust security, and the convenience of not having to manage the hosting environment. diff --git a/docs/deploying/testnet.md b/docs/deploying/testnet.md deleted file mode 100644 index ce648043..00000000 --- a/docs/deploying/testnet.md +++ /dev/null @@ -1,34 +0,0 @@ -# SpacetimeDB Cloud Deployment - -The SpacetimeDB Cloud is a managed cloud service that provides developers an easy way to deploy their SpacetimeDB apps to the cloud. - -Currently only the `testnet` is available for SpacetimeDB cloud which is subject to wipes. The `mainnet` will be available soon. - -## Deploy via CLI - -1. [Install](/install) the SpacetimeDB CLI. -1. Configure your CLI to use the SpacetimeDB Cloud. To do this, run the `spacetime server` command: - -```bash -spacetime server add --default "https://testnet.spacetimedb.com" testnet -``` - -## Connecting your Identity to the Web Dashboard - -By associating an email with your CLI identity, you can view your published modules on the web dashboard. - -1. Get your identity using the `spacetime identity list` command. Copy it to your clipboard. -1. Connect your email address to your identity using the `spacetime identity set-email` command: - -```bash -spacetime identity set-email -``` - -1. Open the SpacetimeDB website and log in using your email address. -1. Choose your identity from the dropdown menu. -1. Validate your email address by clicking the link in the email you receive. -1. You should now be able to see your published modules on the web dashboard. - ---- - -With SpacetimeDB Cloud, you benefit from automatic scaling, robust security, and the convenience of not having to manage the hosting environment. diff --git a/docs/how-to/incremental-migrations.md b/docs/how-to/incremental-migrations.md new file mode 100644 index 00000000..3f9106b1 --- /dev/null +++ b/docs/how-to/incremental-migrations.md @@ -0,0 +1,369 @@ +# Incremental Migrations + +SpacetimeDB does not provide built-in support for general schema-modifying migrations. It does, however, allow adding new tables, and changing reducers' definitions in arbitrary ways. It's possible to run general migrations using an external tool, but this is tedious, necessitates downtime, and imposes the requirement that you update all your clients at the same time as publishing your new module version. + +Our friends at [Lightfox Games](https://www.lightfoxgames.com/) taught us a pattern they call "incremental migrations," which mitigates all these problems, and works perfectly with SpacetimeDB's capabilities. The short version is that, instead of altering an existing table, you add a new table with the desired new schema. Whenever your module wants to access a row from that table, it first checks the new table. If the row is present in the new table, then you've already migrated, so do whatever you want to do. If the new table doesn't have the row, instead look it up in the old table, compute and insert a row for the new table, and use that. (If the row isn't present in either the old or new table, it's just not present.) If possible, you should also update the row in the old table to match any mutations that happen in the new table, so that outdated clients can still function. + +This has several advantages: +- SpacetimeDB's module hotswapping makes this a zero-downtime update. Write your new module, `spacetime publish` it, and watch the new table populate as it's used. +- It amortizes the cost of transforming rows or computing new columns across many transactions. Rows will only be added to the new table when they're needed. +- In many cases, old clients from before the update can coexist with new clients that use the new table. You can publish the updated module without disconnecting your clients, roll out the client update through normal channels, and allow your users to update at their own pace. + +For example, imagine we have a table `player` which stores information about our players: + + + +```rust +#[spacetimedb::table(name = character, public)] +pub struct Character { + #[primary_key] + player_id: Identity, + #[unique] + nickname: String, + level: u32, + class: Class, +} + +#[derive(SpacetimeType, Debug, Copy, Clone)] +pub enum Class { + Fighter, + Caster, + Medic, +} +``` + +We'll write a few helper functions and some simple reducers: + +```rust +#[spacetimedb::reducer] +fn create_character(ctx: &ReducerContext, class: Class, nickname: String) { + log::info!( + "Creating new level 1 {class:?} named {nickname}", + ); + ctx.db.character().insert(Character { + player_id: ctx.sender, + nickname, + level: 1, + class, + }); +} + +fn find_character_for_player(ctx: &ReducerContext) -> Character { + ctx.db + .character() + .player_id() + .find(ctx.sender) + .expect("Player has not created a character") +} + +fn update_character(ctx: &ReducerContext, character: Character) { + ctx.db.character().player_id().update(character); +} + +#[spacetimedb::reducer] +fn rename_character(ctx: &ReducerContext, new_name: String) { + let character = find_character_for_player(ctx); + log::info!( + "Renaming {} to {}", + character.nickname, + new_name, + ); + update_character( + ctx, + Character { + nickname: new_name, + ..character + }, + ); +} + +#[spacetimedb::reducer] +fn level_up_character(ctx: &ReducerContext) { + let character = find_character_for_player(ctx); + log::info!( + "Leveling up {} from {} to {}", + character.nickname, + character.level, + character.level + 1, + ); + update_character( + ctx, + Character { + level: character.level + 1, + ..character + }, + ); +} +``` + +We'll play around a bit with `spacetime call` to set up a character: + +```sh +$ spacetime logs incr-migration-demo -f & + +$ spacetime call incr-migration-demo create_character '{ "Fighter": {} }' "Phoebe" + +2025-01-07T15:32:57.447286Z INFO: src/lib.rs:21: Creating new level 1 Fighter named Phoebe + +$ spacetime call -s local incr-migration-demo rename_character "Gefjon" + +2025-01-07T15:33:48.966134Z INFO: src/lib.rs:48: Renaming Phoebe to Gefjon + +$ spacetime call -s local incr-migration-demo level_up_character + +2025-01-07T15:34:01.437495Z INFO: src/lib.rs:66: Leveling up Gefjon from 1 to 2 + +$ spacetime sql incr-migration-demo 'SELECT * FROM character' + + player_id | nickname | level | class +-----------+----------+-------+---------------- + | "Gefjon" | 2 | (Fighter = ()) +``` + +See [the SATS JSON reference](/docs/sats-json) for more on the encoding of arguments to `spacetime call`. + +Now we want to add a new feature: each player should be able to align themselves with the forces of good or evil, so we can get some healthy competition going between our players. We'll start each character off with `Alliance::Neutral`, and then offer them a reducer `choose_alliance` to set it to either `Alliance::Good` or `Alliance::Evil`. Our first attempt will be to add a new column to the type `Character`: + +```rust +#[spacetimedb::table(name = character, public)] +struct Character { + #[primary_key] + player_id: Identity, + nickname: String, + level: u32, + class: Class, + alliance: Alliance, +} + +#[derive(SpacetimeType, Debug, Copy, Clone)] +enum Alliance { + Good, + Neutral, + Evil, +} + +#[spacetimedb::reducer] +fn choose_alliance(ctx: &ReducerContext, alliance: Alliance) { + let character = find_character_for_player(ctx); + log::info!( + "Setting {}'s alliance to {:?} for player {}", + character.nickname, + alliance, + ctx.sender, + ); + update_character( + ctx, + Character { + alliance, + ..character + }, + ); +} +``` + +But that will fail, since SpacetimeDB doesn't know how to update our existing `character` rows with the new column: + +``` +Error: Database update rejected: Errors occurred: +Adding a column alliance to table character requires a manual migration +``` + +Instead, we'll add a new table, `character_v2`, which will coexist with our original `character` table: + +```rust +#[spacetimedb::table(name = character_v2, public)] +struct CharacterV2 { + #[primary_key] + player_id: Identity, + nickname: String, + level: u32, + class: Class, + alliance: Alliance, +} +``` + +When a new player creates a character, we'll make rows in both tables for them. This way, any old clients that are still subscribing to the original `character` table will continue to work, though of course they won't know about the character's alliance. + +```rust +#[spacetimedb::reducer] +fn create_character(ctx: &ReducerContext, class: Class, nickname: String) { + log::info!( + "Creating new level 1 {class:?} named {nickname} for player {}", + ctx.sender, + ); + + ctx.db.character().insert(Character { + player_id: ctx.sender, + nickname: nickname.clone(), + level: 1, + class, + }); + + ctx.db.character_v2().insert(CharacterV2 { + player_id: ctx.sender, + nickname, + level: 1, + class, + alliance: Alliance::Neutral, + }); +} +``` + +We'll update our helper functions so that they operate on `character_v2` rows. In `find_character_for_player`, if we don't see the player's row in `character_v2`, we'll migrate it from `character` on the fly. In this case, we'll make the player neutral, since they haven't chosen an alliance yet. + +```rust +fn find_character_for_player(ctx: &ReducerContext) -> CharacterV2 { + if let Some(character) = ctx.db.character_v2().player_id().find(ctx.sender) { + // Already migrated; just return the new player. + return character; + } + + // Not yet migrated; look up an old character and update it. + let old_character = ctx + .db + .character() + .player_id() + .find(ctx.sender) + .expect("Player has not created a character"); + + ctx.db.character_v2().insert(CharacterV2 { + player_id: old_character.player_id, + nickname: old_character.nickname, + level: old_character.level, + class: old_character.class, + alliance: Alliance::Neutral, + }) +} +``` + +Just like when creating a new character, when we update a `character_v2` row, we'll also update the old `character` row, so that outdated clients can continue to function. It's very important that we perform the same translation between `character` and `character_v2` rows here as in `create_character` and `find_character_for_player`. + +```rust +fn update_character(ctx: &ReducerContext, character: CharacterV2) { + ctx.db.character().player_id().update(Character { + player_id: character.player_id, + nickname: character.nickname.clone(), + level: character.level, + class: character.class, + }); + ctx.db.character_v2().player_id().update(character); +} +``` + +Then we can make trivial modifications to the callers of `update_character` so that they pass in `CharacterV2` instances: + +```rust +#[spacetimedb::reducer] +fn rename_character(ctx: &ReducerContext, new_name: String) { + let character = find_character_for_player(ctx); + log::info!( + "Renaming {} to {}", + character.nickname, + new_name, + ); + update_character( + ctx, + CharacterV2 { + nickname: new_name, + ..character + }, + ); +} + +#[spacetimedb::reducer] +fn level_up_character(ctx: &ReducerContext) { + let character = find_character_for_player(ctx); + log::info!( + "Leveling up {} from {} to {}", + character.nickname, + character.level, + character.level + 1, + ); + update_character( + ctx, + CharacterV2 { + level: character.level + 1, + ..character + }, + ); +} +``` + +And finally, we can define our new `choose_alliance` reducer: + +```rust +#[spacetimedb::reducer] +fn choose_alliance(ctx: &ReducerContext, alliance: Alliance) { + let character = find_character_for_player(ctx); + log::info!( + "Setting alliance of {} to {:?}", + character.nickname, + alliance, + ); + update_character( + ctx, + CharacterV2 { + alliance, + ..character + }, + ); +} +``` + +A bit more playing around with the CLI will show us that everything works as intended: + +```sh +# Our row in `character` still exists: +$ spacetime sql incr-migration-demo 'SELECT * FROM character' + + player_id | nickname | level | class +-----------+----------+-------+---------------- + | "Gefjon" | 2 | (Fighter = ()) + +# We haven't triggered the "Gefjon" row to migrate yet, so `character_v2` is empty: +$ spacetime sql -s local incr-migration-demo 'SELECT * FROM character_v2' + + player_id | nickname | level | class | alliance +-----------+----------+-------+-------+---------- + +# Accessing our character, e.g. by leveling up, will cause it to migrate into `character_v2`: +$ spacetime call incr-migration-demo level_up_character + +2025-01-07T16:00:20.500600Z INFO: src/lib.rs:110: Leveling up Gefjon from 2 to 3 + +# Now `character_v2` is populated: +$ spacetime sql incr-migration-demo 'SELECT * FROM character_v2' + + player_id | nickname | level | class | alliance +-----------+----------+-------+----------------+---------------- + | "Gefjon" | 3 | (Fighter = ()) | (Neutral = ()) + +# The original row in `character` still got updated by `level_up_character`, +# so outdated clients can continue to function: +$ spacetime sql incr-migration-demo 'SELECT * FROM character' + + player_id | nickname | level | class +-----------+----------+-------+---------------- + | "Gefjon" | 3 | (Fighter = ()) + +# We can set our alliance: +$ spacetime call incr-migration-demo choose_alliance '{ "Good": {} }' + +2025-01-07T16:13:53.816501Z INFO: src/lib.rs:129: Setting alliance of Gefjon to Good + +# And that change shows up in `character_v2`: +$ spacetime sql incr-migration-demo 'SELECT * FROM character_v2' + + player_id | nickname | level | class | alliance +-----------+----------+-------+----------------+------------- + | "Gefjon" | 3 | (Fighter = ()) | (Good = ()) + +# But `character` is not changed, since it doesn't know about alliances: +$ spacetime sql incr-migration-demo 'SELECT * FROM character' + + player_id | nickname | level | class +-----------+----------+-------+---------------- + | "Gefjon" | 3 | (Fighter = ()) +``` + +Now that we know how to define incremental migrations, we can add new features that would seem to require breaking schema changes without cumbersome external migration tools and while maintaining compatibility of outdated clients! The complete for this tutorial is on GitHub in the `clockworklabs/incr-migration-demo` repository, in branches [`v1`](https://github.com/clockworklabs/incr-migration-demo/tree/v1), [`fails-publish`](https://github.com/clockworklabs/incr-migration-demo/tree/fails-publish) and [`v2`](https://github.com/clockworklabs/incr-migration-demo/tree/v2). diff --git a/docs/http/database.md b/docs/http/database.md index b23701e8..8a73759c 100644 --- a/docs/http/database.md +++ b/docs/http/database.md @@ -1,497 +1,406 @@ -# `/database` HTTP API +# `/v1/database` HTTP API -The HTTP endpoints in `/database` allow clients to interact with Spacetime databases in a variety of ways, including retrieving information, creating and deleting databases, invoking reducers and evaluating SQL queries. +The HTTP endpoints in `/v1/database` allow clients to interact with Spacetime databases in a variety of ways, including retrieving information, creating and deleting databases, invoking reducers and evaluating SQL queries. ## At a glance -| Route | Description | -| ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | -| [`/database/dns/:name GET`](#databasednsname-get) | Look up a database's address by its name. | -| [`/database/reverse_dns/:address GET`](#databasereverse_dnsaddress-get) | Look up a database's name by its address. | -| [`/database/set_name GET`](#databaseset_name-get) | Set a database's name, given its address. | -| [`/database/ping GET`](#databaseping-get) | No-op. Used to determine whether a client can connect. | -| [`/database/register_tld GET`](#databaseregister_tld-get) | Register a top-level domain. | -| [`/database/publish POST`](#databasepublish-post) | Publish a database given its module code. | -| [`/database/delete/:address POST`](#databasedeleteaddress-post) | Delete a database. | -| [`/database/subscribe/:name_or_address GET`](#databasesubscribename_or_address-get) | Begin a [WebSocket connection](/docs/ws). | -| [`/database/call/:name_or_address/:reducer POST`](#databasecallname_or_addressreducer-post) | Invoke a reducer in a database. | -| [`/database/schema/:name_or_address GET`](#databaseschemaname_or_address-get) | Get the schema for a database. | -| [`/database/schema/:name_or_address/:entity_type/:entity GET`](#databaseschemaname_or_addressentity_typeentity-get) | Get a schema for a particular table or reducer. | -| [`/database/info/:name_or_address GET`](#databaseinfoname_or_address-get) | Get a JSON description of a database. | -| [`/database/logs/:name_or_address GET`](#databaselogsname_or_address-get) | Retrieve logs from a database. | -| [`/database/sql/:name_or_address POST`](#databasesqlname_or_address-post) | Run a SQL query against a database. | - -## `/database/dns/:name GET` - -Look up a database's address by its name. +| Route | Description | +| -------------------------------------------------------------------------------------------------- | ------------------------------------------------- | +| [`POST /v1/database`](#post-v1database) | Publish a new database given its module code. | +| [`POST /v1/database/:name_or_identity`](#post-v1databasename_or_identity) | Publish to a database given its module code. | +| [`GET /v1/database/:name_or_identity`](#get-v1databasename_or_identity) | Get a JSON description of a database. | +| [`DELETE /v1/database/:name_or_identity`](#post-v1databasename_or_identity) | Delete a database. | +| [`GET /v1/database/:name_or_identity/names`](#get-v1databasename_or_identitynames) | Get the names this database can be identified by. | +| [`POST /v1/database/:name_or_identity/names`](#post-v1databasename_or_identitynames) | Add a new name for this database. | +| [`PUT /v1/database/:name_or_identity/names`](#put-v1databasename_or_identitynames) | Set the list of names for this database. | +| [`GET /v1/database/:name_or_identity/identity`](#get-v1databasename_or_identityidentity) | Get the identity of a database. | +| [`GET /v1/database/:name_or_identity/subscribe`](#get-v1databasename_or_identitysubscribe) | Begin a WebSocket connection. | +| [`POST /v1/database/:name_or_identity/call/:reducer`](#post-v1databasename_or_identitycallreducer) | Invoke a reducer in a database. | +| [`GET /v1/database/:name_or_identity/schema`](#get-v1databasename_or_identityschema) | Get the schema for a database. | +| [`GET /v1/database/:name_or_identity/logs`](#get-v1databasename_or_identitylogs) | Retrieve logs from a database. | +| [`POST /v1/database/:name_or_identity/sql`](#post-v1databasename_or_identitysql) | Run a SQL query against a database. | + +## `POST /v1/database` + +Publish a new database with no name. -Accessible through the CLI as `spacetime dns lookup `. - -#### Parameters - -| Name | Value | -| ------- | ------------------------- | -| `:name` | The name of the database. | - -#### Returns - -If a database with that name exists, returns JSON in the form: - -```typescript -{ "Success": { - "domain": string, - "address": string -} } -``` - -If no database with that name exists, returns JSON in the form: - -```typescript -{ "Failure": { - "domain": string -} } -``` - -## `/database/reverse_dns/:address GET` +Accessible through the CLI as `spacetime publish`. -Look up a database's name by its address. +#### Required Headers -Accessible through the CLI as `spacetime dns reverse-lookup
`. +| Name | Value | +| --------------- | --------------------------------------------------------------------- | +| `Authorization` | A Spacetime token [as Bearer auth](/docs/http#authorization-headers). | -#### Parameters +#### Data -| Name | Value | -| ---------- | ---------------------------- | -| `:address` | The address of the database. | +A WebAssembly module in the [binary format](https://webassembly.github.io/spec/core/binary/index.html). #### Returns -Returns JSON in the form: +If the database was successfully published, returns JSON in the form: ```typescript -{ "names": array } +{ "Success": { + "database_identity": string, + "op": "created" | "updated" +} } ``` -where `` is a JSON array of strings, each of which is a name which refers to the database. - -## `/database/set_name GET` +## `POST /v1/database/:name_or_identity` -Set the name associated with a database. +Publish to a database with the specified name or identity. If the name doesn't exist, creates a new database. -Accessible through the CLI as `spacetime dns set-name
`. +Accessible through the CLI as `spacetime publish`. #### Query Parameters -| Name | Value | -| -------------- | ------------------------------------------------------------------------- | -| `address` | The address of the database to be named. | -| `domain` | The name to register. | -| `register_tld` | A boolean; whether to register the name as a TLD. Should usually be true. | +| Name | Value | +| ------- | --------------------------------------------------------------------------------- | +| `clear` | A boolean; whether to clear any existing data when updating an existing database. | #### Required Headers -| Name | Value | -| --------------- | --------------------------------------------------------------- | -| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http). | +| Name | Value | +| --------------- | --------------------------------------------------------------------- | +| `Authorization` | A Spacetime token [as Bearer auth](/docs/http#authorization-headers). | -#### Returns +#### Data -If the name was successfully set, returns JSON in the form: +A WebAssembly module in the [binary format](https://webassembly.github.io/spec/core/binary/index.html). -```typescript -{ "Success": { - "domain": string, - "address": string -} } -``` +#### Returns -If the top-level domain is not registered, and `register_tld` was not specified, returns JSON in the form: +If the database was successfully published, returns JSON in the form: ```typescript -{ "TldNotRegistered": { - "domain": string +{ "Success": { + "domain": null | string, + "database_identity": string, + "op": "created" | "updated" } } ``` -If the top-level domain is registered, but the identity provided in the `Authorization` header does not have permission to insert into it, returns JSON in the form: +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": { - "domain": string + "name": string } } ``` -> Spacetime top-level domains are an upcoming feature, and are not fully implemented in SpacetimeDB 0.6. For now, database names should not contain slashes. +## `GET /v1/database/:name_or_identity` -## `/database/ping GET` +Get a database's identity, owner identity, host type, number of replicas and a hash of its WASM module. -Does nothing and returns no data. Clients can send requests to this endpoint to determine whether they are able to connect to SpacetimeDB. +#### Returns -## `/database/register_tld GET` +Returns JSON in the form: -Register a new Spacetime top-level domain. A TLD is the part of a database name before the first `/`. For example, in the name `tyler/bitcraft`, the TLD is `tyler`. Each top-level domain is owned by at most one identity, and only the owner can publish databases with that TLD. +```typescript +{ + "database_identity": string, + "owner_identity": string, + "host_type": "wasm", + "initial_program": string +} +``` -> Spacetime top-level domains are an upcoming feature, and are not fully implemented in SpacetimeDB 0.6. For now, database names should not contain slashes. +| Field | Type | Meaning | +| --------------------- | ------ | ---------------------------------------------------------------- | +| `"database_identity"` | String | The Spacetime identity of the database. | +| `"owner_identity"` | String | The Spacetime identity of the database's owner. | +| `"host_type"` | String | The module host type; currently always `"wasm"`. | +| `"initial_program"` | String | Hash of the WASM module with which the database was initialized. | -Accessible through the CLI as `spacetime dns register-tld `. +## `DELETE /v1/database/:name_or_identity` -#### Query Parameters +Delete a database. -| Name | Value | -| ----- | -------------------------------------- | -| `tld` | New top-level domain name to register. | +Accessible through the CLI as `spacetime delete `. #### Required Headers -| Name | Value | -| --------------- | --------------------------------------------------------------- | -| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http). | - -#### Returns +| Name | Value | +| --------------- | --------------------------------------------------------------------- | +| `Authorization` | A Spacetime token [as Bearer auth](/docs/http#authorization-headers). | -If the domain is successfully registered, returns JSON in the form: +## `GET /v1/database/:name_or_identity/names` -```typescript -{ "Success": { - "domain": string -} } -``` +Get the names this datbase can be identified by. -If the domain is already registered to the caller, returns JSON in the form: +Accessible through the CLI as `spacetime dns reverse-lookup `. -```typescript -{ "AlreadyRegistered": { - "domain": string -} } -``` +#### Returns -If the domain is already registered to another identity, returns JSON in the form: +Returns JSON in the form: ```typescript -{ "Unauthorized": { - "domain": string -} } +{ "names": array } ``` -## `/database/publish POST` - -Publish a database. - -Accessible through the CLI as `spacetime publish`. +where `` is a JSON array of strings, each of which is a name which refers to the database. -#### Query Parameters +## `POST /v1/database/:name_or_identity/names` -| Name | Value | -| ----------------- | ------------------------------------------------------------------------------------------------ | -| `host_type` | Optional; a SpacetimeDB module host type. Currently, only `"wasmer"` is supported. | -| `clear` | A boolean; whether to clear any existing data when updating an existing database. | -| `name_or_address` | The name of the database to publish or update, or the address of an existing database to update. | -| `register_tld` | A boolean; whether to register the database's top-level domain. | +Add a new name for this database. #### Required Headers -| Name | Value | -| --------------- | --------------------------------------------------------------- | -| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http). | +| Name | Value | +| --------------- | --------------------------------------------------------------------- | +| `Authorization` | A Spacetime token [as Bearer auth](/docs/http#authorization-headers). | #### Data -A WebAssembly module in the [binary format](https://webassembly.github.io/spec/core/binary/index.html). +Takes as the request body a string containing the new name of the database. #### Returns -If the database was successfully published, returns JSON in the form: +If the name was successfully set, returns JSON in the form: ```typescript { "Success": { - "domain": null | string, - "address": string, - "op": "created" | "updated" + "domain": string, + "database_result": string } } ``` -If the top-level domain for the requested name is not registered, returns JSON in the form: +If the new name already exists but the identity provided in the `Authorization` header does not have permission to edit it, returns JSON in the form: ```typescript -{ "TldNotRegistered": { +{ "PermissionDenied": { "domain": string } } ``` -If the top-level domain for the requested name is registered, but the identity provided in the `Authorization` header does not have permission to insert into it, returns JSON in the form: +## `PUT /v1/database/:name_or_identity/names` -```typescript -{ "PermissionDenied": { - "domain": string -} } -``` +Set the list of names for this database. + +#### Required Headers -> Spacetime top-level domains are an upcoming feature, and are not fully implemented in SpacetimeDB 0.6. For now, database names should not contain slashes. +| Name | Value | +| --------------- | --------------------------------------------------------------------- | +| `Authorization` | A Spacetime token [as Bearer auth](/docs/http#authorization-headers). | -## `/database/delete/:address POST` +#### Data -Delete a database. +Takes as the request body a list of names, as a JSON array of strings. -Accessible through the CLI as `spacetime delete
`. +#### Returns -#### Parameters +If the name was successfully set, returns JSON in the form: -| Name | Address | -| ---------- | ---------------------------- | -| `:address` | The address of the database. | +```typescript +{ "Success": null } +``` -#### Required Headers +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 } +``` -| Name | Value | -| --------------- | --------------------------------------------------------------- | -| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http). | +## `GET /v1/database/:name_or_identity/identity` -## `/database/subscribe/:name_or_address GET` +Get the identity of a database. -Begin a [WebSocket connection](/docs/ws) with a database. +Accessible through the CLI as `spacetime dns lookup `. -#### Parameters +#### Returns -| Name | Value | -| ------------------ | ---------------------------- | -| `:name_or_address` | The address of the database. | +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.bin.spacetimedb`](/docs/ws#binary-protocol) or [`v1.text.spacetimedb`](/docs/ws#text-protocol). | -| `Connection` | `Updgrade` | -| `Upgrade` | `websocket` | -| `Sec-WebSocket-Version` | `13` | -| `Sec-WebSocket-Key` | A 16-byte value, generated randomly by the client, encoded as Base64. | +| 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 [encoded as Basic authorization](/docs/http). | +| Name | Value | +| --------------- | --------------------------------------------------------------------- | +| `Authorization` | A Spacetime token [as Bearer auth](/docs/http#authorization-headers). | -## `/database/call/:name_or_address/:reducer POST` +## `POST /v1/database/:name_or_identity/call/:reducer` Invoke a reducer in a database. -#### Parameters +#### Path parameters -| Name | Value | -| ------------------ | ------------------------------------ | -| `:name_or_address` | The name or address of the database. | -| `:reducer` | The name of the reducer. | +| Name | Value | +| ---------- | ------------------------ | +| `:reducer` | The name of the reducer. | #### Required Headers -| Name | Value | -| --------------- | --------------------------------------------------------------- | -| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http). | +| Name | Value | +| --------------- | --------------------------------------------------------------------- | +| `Authorization` | A Spacetime token [as Bearer auth](/docs/http#authorization-headers). | #### Data A JSON array of arguments to the reducer. -## `/database/schema/:name_or_address GET` +## `GET /v1/database/:name_or_identity/schema` Get a schema for a database. -Accessible through the CLI as `spacetime describe `. - -#### Parameters - -| Name | Value | -| ------------------ | ------------------------------------ | -| `:name_or_address` | The name or address of the database. | +Accessible through the CLI as `spacetime describe `. #### Query Parameters -| Name | Value | -| -------- | ----------------------------------------------------------- | -| `expand` | A boolean; whether to include full schemas for each entity. | +| Name | Value | +| --------- | ------------------------------------------------ | +| `version` | The version of `RawModuleDef` to return, e.g. 9. | #### Returns -Returns a JSON object with two properties, `"entities"` and `"typespace"`. For example, on the default module generated by `spacetime init` with `expand=true`, returns: +Returns a `RawModuleDef` in JSON form. -```typescript +
+Example response from `/schema?version=9` for the default module generated by `spacetime init` + +```json { - "entities": { - "Person": { - "arity": 1, - "schema": { - "elements": [ - { - "algebraic_type": { - "Builtin": { + "typespace": { + "types": [ + { + "Product": { + "elements": [ + { + "name": { + "some": "name" + }, + "algebraic_type": { "String": [] } - }, - "name": { - "some": "name" } - } - ] + ] + } + } + ] + }, + "tables": [ + { + "name": "person", + "product_type_ref": 0, + "primary_key": [], + "indexes": [], + "constraints": [], + "sequences": [], + "schedule": { + "none": [] }, - "type": "table" - }, - "__init__": { - "arity": 0, - "schema": { - "elements": [], - "name": "__init__" + "table_type": { + "User": [] }, - "type": "reducer" - }, - "add": { - "arity": 1, - "schema": { + "table_access": { + "Private": [] + } + } + ], + "reducers": [ + { + "name": "add", + "params": { "elements": [ { - "algebraic_type": { - "Builtin": { - "String": [] - } - }, "name": { "some": "name" + }, + "algebraic_type": { + "String": [] } } - ], - "name": "add" + ] }, - "type": "reducer" + "lifecycle": { + "none": [] + } }, - "say_hello": { - "arity": 0, - "schema": { - "elements": [], - "name": "say_hello" + { + "name": "identity_connected", + "params": { + "elements": [] }, - "type": "reducer" - } - }, - "typespace": [ + "lifecycle": { + "some": { + "OnConnect": [] + } + } + }, { - "Product": { - "elements": [ - { - "algebraic_type": { - "Builtin": { - "String": [] - } - }, - "name": { - "some": "name" - } - } - ] + "name": "identity_disconnected", + "params": { + "elements": [] + }, + "lifecycle": { + "some": { + "OnDisconnect": [] + } + } + }, + { + "name": "init", + "params": { + "elements": [] + }, + "lifecycle": { + "some": { + "Init": [] + } + } + }, + { + "name": "say_hello", + "params": { + "elements": [] + }, + "lifecycle": { + "none": [] } } - ] -} -``` - -The `"entities"` will be an object whose keys are table and reducer names, and whose values are objects of the form: - -```typescript -{ - "arity": number, - "type": "table" | "reducer", - "schema"?: ProductType -} -``` - -| Entity field | Value | -| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `arity` | For tables, the number of colums; for reducers, the number of arguments. | -| `type` | For tables, `"table"`; for reducers, `"reducer"`. | -| `schema` | A [JSON-encoded `ProductType`](/docs/satn); for tables, the table schema; for reducers, the argument schema. Only present if `expand` is supplied and true. | - -The `"typespace"` will be a JSON array of [`AlgebraicType`s](/docs/satn) referenced by the module. This can be used to resolve `Ref` types within the schema; the type `{ "Ref": n }` refers to `response["typespace"][n]`. - -## `/database/schema/:name_or_address/:entity_type/:entity GET` - -Get a schema for a particular table or reducer in a database. - -Accessible through the CLI as `spacetime describe `. - -#### Parameters - -| Name | Value | -| ------------------ | ---------------------------------------------------------------- | -| `:name_or_address` | The name or address of the database. | -| `:entity_type` | `reducer` to describe a reducer, or `table` to describe a table. | -| `:entity` | The name of the reducer or table. | - -#### Query Parameters - -| Name | Value | -| -------- | ------------------------------------------------------------- | -| `expand` | A boolean; whether to include the full schema for the entity. | - -#### Returns - -Returns a single entity in the same format as in the `"entities"` returned by [the `/database/schema/:name_or_address GET` endpoint](#databaseschemaname_or_address-get): - -```typescript -{ - "arity": number, - "type": "table" | "reducer", - "schema"?: ProductType, -} -``` - -| Field | Value | -| -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `arity` | For tables, the number of colums; for reducers, the number of arguments. | -| `type` | For tables, `"table"`; for reducers, `"reducer"`. | -| `schema` | A [JSON-encoded `ProductType`](/docs/satn); for tables, the table schema; for reducers, the argument schema. Only present if `expand` is supplied and true. | - -## `/database/info/:name_or_address GET` - -Get a database's address, owner identity, host type, number of replicas and a hash of its WASM module. - -#### Parameters - -| Name | Value | -| ------------------ | ------------------------------------ | -| `:name_or_address` | The name or address of the database. | - -#### Returns - -Returns JSON in the form: - -```typescript -{ - "address": string, - "owner_identity": string, - "host_type": "wasm", - "initial_program": string + ], + "types": [ + { + "name": { + "scope": [], + "name": "Person" + }, + "ty": 0, + "custom_ordering": true + } + ], + "misc_exports": [], + "row_level_security": [] } ``` -| Field | Type | Meaning | -| ------------------- | ------ | ---------------------------------------------------------------- | -| `"address"` | String | The address of the database. | -| `"owner_identity"` | String | The Spacetime identity of the database's owner. | -| `"host_type"` | String | The module host type; currently always `"wasm"`. | -| `"initial_program"` | String | Hash of the WASM module with which the database was initialized. | +
-## `/database/logs/:name_or_address GET` +## `GET /v1/database/:name_or_identity/logs` Retrieve logs from a database. -Accessible through the CLI as `spacetime logs `. - -#### Parameters - -| Name | Value | -| ------------------ | ------------------------------------ | -| `:name_or_address` | The name or address of the database. | +Accessible through the CLI as `spacetime logs `. #### Query Parameters @@ -502,31 +411,25 @@ Accessible through the CLI as `spacetime logs `. #### Required Headers -| Name | Value | -| --------------- | --------------------------------------------------------------- | -| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http). | +| Name | Value | +| --------------- | --------------------------------------------------------------------- | +| `Authorization` | A Spacetime token [as Bearer auth](/docs/http#authorization-headers). | #### Returns Text, or streaming text if `follow` is supplied, containing log lines. -## `/database/sql/:name_or_address POST` +## `POST /v1/database/:name_or_identity/sql` Run a SQL query against a database. -Accessible through the CLI as `spacetime sql `. - -#### Parameters - -| Name | Value | -| ------------------ | --------------------------------------------- | -| `:name_or_address` | The name or address of the database to query. | +Accessible through the CLI as `spacetime sql `. #### Required Headers -| Name | Value | -| --------------- | --------------------------------------------------------------- | -| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http). | +| Name | Value | +| --------------- | --------------------------------------------------------------------- | +| `Authorization` | A Spacetime token [as Bearer auth](/docs/http#authorization-headers). | #### Data @@ -543,6 +446,6 @@ Returns a JSON array of statement results, each of which takes the form: } ``` -The `schema` will be a [JSON-encoded `ProductType`](/docs/satn) describing the type of the returned rows. +The `schema` will be a [JSON-encoded `ProductType`](/docs/sats-json) describing the type of the returned rows. -The `rows` will be an array of [JSON-encoded `ProductValue`s](/docs/satn), each of which conforms to the `schema`. +The `rows` will be an array of [JSON-encoded `ProductValue`s](/docs/sats-json), each of which conforms to the `schema`. diff --git a/docs/http/energy.md b/docs/http/energy.md deleted file mode 100644 index 6f008314..00000000 --- a/docs/http/energy.md +++ /dev/null @@ -1,76 +0,0 @@ -# `/energy` HTTP API - -The HTTP endpoints in `/energy` allow clients to query identities' energy balances. Spacetime databases expend energy from their owners' balances while executing reducers. - -## At a glance - -| Route | Description | -| ------------------------------------------------ | --------------------------------------------------------- | -| [`/energy/:identity GET`](#energyidentity-get) | Get the remaining energy balance for the user `identity`. | -| [`/energy/:identity POST`](#energyidentity-post) | Set the energy balance for the user `identity`. | - -## `/energy/:identity GET` - -Get the energy balance of an identity. - -Accessible through the CLI as `spacetime energy status `. - -#### Parameters - -| Name | Value | -| ----------- | ----------------------- | -| `:identity` | The Spacetime identity. | - -#### Returns - -Returns JSON in the form: - -```typescript -{ - "balance": string -} -``` - -| Field | Value | -| --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `balance` | The identity's energy balance, as a decimal integer. Note that energy balances may be negative, and will frequently be too large to store in a 64-bit integer. | - -## `/energy/:identity POST` - -Set the energy balance for an identity. - -Note that in the SpacetimeDB 0.6 Testnet, this endpoint always returns code 401, `UNAUTHORIZED`. Testnet energy balances cannot be refilled. - -Accessible through the CLI as `spacetime energy set-balance `. - -#### Parameters - -| Name | Value | -| ----------- | ----------------------- | -| `:identity` | The Spacetime identity. | - -#### Query Parameters - -| Name | Value | -| --------- | ------------------------------------------ | -| `balance` | A decimal integer; the new balance to set. | - -#### Required Headers - -| Name | Value | -| --------------- | --------------------------------------------------------------- | -| `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http). | - -#### Returns - -Returns JSON in the form: - -```typescript -{ - "balance": number -} -``` - -| Field | Value | -| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `balance` | The identity's new energy balance, as a decimal integer. Note that energy balances may be negative, and will frequently be too large to store in a 64-bit integer. | diff --git a/docs/http/identity.md b/docs/http/identity.md index 6f1e22c9..3cec4eb9 100644 --- a/docs/http/identity.md +++ b/docs/http/identity.md @@ -1,59 +1,23 @@ -# `/identity` HTTP API +# `/v1/identity` HTTP API -The HTTP endpoints in `/identity` allow clients to generate and manage Spacetime public identities and private tokens. +The HTTP endpoints in `/v1/identity` allow clients to generate and manage Spacetime public identities and private tokens. ## At a glance -| Route | Description | -| ----------------------------------------------------------------------- | ------------------------------------------------------------------ | -| [`/identity GET`](#identity-get) | Look up an identity by email. | -| [`/identity POST`](#identity-post) | Generate a new identity and token. | -| [`/identity/websocket_token POST`](#identitywebsocket_token-post) | Generate a short-lived access token for use in untrusted contexts. | -| [`/identity/:identity/set-email POST`](#identityidentityset-email-post) | Set the email for an identity. | -| [`/identity/:identity/databases GET`](#identityidentitydatabases-get) | List databases owned by an identity. | -| [`/identity/:identity/verify GET`](#identityidentityverify-get) | Verify an identity and token. | +| Route | Description | +| -------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| [`POST /v1/identity`](#post-v1identity) | Generate a new identity and token. | +| [`POST /v1/identity/websocket-token`](#post-v1identitywebsocket-token) | Generate a short-lived access token for use in untrusted contexts. | +| [`GET /v1/identity/public-key`](#get-v1identitypublic-key) | Get the public key used for verifying tokens. | +| [`GET /v1/identity/:identity/databases`](#get-v1identityidentitydatabases) | List databases owned by an identity. | +| [`GET /v1/identity/:identity/verify`](#get-v1identityidentityverify) | Verify an identity and token. | -## `/identity GET` - -Look up Spacetime identities associated with an email. - -Accessible through the CLI as `spacetime identity find `. - -#### Query Parameters - -| Name | Value | -| ------- | ------------------------------- | -| `email` | An email address to search for. | - -#### Returns - -Returns JSON in the form: - -```typescript -{ - "identities": [ - { - "identity": string, - "email": string - } - ] -} -``` - -The `identities` value is an array of zero or more objects, each of which has an `identity` and an `email`. Each `email` will be the same as the email passed as a query parameter. - -## `/identity POST` +## `POST /v1/identity` Create a new identity. Accessible through the CLI as `spacetime identity new`. -#### Query Parameters - -| Name | Value | -| ------- | ----------------------------------------------------------------------------------------------------------------------- | -| `email` | An email address to associate with the new identity. If unsupplied, the new identity will not have an associated email. | - #### Returns Returns JSON in the form: @@ -65,7 +29,7 @@ Returns JSON in the form: } ``` -## `/identity/websocket_token POST` +## `POST /v1/identity/websocket-token` Generate a short-lived access token which can be used in untrusted contexts, e.g. embedded in URLs. @@ -87,7 +51,15 @@ Returns JSON in the form: The `token` value is a short-lived [JSON Web Token](https://datatracker.ietf.org/doc/html/rfc7519). -## `/identity/:identity/set-email POST` +## `GET /v1/identity/public-key` + +Fetches the public key used by the database to verify tokens. + +#### Returns + +Returns a response of content-type `application/pem-certificate-chain`. + +## `POST /v1/identity/:identity/set-email` Associate an email with a Spacetime identity. @@ -111,7 +83,7 @@ Accessible through the CLI as `spacetime identity set-email `. | --------------- | --------------------------------------------------------------- | | `Authorization` | A Spacetime token [encoded as Basic authorization](/docs/http). | -## `/identity/:identity/databases GET` +## `GET /v1/identity/:identity/databases` List all databases owned by an identity. @@ -133,7 +105,7 @@ Returns JSON in the form: The `addresses` value is an array of zero or more strings, each of which is the address of a database owned by the identity passed as a parameter. -## `/identity/:identity/verify GET` +## `GET /v1/identity/:identity/verify` Verify the validity of an identity/token pair. diff --git a/docs/http/index.md b/docs/http/index.md index a59408e4..4f0973dc 100644 --- a/docs/http/index.md +++ b/docs/http/index.md @@ -1,42 +1,23 @@ # SpacetimeDB HTTP Authorization -Rather than a password, each Spacetime identity is associated with a private token. These tokens are generated by SpacetimeDB when the corresponding identity is created, and cannot be changed. - -> Do not share your SpacetimeDB token with anyone, ever. - ### Generating identities and tokens -Clients can request a new identity and token via [the `/identity POST` HTTP endpoint](/docs/http/identity#identity-post). - -Alternately, a new identity and token will be generated during an anonymous connection via the [WebSocket API](/docs/ws), and passed to the client as [an `IdentityToken` message](/docs/ws#identitytoken). +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/). -### Encoding `Authorization` headers +Clients can request a new identity and token signed by the SpacetimeDB host via [the `POST /v1/identity` HTTP endpoint](/docs/http/identity#post-v1identity). Such a token will not be portable to other SpacetimeDB clusters. -Many SpacetimeDB HTTP endpoints either require or optionally accept a token in the `Authorization` header. SpacetimeDB authorization headers use `Basic` authorization with the username `token` and the token as the password. Because Spacetime tokens are not passwords, and SpacetimeDB Cloud uses TLS, usual security concerns about HTTP `Basic` authorization do not apply. +Alternately, a new identity and token will be generated during an anonymous connection via the WebSocket API, and passed to the client as an `IdentityToken` message. -To construct an appropriate `Authorization` header value for a `token`: +### `Authorization` headers -1. Prepend the string `token:`. -2. Base64-encode. -3. Prepend the string `Basic `. +Many SpacetimeDB HTTP endpoints either require or optionally accept a token in the `Authorization` header. SpacetimeDB authorization headers are of the form `Authorization: Bearer ${token}`, where `token` is an [OpenID Connect](https://openid.net/developers/how-connect-works/) compliant [JSON Web Token](https://jwt.io/), such as the one returned from [the `POST /v1/identity` HTTP endpoint](/docs/http/identity#post-v1identity). -#### Rust +# Top level routes -```rust -fn auth_header_value(token: &str) -> String { - let username_and_password = format!("token:{}", token); - let base64_encoded = base64::prelude::BASE64_STANDARD.encode(username_and_password); - format!("Basic {}", encoded) -} -``` +| Route | Description | +| ----------------------------- | ------------------------------------------------------ | +| [`GET /v1/ping`](#get-v1ping) | No-op. Used to determine whether a client can connect. | -#### C# +## `GET /v1/ping` -```csharp -public string AuthHeaderValue(string token) -{ - var username_and_password = Encoding.UTF8.GetBytes($"token:{auth}"); - var base64_encoded = Convert.ToBase64String(username_and_password); - return "Basic " + base64_encoded; -} -``` +Does nothing and returns no data. Clients can send requests to this endpoint to determine whether they are able to connect to SpacetimeDB. diff --git a/docs/index.md b/docs/index.md index 974b543f..6e4a0b65 100644 --- a/docs/index.md +++ b/docs/index.md @@ -46,7 +46,7 @@ You write SQL queries specifying what information a client is interested in -- f ### Module Libraries -Every SpacetimeDB database contains a collection of stored procedures called a **module**. Modules can be written in C# or Rust. They specify a database schema and the business logic that responds to client requests. Modules are administered using the `spacetime` CLI tool. +Every SpacetimeDB database contains a collection of [stored procedures](https://en.wikipedia.org/wiki/Stored_procedure) and schema definitions. Such a collection is called a **module**, which can be written in C# or Rust. They specify a database schema and the business logic that responds to client requests. Modules are administered using the `spacetime` CLI tool. - [Rust](/docs/modules/rust) - [(Quickstart)](/docs/modules/rust/quickstart) - [C#](/docs/modules/c-sharp) - [(Quickstart)](/docs/modules/c-sharp/quickstart) @@ -111,6 +111,27 @@ Tables marked `public` can also be read by [clients](#client). A **reducer** is a function exported by a [database](#database). Connected [clients](#client-side-sdks) can call reducers to interact with the database. This is a form of [remote procedure call](https://en.wikipedia.org/wiki/Remote_procedure_call). + +:::server-rust +A reducer can be written in Rust like so: + +```rust +#[spacetimedb::reducer] +pub fn set_player_name(ctx: &spacetimedb::ReducerContext, id: u64, name: String) -> Result<(), String> { + // ... +} +``` + +And a Rust [client](#client) can call that reducer: + +```rust +fn main() { + // ...setup code, then... + ctx.reducers.set_player_name(57, "Marceline".into()); +} +``` +::: +:::server-csharp A reducer can be written in C# like so: ```csharp @@ -120,14 +141,6 @@ public static void SetPlayerName(ReducerContext ctx, uint playerId, string name) // ... } ``` - And a C# [client](#client) can call that reducer: @@ -137,13 +150,78 @@ void Main() { Connection.Reducer.SetPlayerName(57, "Marceline"); } ``` +::: + +These look mostly like regular function calls, but under the hood, +the client sends a request over the internet, which the database processes and responds to. + +The `ReducerContext` is a reducer's only mandatory parameter +and includes information about the caller's [identity](#identity). +This can be used to authenticate the caller. + +Reducers are run in their own separate and atomic [database transactions](https://en.wikipedia.org/wiki/Database_transaction). +When a reducer completes successfully, the changes the reducer has made, +such as inserting a table row, are *committed* to the database. +However, if the reducer instead returns an error, or throws an exception, +the database will instead reject the request and *revert* all those changes. +That is, reducers and transactions are all-or-nothing requests. +It's not possible to keep the first half of a reducer's changes and discard the last. + +Transactions are only started by requests from outside the database. +When a reducer calls another reducer directly, as in the example below, +the changes in the called reducer does not happen in its own child transaction. +Instead, when the nested reducer gracefully errors, +and the overall reducer completes successfully, +the changes in the nested one are still persisted. + +:::server-rust +```rust +#[spacetimedb::reducer] +pub fn hello(ctx: &spacetimedb::ReducerContext) -> Result<(), String> { + if world(ctx).is_err() { + other_changes(ctx); + } +} -These look mostly like regular function calls, but under the hood, the client sends a request over the internet, which the database processes and responds to. +#[spacetimedb::reducer] +pub fn world(ctx: &spacetimedb::ReducerContext) -> Result<(), String> { + clear_all_tables(ctx); +} +``` +::: +:::server-csharp +```csharp +[SpacetimeDB.Reducer] +public static void Hello(ReducerContext ctx) +{ + if(!World(ctx)) + { + OtherChanges(ctx); + } +} -The `ReducerContext` passed into a reducer includes information about the caller's [identity](#identity) and [address](#address). The database can reject any request it doesn't approve of. +[SpacetimeDB.Reducer] +public static void World(ReducerContext ctx) +{ + ClearAllTables(ctx); + // ... +} +``` +::: + +:::server-rust +While SpacetimeDB doesn't support nested transactions, +a reducer can [schedule another reducer](https://docs.rs/spacetimedb/latest/spacetimedb/attr.reducer.html#scheduled-reducers) to run at an interval, +or at a specific time. +::: +:::server-csharp +While SpacetimeDB doesn't support nested transactions, +a reducer can [schedule another reducer](/docs/modules/c-sharp#scheduled-reducers) to run at an interval, +or at a specific time. +::: ### Client -A **client** is an application that connects to a [database](#database). A client logs in using an [identity](#identity) and receives an [address](#address) to identify the connection. After that, it can call [reducers](#reducer) and query public [tables](#table). +A **client** is an application that connects to a [database](#database). A client logs in using an [identity](#identity) and receives an [connection id](#connectionid) to identify the connection. After that, it can call [reducers](#reducer) and query public [tables](#table). Clients are written using the [client-side SDKs](#client-side-sdks). The `spacetime` CLI tool allows automatically generating code that works with the client-side SDKs to talk to a particular database. @@ -159,15 +237,33 @@ Modules themselves also have Identities. When you `spacetime publish` a module, Identities are issued using the [OpenID Connect](https://openid.net/developers/how-connect-works/) specification. Database developers are responsible for issuing Identities to their end users. OpenID Connect lets users log in to these accounts through standard services like Google and Facebook. - +Specifically, an identity is derived from the issuer and subject fields of a [JSON Web Token (JWT)](https://jwt.io/) hashed together. The psuedocode for this is as follows: + +```python +def identity_from_claims(issuer: str, subject: str) -> [u8; 32]: + hash1: [u8; 32] = blake3_hash(issuer + "|" + subject) + id_hash: [u8; 26] = hash1[:26] + checksum_hash: [u8; 32] = blake3_hash([ + 0xC2, + 0x00, + *id_hash + ]) + identity_big_endian_bytes: [u8; 32] = [ + 0xC2, + 0x00, + *checksum_hash[:4], + *id_hash + ] + return identity_big_endian_bytes +``` -### Address + - +### ConnectionId -An `Address` identifies client connections to a SpacetimeDB module. +A `ConnectionId` identifies client connections to a SpacetimeDB module. -A user has a single [`Identity`](#identity), but may open multiple connections to your module. Each of these will receive a unique `Address`. +A user has a single [`Identity`](#identity), but may open multiple connections to your module. Each of these will receive a unique `ConnectionId`. ### Energy **Energy** is the currency used to pay for data storage and compute operations in a SpacetimeDB host. diff --git a/docs/migration/v0.12.md b/docs/migration/v0.12.md deleted file mode 100644 index 9384407f..00000000 --- a/docs/migration/v0.12.md +++ /dev/null @@ -1,341 +0,0 @@ -# Updating your app for SpacetimeDB v0.12 - -We're excited to release SpacetimeDB v0.12, which includes a major overhaul of our Rust, C# and TypeScript APIs for both modules and clients. In no particular order, our goals with this rewrite were: - -- Our APIs should be as similar as possible in all three languages we support, and in clients and modules, so that you don't have to go to a ton of work figuring out why something works in one place but not somewhere else. -- We should be very explicit about what operations interact with the database and how. In addition to good hygiene, this means that a client can now connect to multiple remote modules at the same time without getting confused. (Some day a module will be able to connect to remote modules too, but we're not there yet.) -- Our APIs should expose low level database operations so you can program your applications to have predictable performance characteristics. An indexed lookup should look different in your code from a full scan, and writing the indexed lookup should be easier. This will help you write your apps as efficiently as possible as we add features to SpacetimeDB. (In the future, as we get more sophisticated at optimizing and evaluating queries, we will offer a higher level logical query API which let's us implement very high performance optimizations and abstract away concerns like indices.) - -The new APIs are a significant improvement to the developer experience of SpacetimeDB and enable some amazing features in the future. They're completely new APIs, so if you run into any trouble, please [ask us for help or share your feedback on Discord!](https://discord.gg/spacetimedb) - -To start migrating, update your SpacetimeDB CLI, and bump the `spacetimedb` and `spacetimedb-sdk` dependency versions to 0.12 in your module and client respectively. - -## Modules - -### The reducer context - -All your reducers must now accept a reducer context as their first argument. In Rust, this is now taken by reference, as `&ReducerContext`. All access to tables now go through methods on the `db` or `Db` field of the `ReducerContext`. - -```rust -#[spacetimedb::reducer] -fn my_reducer(ctx: &ReducerContext) { - for row in ctx.db.my_table().iter() { - // Do something with the row... - } -} -``` - -```csharp -[SpacetimeDB.Reducer] -public static void MyReducer(ReducerContext ctx) { - foreach (var row in ctx.Db.MyTable.Iter()) { - // Do something with the row... - } -} -``` - -### Table names and access methods - -You now must specify a name for every table, distinct from the type name. In Rust, write this as `#[spacetimedb::table(name = my_table)]`. The name you specify here will be the method on `ctx.db` you use to access the table. - -```rust -#[spacetimedb::table(name = my_table)] -struct MyTable { - #[primary_key] - #[auto_inc] - id: u64, - other_column: u32, -} -``` - -```csharp -[SpacetimeDB.Table(Name = "MyTable")] -public partial struct MyTable -{ - [SpacetimeDB.PrimaryKey] - [SpacetimeDB.AutoInc] - public long Id; - public int OtherColumn; -} -``` - -One neat upside of this is that you can now have multiple tables with the same row type! - -```rust -#[spacetimedb::table(name = signed_in_user)] -#[spacetimedb::table(name = signed_out_user)] -struct User { - #[primary_key] - id: Identity, - #[unique] - username: String, -} -``` - -```csharp -[SpacetimeDB.Table(Name = "SignedInUser")] -[SpacetimeDB.Table(Name = "SignedOutUser")] -public partial struct User -{ - [SpacetimeDB.PrimaryKey] - public SpacetimeDB.Identity Id; - [SpacetimeDB.Unique] - public String Username; -} -``` - -### Iterating, counting, inserting, deleting - -Each "table handle" `ctx.db.my_table()` has methods: - -| Rust name | C# name | Behavior | -|-----------|----------|-----------------------------------------| -| `iter` | `Iter` | Iterate over all rows in the table. | -| `count` | `Count` | Return the number of rows in the table. | -| `insert` | `Insert` | Add a new row to the table. | -| `delete` | `Delete` | Delete a given row from the table. | - -### Index access - -Each table handle also has a method for each BTree index and/or unique constraint on the table, which allows you to filter, delete or update by that index. BTree indices' filter and delete methods accept both point and range queries. - -```rust -#[spacetimedb::table( - name = entity, - index(name = location, btree = [x, y]), -)] -struct Entity { - #[primary_key] - #[auto_inc] - id: u64, - x: u32, - y: u32, - #[index(btree)] - faction: String, -} - -#[spacetimedb::reducer] -fn move_entity(ctx: &ReducerContext, entity_id: u64, x: u32, y: u32) { - let entity = ctx.db.entity().id().find(entity_id).expect("No such entity"); - ctx.db.entity.id().update(Entity { x, y, ..entity }); -} - -#[spacetimedb::reducer] -fn log_entities_at_point(ctx: &ReducerContext, x: u32, y: u32) { - for entity in ctx.db.entity().location().filter((x, y)) { - log::info!("Entity {} is at ({}, {})", entity.id, x, y); - } -} - -#[spacetimedb::reducer] -fn delete_faction(ctx: &ReducerContext, faction: String) { - ctx.db.entity().faction().delete(&faction); -} -``` - -```csharp -[SpacetimeDB.Table(Name = "Entity")] -[SpacetimeDB.Table(Name = "SignedOutUser")] -[SpacetimeDB.Index(Name = "Location", BTree = ["X", "Y"])] -[SpacetimeDB.Index(Name = "Faction", BTree = ["Faction"])] -public partial struct Entity -{ - [SpacetimeDB.PrimaryKey] - [SpacetimeDB.AutoInc] - public long Id; - public int X; - public int Y; - public string Faction; -} - -[SpacetimeDB.Reducer] -public static void MoveEntity(SpacetimeDB.ReducerContext ctx, long entityId, int x, int y) { - var entity = ctx.Db.Entity.Id.Find(entityId); - ctx.Db.Entity.Id.Update(new Entity { - Id = entityId, - X = x, - Y = y, - Faction = entity.Faction, - }); -} - -[SpacetimeDB.Reducer] -public static void LogEntitiesAtPoint(SpacetimeDB.ReducerContext ctx, int x, int y) { - foreach(var entity in ctx.Db.Entity.Location.Filter((x, y))) { - SpacetimeDB.Log.Info($"Entity {entity.Id} is at ({x}, {y})"); - } -} - -[SpacetimeDB.Reducer] -public static void DeleteFaction(SpacetimeDB.ReducerContext ctx, string Faction) { - ctx.Db.Entity.Faction.Delete(Faction); -} -``` - -### `query` - -Note that the `query!` macro in Rust and the `.Query()` method in C# have been removed. We plan to replace them with something even better in the future, but for now, you should write your query explicitly, either by accessing an index or multi-column index by chaining `ctx.db.my_table().iter().filter(|row| predicate)`. - -### Built-in reducers - -The Rust syntax for declaring builtin lifecycles have changed. They are now: - -- `#[spacetimedb::reducer(client_connected)]` -- `#[spacetimedb::reducer(client_disconnected)]` -- `#[spacetimedb::reducer(init)]` - -In C# they are now: - -- `[SpacetimeDB.Reducer(SpacetimeDB.ReducerKind.ClientConnected)]` -- `[SpacetimeDB.Reducer(SpacetimeDB.ReducerKind.ClientDisconnected)]` -- `[SpacetimeDB.Reducer(SpacetimeDB.ReducerKind.Init)]` - -## Clients - -Make sure to run `spacetime generate` after updating your module! - -### The connection object - -Your connection to a remote module is now represented by a `DbConnection` object, which holds all state associated with the connection. We encourage you to name the variable that holds your connection `ctx`. - -Construct a `DbConnection` via the [builder pattern](https://en.wikipedia.org/wiki/Builder_pattern) with `DbConnection::builder()` or your language's equivalent. Register on-connect and on-disconnect callbacks while constructing the connection via the builder. - -> NOTE: The APIs for the the `DbConnection` and `ReducerContext` are quite similar, allowing you to write the same patterns on both the client and server. - -### Polling the `DbConnection` - -In Rust, you now must explicitly poll your `DbConnection` to advance, where previously it ran automatically in the background. This provides a much greater degree of flexibility to choose your own async runtime and to work under the variety of exciting constraints imposed by game development - for example, you can now arrange it so that all your callbacks run on the main thread if you want to make GUI calls. You can recreate the previous behavior by calling `ctx.run_threaded()` immediately after buidling your connection. You can also call `ctx.run_async()`, or manually call `ctx.frame_tick()` at an appropriate interval. - -In C# the existing API already required you explictly poll your `DbConnection`, so not much has changed there. The `Update()` method is now called `FrameTick()`. - -### Subscribing to queries - -We're planning a major overhaul of the API for subscribing to queries, but we're not quite there yet. This means that our subscription APIs are not yet as consistent as will soon be. - -#### Rust - -Subscribe to a set of queries by creating a subscription builder and calling `subscribe`. - -```rust -ctx.subscription_builder() - .on_applied(|ctx| { ... }) - .subscribe([ - "SELECT * FROM my_table", - "SELECT * FROM other_table WHERE some_column = 123" - ]); -``` - -The `on_applied` callback is optional. A temporarily limitation of this API is that you should add all your subscription queries at one time for any given connection. - -#### C# - -```csharp -ctx.SubscriptionBuilder() - .OnApplied(ctx => { ... }) - .Subscribe( - "SELECT * FROM MyTable", - "SELECT * FROM OtherTable WHERE SomeColumn = 123" - ); -``` - -#### TypeScript - -```ts -ctx.subscriptionBuilder() - .onApplied(ctx => { ... }) - .subscribe([ - "SELECT * FROM my_table", - "SELECT * FROM other_table WHERE some_column = 123" - ]); -``` - -### Accessing tables - -As in modules, all accesses to your connection's client cache now go through the `ctx.db`. Support for client-side indices is not yet consistent across all our SDKs, so for now you may find that you can't make some queries in clients which you could make in modules. The table handles also expose row callbacks. - -### Observing and invoking reducers - -Register reducer callbacks and request reducer invocations by going through `ctx.reducers`. You can also add functions to subscribe to reducer events that the server sends when a particular reducer is executed. - -#### Rust - -```rust -ctx.reducers.my_reducer(my_first_arg, my_second_arg, ...); - -// Add a callback for each reducer event for `my_reducer` -let callback_id = ctx.reducers.on_my_reducer(|ctx, first_arg, second_arg, ...| { - ... -}); - -// Unregister the callback -ctx.reducers.remove_my_reducer(callback_id); -``` - -#### C# - -```cs -ctx.Reducers.MyReducer(myFirstArg, mySecondArg, ...); - -// Add a callback for each reducer event for `MyReducer` -void OnMyReducerCallback(EventContext ctx) { - ... -} -ctx.Reducers.OnMyReducer += OnMyReducerCallback; - -// Unregister the callback -ctx.Reducers.OnMyReducer -= OnMyReducerCallback; -``` - -#### TypeScript - -```ts -ctx.reducers.myReducer(myFirstArg, mySecondArg, ...); - -// Add a callback for each reducer event for `my_reducer` -const callback = (ctx, firstArg, secondArg, ...) => { - ... -}; -ctx.reducers.onMyReducer(callback); - -// Unregister the callback -ctx.reducers.removeMyReducer(callback); -``` - -### The event context - -Most callbacks now take a first argument of type `&EventContext`. This is just like your `DbConnection`, but it has an additional field `event: Event`. `Event` is an enum, tagged union, or sum type which encodes all the different events the SDK can observe. This fills the same role as `ReducerEvent` used to, but `Event` is more specific and more accurate to what actually happened. - -```rust -ctx.reducers.on_my_reducer(|ctx, first_arg, second_arg, ...| { - match ctx.event { - Reducer(reducer_event) => { - ... - }, - _ => unreachable!(); - } -}); -``` - -#### C# - -```csharp -ctx.Reducers.OnMyReducer += (ctx, firstArg, secondArg, ...) => { - switch (ctx.Event) { - case Event.Reducer (var value): - var reducerEvent = value.Reducer; - ... - break; - } -}; -``` - -#### TypeScript - -```ts -ctx.reducers.onMyReducer((ctx, firstArg, secondArg, ...) => { - if (ctx.event.tag === 'Reducer') { - const reducerEvent = ctx.event.value; - ... - } -}); -``` diff --git a/docs/modules/c-sharp/index.md b/docs/modules/c-sharp/index.md index 2c31bb1c..fc2acc95 100644 --- a/docs/modules/c-sharp/index.md +++ b/docs/modules/c-sharp/index.md @@ -1,414 +1,1394 @@ -# SpacetimeDB C# Modules +# SpacetimeDB C# Module Library + +[SpacetimeDB](https://spacetimedb.com/) allows using the C# language to write server-side applications called **modules**. Modules, which run inside a relational database, have direct access to database tables, and expose public functions called **reducers** that can be invoked over the network. Clients connect directly to the database to read data. + +```text + Client Application SpacetimeDB +┌───────────────────────┐ ┌───────────────────────┐ +│ │ │ │ +│ ┌─────────────────┐ │ SQL Query │ ┌─────────────────┐ │ +│ │ Subscribed Data │<─────────────────────│ Database │ │ +│ └─────────────────┘ │ │ └─────────────────┘ │ +│ │ │ │ ^ │ +│ │ │ │ │ │ +│ v │ │ v │ +│ +─────────────────┐ │ call_reducer() │ ┌─────────────────┐ │ +│ │ Client Code │─────────────────────>│ Module Code │ │ +│ └─────────────────┘ │ │ └─────────────────┘ │ +│ │ │ │ +└───────────────────────┘ └───────────────────────┘ +``` -You can use the [C# SpacetimeDB library](https://github.com/clockworklabs/SpacetimeDBLibCSharp) to write modules in C# which interact with the SpacetimeDB database. +C# modules are written with the the C# Module Library (this package). They are built using the [dotnet CLI tool](https://learn.microsoft.com/en-us/dotnet/core/tools/) and deployed using the [`spacetime` CLI tool](https://spacetimedb.com/install). C# modules can import any [NuGet package](https://www.nuget.org/packages) that supports being compiled to WebAssembly. -It uses [Roslyn incremental generators](https://github.com/dotnet/roslyn/blob/main/docs/features/incremental-generators.md) to add extra static methods to types, tables and reducers marked with special attributes and registers them with the database runtime. +(Note: C# can also be used to write **clients** of SpacetimeDB databases, but this requires using a different library, the SpacetimeDB C# Client SDK. See the documentation on [clients] for more information.) -## Example +This reference assumes you are familiar with the basics of C#. If you aren't, check out the [C# language documentation](https://learn.microsoft.com/en-us/dotnet/csharp/). For a guided introduction to C# Modules, see the [C# Module Quickstart](https://spacetimedb.com/docs/modules/c-sharp/quickstart). -Let's start with a heavily commented version of the default example from the landing page: +# Overview -```csharp -// These imports bring into the scope common APIs you'll need to expose items from your module and to interact with the database runtime. -using SpacetimeDB.Module; -using static SpacetimeDB.Runtime; +SpacetimeDB modules have two ways to interact with the outside world: tables and reducers. + +- [Tables](#tables) store data and optionally make it readable by [clients]. + +- [Reducers](#reducers) are functions that modify data and can be invoked by [clients] over the network. They can read and write data in tables, and write to a private debug log. -// Roslyn generators are statically generating extra code as-if they were part of the source tree, so, -// in order to inject new methods, types they operate on as well as their parents have to be marked as `partial`. -// -// We start with the top-level `Module` class for the module itself. +These are the only ways for a SpacetimeDB module to interact with the outside world. Calling functions from `System.IO` or `System.Net` inside a reducer will result in runtime errors. + +Declaring tables and reducers is straightforward: + +```csharp static partial class Module { - // `[SpacetimeDB.Table]` registers a struct or a class as a SpacetimeDB table. - // - // It generates methods to insert, filter, update, and delete rows of the given type in the table. - [SpacetimeDB.Table(Public = true)] + [SpacetimeDB.Table(Name = "player")] + public partial struct Player + { + public int Id; + public string Name; + } + + [SpacetimeDB.Reducer] + public static void AddPerson(ReducerContext ctx, int Id, string Name) { + ctx.Db.player.Insert(new Player { Id = Id, Name = Name }); + } +} +``` + + +Note that reducers don't return data directly; they can only modify the database. Clients connect directly to the database and use SQL to query [public](#public-and-private-tables) tables. Clients can also subscribe to a set of rows using SQL queries and receive streaming updates whenever any of those rows change. + +Tables and reducers in C# modules can use any type annotated with [`[SpacetimeDB.Type]`](#attribute-spacetimedbtype). + + + +# Setup + +To create a C# module, install the [`spacetime` CLI tool](https://spacetimedb.com/install) in your preferred shell. Navigate to your work directory and run the following command: + +```bash +spacetime init --lang csharp my-project-directory +``` + +This creates a `dotnet` project in `my-project-directory` with the following `StdbModule.csproj`: + +```xml + + + + net8.0 + wasi-wasm + enable + enable + + + + + + + +``` + +This is a standard `csproj`, with the exception of the line `wasi-wasm`. +This line is important: it allows the project to be compiled to a WebAssembly module. + +The project's `Lib.cs` will contain the following skeleton: + +```csharp +public static partial class Module +{ + [SpacetimeDB.Table] public partial struct Person { - // `[SpacetimeDB.Column]` allows to specify column attributes / constraints such as - // "this field should be unique" or "this field should get automatically assigned auto-incremented value". - [SpacetimeDB.Column(ColumnAttrs.Unique | ColumnAttrs.AutoInc)] + [SpacetimeDB.AutoInc] + [SpacetimeDB.PrimaryKey] public int Id; public string Name; public int Age; } - // `[SpacetimeDB.Reducer]` marks a static method as a SpacetimeDB reducer. - // - // Reducers are functions that can be invoked from the database runtime. - // They can't return values, but can throw errors that will be caught and reported back to the runtime. [SpacetimeDB.Reducer] - public static void Add(string name, int age) + public static void Add(ReducerContext ctx, string name, int age) { - // We can skip (or explicitly set to zero) auto-incremented fields when creating new rows. - var person = new Person { Name = name, Age = age }; - - // `Insert()` method is auto-generated and will insert the given row into the table. - person.Insert(); - // After insertion, the auto-incremented fields will be populated with their actual values. - // - // `Log()` function is provided by the runtime and will print the message to the database log. - // It should be used instead of `Console.WriteLine()` or similar functions. - Log($"Inserted {person.Name} under #{person.Id}"); + var person = ctx.Db.Person.Insert(new Person { Name = name, Age = age }); + Log.Info($"Inserted {person.Name} under #{person.Id}"); } [SpacetimeDB.Reducer] - public static void SayHello() + public static void SayHello(ReducerContext ctx) { - // Each table type gets a static Iter() method that can be used to iterate over the entire table. - foreach (var person in Person.Iter()) + foreach (var person in ctx.Db.Person.Iter()) { - Log($"Hello, {person.Name}!"); + Log.Info($"Hello, {person.Name}!"); } - Log("Hello, World!"); + Log.Info("Hello, World!"); } } ``` -## API reference +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: -Now we'll get into details on all the APIs SpacetimeDB provides for writing modules in C#. +```bash +dotnet workload install wasi-experimental +``` -### Logging +If you are managing your dotnet installation in some other way, you will need to install the `wasi-experimental` workload yourself. -First of all, logging as we're likely going to use it a lot for debugging and reporting errors. +To build your application and upload it to the public SpacetimeDB network, run: -`SpacetimeDB.Runtime` provides a `Log` function that will print the given message to the database log, along with the source location and a log level it was provided. +```bash +spacetime login +``` + +And then: + +```bash +spacetime publish [MY_DATABASE_NAME] +``` + +For example: + +```bash +spacetime publish silly_demo_app +``` -Supported log levels are provided by the `LogLevel` enum: +When you publish your module, a database named `silly_demo_app` will be created with the requested tables, and the module will be installed inside it. + +The output of `spacetime publish` will end with a line: +```text +Created new database with name: , identity: +``` + +This name is the human-readable name of the created database, and the hex string is its [`Identity`](#struct-identity). These distinguish the created database from the other databases running on the SpacetimeDB network. They are used when administering the application, for example using the [`spacetime logs `](#class-log) command. You should probably write the database name down in a text file so that you can remember it. + +After modifying your project, you can run: + +`spacetime publish ` + +to update the module attached to your database. Note that SpacetimeDB tries to [automatically migrate](#automatic-migrations) your database schema whenever you run `spacetime publish`. + +You can also generate code for clients of your module using the `spacetime generate` command. See the [client SDK documentation] for more information. + +# How it works + +Under the hood, SpacetimeDB modules are WebAssembly modules that import a [specific WebAssembly ABI](https://spacetimedb.com/docs/webassembly-abi) and export a small number of special functions. This is automatically configured when you add the `SpacetimeDB.Runtime` package as a dependency of your application. + +The SpacetimeDB host is an application that hosts SpacetimeDB databases. [Its source code is available](https://github.com/clockworklabs/SpacetimeDB) under [the Business Source License with an Additional Use Grant](https://github.com/clockworklabs/SpacetimeDB/blob/master/LICENSE.txt). You can run your own host, or you can upload your module to the public SpacetimeDB network. The network will create a database for you and install your module in it to serve client requests. + +## In More Detail: Publishing a Module + +The `spacetime publish [DATABASE_IDENTITY]` command compiles a module and uploads it to a SpacetimeDB host. After this: +- The host finds the database with the requested `DATABASE_IDENTITY`. + - (Or creates a fresh database and identity, if no identity was provided). +- The host loads the new module and inspects its requested database schema. If there are changes to the schema, the host tries perform an [automatic migration](#automatic-migrations). If the migration fails, publishing fails. +- The host terminates the old module attached to the database. +- The host installs the new module into the database. It begins running the module's [lifecycle reducers](#lifecycle-reducers) and [scheduled reducers](#scheduled-reducers), starting with the `Init` reducer. +- The host begins allowing clients to call the module's reducers. + +From the perspective of clients, this process is seamless. Open connections are maintained and subscriptions continue functioning. [Automatic migrations](#automatic-migrations) forbid most table changes except for adding new tables, so client code does not need to be recompiled. +However: +- Clients may witness a brief interruption in the execution of scheduled reducers (for example, game loops.) +- New versions of a module may remove or change reducers that were previously present. Client code calling those reducers will receive runtime errors. + + +# Tables + +Tables are declared using the [`[SpacetimeDB.Table]` attribute](#table-attribute). + +This macro is applied to a C# `partial class` or `partial struct` with named fields. (The `partial` modifier is required to allow code generation to add methods.) All of the fields of the table must be marked with [`[SpacetimeDB.Type]`](#type-attribute). + +The resulting type is used to store rows of the table. It's a normal class (or struct). Row values are not special -- operations on row types do not, by themselves, modify the table. Instead, a [`ReducerContext`](#class-reducercontext) is needed to get a handle to the table. + +```csharp +public static partial class Module { + + /// + /// A Person is a row of the table person. + /// + [SpacetimeDB.Table(Name = "person", Public)] + public partial struct Person { + [SpacetimeDB.PrimaryKey] + [SpacetimeDB.AutoInc] + ulong Id; + [SpacetimeDB.Index.BTree] + string Name; + } + + // `Person` is a normal C# struct type. + // Operations on a `Person` do not, by themselves, do anything. + // The following function does not interact with the database at all. + public static void DoNothing() { + // Creating a `Person` DOES NOT modify the database. + var person = new Person { Id = 0, Name = "Joe Average" }; + // Updating a `Person` DOES NOT modify the database. + person.Name = "Joanna Average"; + // Deallocating a `Person` DOES NOT modify the database. + person = null; + } + + // To interact with the database, you need a `ReducerContext`, + // which is provided as the first parameter of any reducer. + [SpacetimeDB.Reducer] + public static void DoSomething(ReducerContext ctx) { + // The following inserts a row into the table: + var examplePerson = ctx.Db.person.Insert(new Person { id = 0, name = "Joe Average" }); + + // `examplePerson` is a COPY of the row stored in the database. + // If we update it: + examplePerson.name = "Joanna Average".to_string(); + // Our copy is now updated, but the database's copy is UNCHANGED. + // To push our change through, we can call `UniqueIndex.Update()`: + examplePerson = ctx.Db.person.Id.Update(examplePerson); + // Now the database and our copy are in sync again. + + // We can also delete the row in the database using `UniqueIndex.Delete()`. + ctx.Db.person.Id.Delete(examplePerson.Id); + } +} +``` + +(See [reducers](#reducers) for more information on declaring reducers.) + +This library generates a custom API for each table, depending on the table's name and structure. + +All tables support getting a handle implementing the [`ITableView`](#interface-itableview) interface from a [`ReducerContext`](#class-reducercontext), using: + +```text +ctx.Db.{table_name} +``` + +For example, ```csharp -public enum LogLevel +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() { - Error, - Warn, - Info, - Debug, - Trace, - Panic + /* ... */ } ``` + + +Implemented for every table handle generated by the [`Table`](#tables) attribute. +For a table named `{name}`, a handle can be extracted from a [`ReducerContext`](#class-reducercontext) using `ctx.Db.{name}`. For example, `ctx.Db.person`. + +Contains methods that are present for every table handle, regardless of what unique constraints +and indexes are present. + +The type `Row` is the type of rows in the table. + +| Name | Description | +| --------------------------------------------- | ----------------------------- | +| [Method `Insert`](#method-itableviewinsert) | Insert a row into the table | +| [Method `Delete`](#method-itableviewdelete) | Delete a row from the table | +| [Method `Iter`](#method-itableviewiter) | Iterate all rows of the table | +| [Property `Count`](#property-itableviewcount) | Count all rows of the table | + +### Method `ITableView.Insert` + +```csharp +Row Insert(Row row); +``` + +Inserts `row` into the table. + +The return value is the inserted row, with any auto-incrementing columns replaced with computed values. +The `insert` method always returns the inserted row, even when the table contains no auto-incrementing columns. + +(The returned row is a copy of the row in the database. +Modifying this copy does not directly modify the database. +See [`UniqueIndex.Update()`](#method-uniqueindexupdate) if you want to update the row.) + +Throws an exception if inserting the row violates any constraints. + +Inserting a duplicate row in a table is a no-op, +as SpacetimeDB is a set-semantic database. -If omitted, the log level will default to `Info`, so these two forms are equivalent: +### Method `ITableView.Delete` ```csharp -Log("Hello, World!"); -Log("Hello, World!", LogLevel.Info); +bool Delete(Row row); ``` -### Supported types +Deletes a row equal to `row` from the table. -#### Built-in types +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. -The following types are supported out of the box and can be stored in the database tables directly or as part of more complex types: +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. -- `bool` -- `byte`, `sbyte` -- `short`, `ushort` -- `int`, `uint` -- `long`, `ulong` -- `float`, `double` -- `string` -- [`Int128`](https://learn.microsoft.com/en-us/dotnet/api/system.int128), [`UInt128`](https://learn.microsoft.com/en-us/dotnet/api/system.uint128) -- `T[]` - arrays of supported values. -- [`List`](https://learn.microsoft.com/en-us/dotnet/api/system.collections.generic.list-1) -- [`Dictionary`](https://learn.microsoft.com/en-us/dotnet/api/system.collections.generic.dictionary-2) +Throws an exception if deleting the row would violate any constraints. -And a couple of special custom types: +### Method `ITableView.Iter` -- `SpacetimeDB.SATS.Unit` - semantically equivalent to an empty struct, sometimes useful in generic contexts where C# doesn't permit `void`. -- `Identity` (`SpacetimeDB.Runtime.Identity`) - a unique identifier for each user; internally a byte blob but can be printed, hashed and compared for equality. -- `Address` (`SpacetimeDB.Runtime.Address`) - an identifier which disamgibuates connections by the same `Identity`; internally a byte blob but can be printed, hashed and compared for equality. +```csharp +IEnumerable Iter(); +``` -#### Custom types +Iterate over all rows of the table. -`[SpacetimeDB.Type]` attribute can be used on any `struct`, `class` or an `enum` to mark it as a SpacetimeDB type. It will implement serialization and deserialization for values of this type so that they can be stored in the database. +(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.) -Any `struct` or `class` marked with this attribute, as well as their respective parents, must be `partial`, as the code generator will add methods to them. +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 -[SpacetimeDB.Type] -public partial struct Point -{ - public int x; - public int y; +ulong Count { get; } +``` + +Returns the number of rows of this table. + +This takes into account modifications by the current transaction, +even though those modifications have not yet been committed or broadcast to clients. +This applies generally to insertions, deletions, updates, and iteration as well. + +## Public and Private Tables + +By default, tables are considered **private**. This means that they are only readable by the database owner and by reducers. Reducers run inside the database, so clients cannot see private tables at all or even know of their existence. + +Using the `[SpacetimeDB.Table(Name = "table_name", Public)]` flag makes a table public. **Public** tables are readable by all clients. They can still only be modified by reducers. + +(Note that, when run by the module owner, the `spacetime sql ` command can also read private tables. This is for debugging convenience. Only the module owner can see these tables. This is determined by the `Identity` stored by the `spacetime login` command. Run `spacetime login show` to print your current logged-in `Identity`.) + +To learn how to subscribe to a public table, see the [client SDK documentation](https://spacetimedb.com/docs/sdks). + +## Unique and Primary Key Columns + +Columns of a table (that is, fields of a [`[Table]`](#tables) struct) can be annotated with `[Unique]` or `[PrimaryKey]`. Multiple columns can be `[Unique]`, but only one can be `[PrimaryKey]`. For example: + +```csharp +[SpacetimeDB.Table(Name = "citizen")] +public partial struct Citizen { + [SpacetimeDB.PrimaryKey] + ulong Id; + + [SpacetimeDB.Unique] + string Ssn; + + [SpacetimeDB.Unique] + string Email; + + string name; } ``` -`enum`s marked with this attribute must not use custom discriminants, as the runtime expects them to be always consecutive starting from zero. Unlike structs and classes, they don't use `partial` as C# doesn't allow to add methods to `enum`s. +Every row in the table `Person` must have unique entries in the `id`, `ssn`, and `email` columns. Attempting to insert multiple `Person`s with the same `id`, `ssn`, or `email` will throw an exception. + +Any `[Unique]` or `[PrimaryKey]` column supports getting a [`UniqueIndex`](#class-uniqueindex) from a [`ReducerContext`](#class-reducercontext) using: + +```text +ctx.Db.{table}.{unique_column} +``` + +For example, ```csharp -[SpacetimeDB.Type] -public enum Color +ctx.Db.citizen.Ssn +``` + +Notice that updating a row is only possible if a row has a unique column -- there is no `update` method in the base [`ITableView`](#interface-itableview) interface. SpacetimeDB has no notion of rows having an "identity" aside from their unique / primary keys. + +The `[PrimaryKey]` annotation implies a `[Unique]` annotation, but avails additional methods in the [client]-side SDKs. + +It is not currently possible to mark a group of fields as collectively unique. + +Filtering on unique columns is only supported for a limited number of types. + +## Class `UniqueIndex` + +```csharp +namespace SpacetimeDB.Internal; + +public abstract class UniqueIndex : IndexBase + where Handle : ITableView + where Row : IStructuralReadWrite, new() + where Column : IEquatable { - Red, - Green, - Blue, + /* ... */ +} +``` + + +A unique index on a column. Available for `[Unique]` and `[PrimaryKey]` columns. +(A custom class derived from `UniqueIndex` is generated for every such column.) + +`Row` is the type decorated with `[SpacetimeDB.Table]`, `Column` is the type of the column, +and `Handle` is the type of the generated table handle. + +For a table *table* with a column *column*, use `ctx.Db.{table}.{column}` +to get a `UniqueColumn` from a [`ReducerContext`](#class-reducercontext). + +Example: + +```csharp +using SpacetimeDB; + +public static partial class Module { + [Table(Name = "user")] + public partial struct User { + [PrimaryKey] + uint Id; + [Unique] + string Username; + ulong DogCount; + } + + [Reducer] + void Demo(ReducerContext ctx) { + var idIndex = ctx.Db.user.Id; + var exampleUser = idIndex.find(357).unwrap(); + exampleUser.dog_count += 5; + idIndex.update(exampleUser); + + var usernameIndex = ctx.Db.user.Username; + usernameIndex.delete("Evil Bob"); + } } ``` -#### Tagged enums +| Name | Description | +| -------------------------------------------- | -------------------------------------------- | +| [Method `Find`](#method-uniqueindexfind) | Find a row by the value of a unique column | +| [Method `Update`](#method-uniqueindexupdate) | Update a row with a unique column | +| [Method `Delete`](#method-uniqueindexdelete) | Delete a row by the value of a unique column | -SpacetimeDB has support for tagged enums which can be found in languages like Rust, but not C#. + -We provide a tagged enum support for C# modules via a special `record SpacetimeDB.TaggedEnum<(...types and names of the variants as a tuple...)>`. +### Method `UniqueIndex.Find` -When you inherit from the `SpacetimeDB.TaggedEnum` marker, it will generate variants as subclasses of the annotated type, so you can use regular C# pattern matching operators like `is` or `switch` to determine which variant a given tagged enum holds at any time. +```csharp +Row? Find(Column key); +``` -For unit variants (those without any data payload) you can use a built-in `SpacetimeDB.Unit` as the variant type. +Finds and returns the row where the value in the unique column matches the supplied `key`, +or `null` if no such row is present in the database state. -Example: +### Method `UniqueIndex.Update` ```csharp -// Define a tagged enum named `MyEnum` with three variants, -// `MyEnum.String`, `MyEnum.Int` and `MyEnum.None`. -[SpacetimeDB.Type] -public partial record MyEnum : SpacetimeDB.TaggedEnum<( - string String, - int Int, - SpacetimeDB.Unit None -)>; +Row Update(Row row); +``` + +Deletes the row where the value in the unique column matches that in the corresponding field of `row` and then inserts `row`. + +Returns the new row as actually inserted, with any auto-inc placeholders substituted for computed values. + +Throws if no row was previously present with the matching value in the unique column, +or if either the delete or the insertion would violate a constraint. + +### Method `UniqueIndex.Delete` + +```csharp +bool Delete(Column key); +``` + +Deletes the row where the value in the unique column matches the supplied `key`, if any such row is present in the database state. + +Returns `true` if a row with the specified `key` was previously present and has been deleted, +or `false` if no such row was present. + +## Auto-inc columns + +Columns can be marked `[SpacetimeDB.AutoInc]`. This can only be used on integer types (`int`, `ulong`, etc.) + +When inserting into or updating a row in a table with an `[AutoInc]` column, if the annotated column is set to zero (`0`), the database will automatically overwrite that zero with an atomically increasing value. + +[`ITableView.Insert`] and [`UniqueIndex.Update()`](#method-uniqueindexupdate) returns rows with `[AutoInc]` columns set to the values that were actually written into the database. -// Print an instance of `MyEnum`, using `switch`/`case` to determine the active variant. -void PrintEnum(MyEnum e) +```csharp +public static partial class Module { - switch (e) + [SpacetimeDB.Table(Name = "example")] + public partial struct Example { - case MyEnum.String(var s): - Console.WriteLine(s); - break; - - case MyEnum.Int(var i): - Console.WriteLine(i); - break; + [SpacetimeDB.AutoInc] + public int Field; + } - case MyEnum.None: - Console.WriteLine("(none)"); - break; + [SpacetimeDB.Reducer] + public static void InsertAutoIncExample(ReducerContext ctx, int Id, string Name) { + for (var i = 0; i < 10; i++) { + // These will have distinct, unique values + // at rest in the database, since they + // are inserted with the sentinel value 0. + var actual = ctx.Db.example.Insert(new Example { Field = 0 }); + Debug.Assert(actual.Field != 0); + } } } +``` + +`[AutoInc]` is often combined with `[Unique]` or `[PrimaryKey]` to automatically assign unique integer identifiers to rows. + +## Indexes -// Test whether an instance of `MyEnum` holds some value (either a string or an int one). -bool IsSome(MyEnum e) => e is not MyEnum.None; +SpacetimeDB supports both single- and multi-column [B-Tree](https://en.wikipedia.org/wiki/B-tree) indexes. -// Construct an instance of `MyEnum` with the `String` variant active. -var myEnum = new MyEnum.String("Hello, world!"); -Console.WriteLine($"IsSome: {IsSome(myEnum)}"); -PrintEnum(myEnum); +Indexes are declared using the syntax: + +```csharp +[SpacetimeDB.Index.BTree(Name = "IndexName", Columns = [nameof(Column1), nameof(Column2), nameof(Column3)])] +``` + +For example: + +```csharp +[SpacetimeDB.Table(Name = "paper")] +[SpacetimeDB.Index.BTree(Name = "TitleAndDate", Columns = [nameof(Title), nameof(Date)])] +[SpacetimeDB.Index.BTree(Name = "UrlAndCountry", Columns = [nameof(Url), nameof(Country)])] +public partial struct AcademicPaper { + public string Title; + public string Url; + public string Date; + public string Venue; + public string Country; +} ``` -### Tables +Multiple indexes can be declared. -`[SpacetimeDB.Table]` attribute can be used on any `struct` or `class` to mark it as a SpacetimeDB table. It will register a table in the database with the given name and fields as well as will generate C# methods to insert, filter, update, and delete rows of the given type. -By default, tables are **private**. This means that they are only readable by the table owner, and by server module code. -Adding `[SpacetimeDB.Table(Public = true))]` annotation makes a table public. **Public** tables are readable by all users, but can still only be modified by your server module code. +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; +} +``` -_Coming soon: We plan to add much more robust access controls than just public or private. Stay tuned!_ -It implies `[SpacetimeDB.Type]`, so you must not specify both attributes on the same type. +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`. + +## Class `Index` ```csharp -[SpacetimeDB.Table(Public = true)] -public partial struct Person +public abstract class IndexBase + where Row : IStructuralReadWrite, new() { - [SpacetimeDB.Column(ColumnAttrs.Unique | ColumnAttrs.AutoInc)] - public int Id; - public string Name; - public int Age; + // ... } ``` -The example above will generate the following extra methods: +Each index generates a subclass of `IndexBase`, which is accessible via `ctx.Db.{table}.{index}`. For example, `ctx.Db.academic_paper.TitleAndDate`. + +Indexes can be applied to a variable number of columns, referred to as `Column1`, `Column2`, `Column3`... in the following examples. + +| Name | Description | +| -------------------------------------- | ----------------------- | +| Method [`Filter`](#method-indexfilter) | Filter rows in an index | +| Method [`Delete`](#method-indexdelete) | Delete rows in an index | + +### Method `Index.Filter` ```csharp -public partial struct Person +public IEnumerable Filter(Column1 bound); +public IEnumerable Filter(Bound bound); +public IEnumerable Filter((Column1, Column2) bound); +public IEnumerable Filter((Column1, Bound) bound); +public IEnumerable Filter((Column1, Column2, Column3) bound); +public IEnumerable Filter((Column1, Column2, Bound) bound); +// ... +``` + +Returns an iterator over all rows in the database state where the indexed column(s) match the passed `bound`. Bound is a tuple of column values, possibly terminated by a `Bound`. A `Bound` is simply a tuple `(LastColumn Min, LastColumn Max)`. Any prefix of the indexed columns can be passed, for example: + +```csharp +using SpacetimeDB; + +public static partial class Module { - // Inserts current instance as a new row into the table. - public void Insert(); + [SpacetimeDB.Table(Name = "zoo_animal")] + [SpacetimeDB.Index.BTree(Name = "SpeciesAgeName", Columns = [nameof(Species), nameof(Age), nameof(Name)])] + public partial struct ZooAnimal + { + public string Species; + public uint Age; + public string Name; + [SpacetimeDB.PrimaryKey] + public uint Id; + } - // Returns an iterator over all rows in the table, e.g.: - // `for (var person in Person.Iter()) { ... }` - public static IEnumerable Iter(); + [SpacetimeDB.Reducer] + public static void Example(ReducerContext ctx) + { + foreach (var baboon in ctx.Db.zoo_animal.SpeciesAgeName.Filter("baboon")) + { + // Work with the baboon. + } + foreach (var animal in ctx.Db.zoo_animal.SpeciesAgeName.Filter(("b", "e"))) + { + // Work with the animal. + // The name of the species starts with a character between "b" and "e". + } + foreach (var babyBaboon in ctx.Db.zoo_animal.SpeciesAgeName.Filter(("baboon", 1))) + { + // Work with the baby baboon. + } + foreach (var youngBaboon in ctx.Db.zoo_animal.SpeciesAgeName.Filter(("baboon", (1, 5)))) + { + // Work with the young baboon. + } + foreach (var babyBaboonNamedBob in ctx.Db.zoo_animal.SpeciesAgeName.Filter(("baboon", 1, "Bob"))) + { + // Work with the baby baboon named "Bob". + } + foreach (var babyBaboon in ctx.Db.zoo_animal.SpeciesAgeName.Filter(("baboon", 1, ("a", "f")))) + { + // Work with the baby baboon, whose name starts with a letter between "a" and "f". + } + } +} +``` - // Returns an iterator over all rows in the table that match the given filter, e.g.: - // `for (var person in Person.Query(p => p.Age >= 18)) { ... }` - public static IEnumerable Query(Expression> filter); +### Method `Index.Delete` - // Generated for each column: +```csharp +public ulong Delete(Column1 bound); +public ulong Delete(Bound bound); +public ulong Delete((Column1, Column2) bound); +public ulong Delete((Column1, Bound) bound); +public ulong Delete((Column1, Column2, Column3) bound); +public ulong Delete((Column1, Column2, Bound) bound); +// ... +``` - // Returns an iterator over all rows in the table that have the given value in the `Name` column. - public static IEnumerable FilterByName(string name); - public static IEnumerable FilterByAge(int age); +Delete all rows in the database state where the indexed column(s) match the passed `bound`. Returns the count of rows deleted. Note that there may be multiple rows deleted even if only a single column value is passed, since the index is not guaranteed to be unique. - // Generated for each unique column: +# Reducers - // Finds a row in the table with the given value in the `Id` column and returns it, or `null` if no such row exists. - public static Person? FindById(int id); +Reducers are declared using the `[SpacetimeDB.Reducer]` attribute. - // Deletes a row in the table with the given value in the `Id` column and returns `true` if the row was found and deleted, or `false` if no such row exists. - public static bool DeleteById(int id); +`[SpacetimeDB.Reducer]` is always applied to static C# functions. The first parameter of a reducer must be a [`ReducerContext`]. The remaining parameters must be types marked with [`SpacetimeDB.Type`]. Reducers should return `void`. - // Updates a row in the table with the given value in the `Id` column and returns `true` if the row was found and updated, or `false` if no such row exists. - public static bool UpdateById(int oldId, Person newValue); +```csharp +public static partial class Module { + [SpacetimeDB.Reducer] + public static void GivePlayerItem( + ReducerContext context, + ulong PlayerId, + ulong ItemId + ) + { + // ... + } } ``` -You can create multiple tables backed by items of the same type by applying it with different names. For example, to store active and archived posts separately and with different privacy rules, you can declare two tables like this: +Every reducer runs inside a [database transaction](https://en.wikipedia.org/wiki/Database_transaction). This means that reducers will not observe the effects of other reducers modifying the database while they run. If a reducer fails, all of its changes to the database will automatically be rolled back. Reducers can fail by throwing an exception. + +## Class `ReducerContext` ```csharp -[SpacetimeDB.Table(Name = "Post", Public = true)] -[SpacetimeDB.Table(Name = "ArchivedPost", Public = false)] -public partial struct Post { - public string Title; - public string Body; +public sealed record ReducerContext : DbContext, Internal.IReducerContext +{ + // ... } ``` -#### Column attributes +Reducers have access to a special [`ReducerContext`] parameter. This parameter allows reading and writing the database attached to a module. It also provides some additional functionality, like generating random numbers and scheduling future operations. -Attribute `[SpacetimeDB.Column]` can be used on any field of a `SpacetimeDB.Table`-marked `struct` or `class` to customize column attributes as seen above. +[`ReducerContext`] provides access to the database tables via [the `.Db` property](#property-reducercontextdb). The [`[Table]`](#tables) attribute generated code that adds table accessors to this property. -The supported column attributes are: +| Name | Description | +| --------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| Property [`Db`](#property-reducercontextdb) | The current state of the database | +| Property [`Sender`](#property-reducercontextsender) | The [`Identity`](#struct-identity) of the caller of the reducer | +| Property [`ConnectionId`](#property-reducercontextconnectionid) | The [`ConnectionId`](#struct-connectionid) of the caller of the reducer, if any | +| Property [`Rng`](#property-reducercontextrng) | A [`System.Random`] instance. | +| Property [`Timestamp`](#property-reducercontexttimestamp) | The [`Timestamp`](#struct-timestamp) of the reducer invocation | +| Property [`Identity`](#property-reducercontextidentity) | The [`Identity`](#struct-identity) of the module | -- `ColumnAttrs.AutoInc` - this column should be auto-incremented. -- `ColumnAttrs.Unique` - this column should be unique. -- `ColumnAttrs.PrimaryKey` - this column should be a primary key, it implies `ColumnAttrs.Unique` but also allows clients to subscribe to updates via `OnUpdate` which will use this field to match the old and the new version of the row with each other. +### Property `ReducerContext.Db` -These attributes are bitflags and can be combined together, but you can also use some predefined shortcut aliases: +```csharp +DbView Db; +``` -- `ColumnAttrs.Identity` - same as `ColumnAttrs.Unique | ColumnAttrs.AutoInc`. -- `ColumnAttrs.PrimaryKeyAuto` - same as `ColumnAttrs.PrimaryKey | ColumnAttrs.AutoInc`. +Allows accessing the local database attached to a module. -### Reducers +The `[Table]` attribute generates a field of this property. -Attribute `[SpacetimeDB.Reducer]` can be used on any `static void` method to register it as a SpacetimeDB reducer. The method must accept only supported types as arguments. If it throws an exception, those will be caught and reported back to the database runtime. +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 -[SpacetimeDB.Reducer] -public static void Add(string name, int age) -{ - var person = new Person { Name = name, Age = age }; - person.Insert(); - Log($"Inserted {person.Name} under #{person.Id}"); -} +Identity Sender; ``` -If a reducer has an argument with a type `ReducerContext` (`SpacetimeDB.Runtime.ReducerContext`), it will be provided with event details such as the sender identity (`SpacetimeDB.Runtime.Identity`), sender address (`SpacetimeDB.Runtime.Address?`) and the time (`DateTimeOffset`) of the invocation: +The [`Identity`](#struct-identity) of the client that invoked the reducer. + +### Property `ReducerContext.ConnectionId` ```csharp -[SpacetimeDB.Reducer] -public static void PrintInfo(ReducerContext e) -{ - Log($"Sender identity: {e.Sender}"); - Log($"Sender address: {e.Address}"); - Log($"Time: {e.Time}"); -} +ConnectionId? ConnectionId; +``` + +The [`ConnectionId`](#struct-connectionid) of the client that invoked the reducer. + +`null` if no `ConnectionId` was supplied to the `/database/call` HTTP endpoint, +or via the CLI's `spacetime call` subcommand. + +### Property `ReducerContext.Rng` + +```csharp +Random Rng; +``` + +A [`System.Random`] that can be used to generate random numbers. + +### Property `ReducerContext.Timestamp` + +```csharp +Timestamp Timestamp; +``` + +The time at which the reducer was invoked. + +### Property `ReducerContext.Identity` + +```csharp +Identity Identity; ``` -### Scheduler Tables +The [`Identity`](#struct-identity) of the module. + +This can be used to [check whether a scheduled reducer is being called by a user](#restricting-scheduled-reducers). + +Note: this is not the identity of the caller, that's [`ReducerContext.Sender`](#property-reducercontextsender). + + +## Lifecycle Reducers + +A small group of reducers are called at set points in the module lifecycle. These are used to initialize +the database and respond to client connections. You can have one of each per module. + +These reducers cannot be called manually and may not have any parameters except for `ReducerContext`. + +### The `Init` reducer -Tables can be used to schedule a reducer calls either at a specific timestamp or at regular intervals. +This reducer is marked with `[SpacetimeDB.Reducer(ReducerKind.Init)]`. It is run the first time a module is published and any time the database is cleared. + +If an error occurs when initializing, the module will not be published. + +This reducer can be used to configure any static data tables used by your module. It can also be used to start running [scheduled reducers](#scheduled-reducers). + +### The `ClientConnected` reducer + +This reducer is marked with `[SpacetimeDB.Reducer(ReducerKind.ClientConnected)]`. It is run when a client connects to the SpacetimeDB module. Their identity can be found in the sender value of the `ReducerContext`. + +If an error occurs in the reducer, the client will be disconnected. + +### The `ClientDisconnected` reducer + +This reducer is marked with `[SpacetimeDB.Reducer(ReducerKind.ClientDisconnected)]`. It is run when a client disconnects from the SpacetimeDB module. Their identity can be found in the sender value of the `ReducerContext`. + +If an error occurs in the disconnect reducer, the client is still recorded as disconnected. + + +## Scheduled Reducers + +Reducers can schedule other reducers to run asynchronously. This allows calling the reducers at a particular time, or at repeating intervals. This can be used to implement timers, game loops, and maintenance tasks. + +The scheduling information for a reducer is stored in a table. +This table has two mandatory fields: +- An `[AutoInc] [PrimaryKey] ulong` field that identifies scheduled reducer calls. +- A [`ScheduleAt`](#record-scheduleat) field that says when to call the reducer. + +Managing timers with a scheduled table is as simple as inserting or deleting rows from the table. +This makes scheduling transactional in SpacetimeDB. If a reducer A first schedules B but then errors for some other reason, B will not be scheduled to run. + +A [`ScheduleAt`](#record-scheduleat) can be created from a [`Timestamp`](#struct-timestamp), in which case the reducer will be scheduled once, or from a [`TimeDuration`](#struct-timeduration), in which case the reducer will be scheduled in a loop. + +Example: ```csharp -public static partial class Timers +using SpacetimeDB; + +public static partial class Module { - // The `Scheduled` attribute links this table to a reducer. - [SpacetimeDB.Table(Scheduled = nameof(SendScheduledMessage))] - public partial struct SendMessageTimer + // First, we declare the table with scheduling information. + + [Table(Name = "send_message_schedule", Scheduled = nameof(SendMessage), ScheduledAt = nameof(ScheduledAt))] + public partial struct SendMessageSchedule { - public string Text; + + // Mandatory fields: + + [PrimaryKey] + [AutoInc] + public ulong Id; + + public ScheduleAt ScheduledAt; + + // Custom fields: + + public string Message; } + // Then, we declare the scheduled reducer. + // The first argument of the reducer should be, as always, a `ReducerContext`. + // The second argument should be a row of the scheduling information table. - // Define the reducer that will be invoked by the scheduler table. - // The first parameter is always `ReducerContext`, and the second parameter is an instance of the linked table struct. - [SpacetimeDB.Reducer] - public static void SendScheduledMessage(ReducerContext ctx, SendMessageTimer arg) + [Reducer] + public static void SendMessage(ReducerContext ctx, SendMessageSchedule schedule) { + Log.Info($"Sending message {schedule.Message}"); // ... } + // Finally, we want to actually start scheduling reducers. + // It's convenient to do this inside the `init` reducer. - // Scheduling reducers inside `init` reducer. - [SpacetimeDB.Reducer(ReducerKind.Init)] + [Reducer(ReducerKind.Init)] public static void Init(ReducerContext ctx) { + var currentTime = ctx.Timestamp; + var tenSeconds = new TimeDuration { Microseconds = +10_000_000 }; + var futureTimestamp = currentTime + tenSeconds; - // Schedule a one-time reducer call by inserting a row. - new SendMessageTimer + ctx.Db.send_message_schedule.Insert(new() { - Text = "bot sending a message", - ScheduledAt = ctx.Time.AddSeconds(10), - ScheduledId = 1, - }.Insert(); - + Id = 0, // Have [AutoInc] assign an Id. + ScheduledAt = new ScheduleAt.Time(futureTimestamp), + Message = "I'm a bot sending a message one time!" + }); - // Schedule a recurring reducer. - new SendMessageTimer + ctx.Db.send_message_schedule.Insert(new() { - Text = "bot sending a message", - ScheduledAt = new TimeStamp(10), - ScheduledId = 2, - }.Insert(); + Id = 0, // Have [AutoInc] assign an Id. + ScheduledAt = new ScheduleAt.Interval(tenSeconds), + Message = "I'm a bot sending a message every ten seconds!" + }); } } ``` -Annotating a struct with `Scheduled` automatically adds fields to support scheduling, It can be expanded as: +Scheduled reducers are called on a best-effort basis and may be slightly delayed in their execution +when a database is under heavy load. + +### Restricting scheduled reducers + +Scheduled reducers are normal reducers, and may still be called by clients. +If a scheduled reducer should only be called by the scheduler, consider beginning it with a check that the caller `Identity` is the module: ```csharp -public static partial class Timers +[Reducer] +public static void SendMessage(ReducerContext ctx, SendMessageSchedule schedule) { - [SpacetimeDB.Table] - public partial struct SendMessageTimer + if (ctx.Sender != ctx.Identity) { - public string Text; // fields of original struct + throw new Exception("Reducer SendMessage may not be invoked by clients, only via scheduling."); + } + // ... +} +``` + +# Automatic migrations + +When you `spacetime publish` a module that has already been published using `spacetime publish `, +SpacetimeDB attempts to automatically migrate your existing database to the new schema. (The "schema" is just the collection +of tables and reducers you've declared in your code, together with the types they depend on.) This form of migration is limited and only supports a few kinds of changes. +On the plus side, automatic migrations usually don't break clients. The situations that may break clients are documented below. + +The following changes are always allowed and never breaking: + +- ✅ **Adding tables**. Non-updated clients will not be able to see the new tables. +- ✅ **Adding indexes**. +- ✅ **Adding or removing `[AutoInc]` annotations.** +- ✅ **Changing tables from private to public**. +- ✅ **Adding reducers**. +- ✅ **Removing `[Unique]` annotations.** + +The following changes are allowed, but may break clients: + +- ⚠️ **Changing or removing reducers**. Clients that attempt to call the old version of a changed reducer will receive runtime errors. +- ⚠️ **Changing tables from public to private**. Clients that are subscribed to a newly-private table will receive runtime errors. +- ⚠️ **Removing `[PrimaryKey]` annotations**. Non-updated clients will still use the old `[PrimaryKey]` as a unique key in their local cache, which can result in non-deterministic behavior when updates are received. +- ⚠️ **Removing indexes**. This is only breaking in some situtations. + The specific problem is subscription queries involving semijoins, such as: + ```sql + SELECT Employee.* + FROM Employee JOIN Dept + ON Employee.DeptName = Dept.DeptName + ) + ``` + For performance reasons, SpacetimeDB will only allow this kind of subscription query if there are indexes on `Employee.DeptName` and `Dept.DeptName`. Removing either of these indexes will invalidate this subscription query, resulting in client-side runtime errors. + +The following changes are forbidden without a manual migration: - [SpacetimeDB.Column(ColumnAttrs.PrimaryKeyAuto)] - public ulong ScheduledId; // unique identifier to be used internally +- ❌ **Removing tables**. +- ❌ **Changing the columns of a table**. This includes changing the order of columns of a table. +- ❌ **Changing whether a table is used for [scheduling](#scheduled-reducers).** +- ❌ **Adding `[Unique]` or `[PrimaryKey]` constraints.** This could result in existing tables being in an invalid state. - public SpacetimeDB.ScheduleAt ScheduleAt; // Scheduling details (Time or Inteval) +Currently, manual migration support is limited. The `spacetime publish --clear-database ` command can be used to **COMPLETELY DELETE** and reinitialize your database, but naturally it should be used with EXTREME CAUTION. + +# Other infrastructure + +## Class `Log` + +```csharp +namespace SpacetimeDB +{ + public static class Log + { + public static void Debug(string message); + public static void Error(string message); + public static void Exception(string message); + public static void Exception(Exception exception); + public static void Info(string message); + public static void Trace(string message); + public static void Warn(string message); } } - -// `ScheduledAt` definition -public abstract partial record ScheduleAt: SpacetimeDB.TaggedEnum<(DateTimeOffset Time, TimeSpan Interval)> ``` -#### Special reducers +Methods for writing to a private debug log. Log messages will include file and line numbers. -These are four special kinds of reducers that can be used to respond to module lifecycle events. They're stored in the `SpacetimeDB.Module.ReducerKind` class and can be used as an argument to the `[SpacetimeDB.Reducer]` attribute: +Log outputs of a running module can be inspected using the `spacetime logs` command: -- `ReducerKind.Init` - this reducer will be invoked when the module is first published. -- `ReducerKind.Update` - this reducer will be invoked when the module is updated. -- `ReducerKind.Connect` - this reducer will be invoked when a client connects to the database. -- `ReducerKind.Disconnect` - this reducer will be invoked when a client disconnects from the database. +```text +spacetime logs +``` + +These are only visible to the database owner, not to clients or other developers. + +Note that `Log.Error` and `Log.Exception` only write to the log, they do not throw exceptions themselves. Example: -````csharp -[SpacetimeDB.Reducer(ReducerKind.Init)] -public static void Init() +```csharp +using SpacetimeDB; + +public static partial class Module { + [Table(Name = "user")] + public partial struct User { + [PrimaryKey] + uint Id; + [Unique] + string Username; + ulong DogCount; + } + + [Reducer] + public static void LogDogs(ReducerContext ctx) { + Log.Info("Examining users."); + + var totalDogCount = 0; + + foreach (var user in ctx.Db.user.Iter()) { + Log.Info($" User: Id = {user.Id}, Username = {user.Username}, DogCount = {user.DogCount}"); + + totalDogCount += user.DogCount; + } + + if (totalDogCount < 300) { + Log.Warn("Insufficient dogs."); + } + + if (totalDogCount < 100) { + Log.Error("Dog population is critically low!"); + } + } +} +``` + +## Attribute `[SpacetimeDB.Type]` + +This attribute makes types self-describing, allowing them to automatically register their structure +with SpacetimeDB. Any C# type annotated with `[SpacetimeDB.Type]` can be used as a table column or reducer argument. + +Types marked `[SpacetimeDB.Table]` are automatically marked `[SpacetimeDB.Type]`. + +`[SpacetimeDB.Type]` can be combined with [`SpacetimeDB.TaggedEnum`] to use tagged enums in tables or reducers. + +```csharp +using SpacetimeDB; + +public static partial class Module { + + [Type] + public partial struct Coord { + public int X; + public int Y; + } + + [Type] + public partial struct TankData { + public int Ammo; + public int LeftTreadHealth; + public int RightTreadHealth; + } + + [Type] + public partial struct TransportData { + public int TroopCount; + } + + // A type that could be either the data for a Tank or the data for a Transport. + // See SpacetimeDB.TaggedEnum docs. + [Type] + public partial record VehicleData : TaggedEnum<(TankData Tank, TransportData Transport)> {} + + [Table(Name = "vehicle")] + public partial struct Vehicle { + [PrimaryKey] + [AutoInc] + public uint Id; + public Coord Coord; + public VehicleData Data; + } + + [SpacetimeDB.Reducer] + public static void InsertVehicle(ReducerContext ctx, Coord Coord, VehicleData Data) { + ctx.Db.vehicle.Insert(new Vehicle { Id = 0, Coord = Coord, Data = Data }); + } +} +``` + +The fields of the struct/enum must also be marked with `[SpacetimeDB.Type]`. + +Some types from the standard library are also considered to be marked with `[SpacetimeDB.Type]`, including: +- `byte` +- `sbyte` +- `ushort` +- `short` +- `uint` +- `int` +- `ulong` +- `long` +- `SpacetimeDB.U128` +- `SpacetimeDB.I128` +- `SpacetimeDB.U256` +- `SpacetimeDB.I256` +- `List` where `T` is a `[SpacetimeDB.Type]` + +## Struct `Identity` + +```csharp +namespace SpacetimeDB; + +public readonly record struct Identity { - Log("...and we're live!"); + public static Identity FromHexString(string hex); + public string ToString(); } +``` + +An `Identity` for something interacting with the database. + +This is a record struct, so it can be printed, compared with `==`, and used as a `Dictionary` key. + +`ToString()` returns a hex encoding of the Identity, suitable for printing. -[SpacetimeDB.Reducer(ReducerKind.Update)] -public static void Update() + + +## Struct `ConnectionId` + +```csharp +namespace SpacetimeDB; + +public readonly record struct ConnectionId { - Log("Update get!"); + public static ConnectionId? FromHexString(string hex); + public string ToString(); } +``` + +A unique identifier for a client connection to a SpacetimeDB database. + +This is a record struct, so it can be printed, compared with `==`, and used as a `Dictionary` key. + +`ToString()` returns a hex encoding of the `ConnectionId`, suitable for printing. -[SpacetimeDB.Reducer(ReducerKind.Connect)] -public static void OnConnect(DbEventArgs ctx) +## Struct `Timestamp` + +```csharp +namespace SpacetimeDB; + +public record struct Timestamp(long MicrosecondsSinceUnixEpoch) + : IStructuralReadWrite, + IComparable { - Log($"{ctx.Sender} has connected from {ctx.Address}!"); + // ... +} +``` + +A point in time, measured in microseconds since the Unix epoch. +This can be converted to/from a standard library [`DateTimeOffset`]. It is provided for consistency of behavior between SpacetimeDB's supported module and SDK languages. + +| Name | Description | +| ------------------------------------- | ----------------------------------------------------- | +| Property `MicrosecondsSinceUnixEpoch` | Microseconds since the [unix epoch]. | +| Conversion to/from `DateTimeOffset` | Convert to/from a standard library [`DateTimeOffset`] | +| Static property `UNIX_EPOCH` | The [unix epoch] as a `Timestamp` | +| Method `TimeDurationSince` | Measure the time elapsed since another `Timestamp` | +| Operator `+` | Add a [`TimeDuration`] to a `Timestamp` | +| Method `CompareTo` | Compare to another `Timestamp` | + +### Property `Timestamp.MicrosecondsSinceUnixEpoch` + +```csharp +long MicrosecondsSinceUnixEpoch; +``` + +The number of microseconds since the [unix epoch]. + +A positive value means a time after the Unix epoch, and a negative value means a time before. + +### Conversion to/from `DateTimeOffset` + +```csharp +public static implicit operator DateTimeOffset(Timestamp t); +public static implicit operator Timestamp(DateTimeOffset offset); +``` +`Timestamp` may be converted to/from a [`DateTimeOffset`], but the conversion can lose precision. +This type has less precision than DateTimeOffset (units of microseconds rather than units of 100ns). + +### Static property `Timestamp.UNIX_EPOCH` +```csharp +public static readonly Timestamp UNIX_EPOCH = new Timestamp { MicrosecondsSinceUnixEpoch = 0 }; +``` + +The [unix epoch] as a `Timestamp`. + +### Method `Timestamp.TimeDurationSince` +```csharp +public readonly TimeDuration TimeDurationSince(Timestamp earlier) => +``` + +Create a new [`TimeDuration`] that is the difference between two `Timestamps`. + +### Operator `Timestamp.+` +```csharp +public static Timestamp operator +(Timestamp point, TimeDuration interval); +``` + +Create a new `Timestamp` that occurs `interval` after `point`. + +### Method `Timestamp.CompareTo` +```csharp +public int CompareTo(Timestamp that) +``` + +Compare two `Timestamp`s. + +## Struct `TimeDuration` +```csharp +namespace SpacetimeDB; + +public record struct TimeDuration(long Microseconds) : IStructuralReadWrite { + // ... } +``` + +A duration that represents an interval between two [`Timestamp`]s. + +This type may be converted to/from a [`TimeSpan`]. It is provided for consistency of behavior between SpacetimeDB's supported module and SDK languages. + +| Name | Description | +| ------------------------------------------------------------- | ------------------------------------------------- | +| Property [`Microseconds`](#property-timedurationmicroseconds) | Microseconds between the [`Timestamp`]s. | +| [Conversion to/from `TimeSpan`](#conversion-tofrom-timespan) | Convert to/from a standard library [`TimeSpan`] | +| Static property [`ZERO`](#static-property-timedurationzero) | The duration between any [`Timestamp`] and itself | + +### Property `TimeDuration.Microseconds` +```csharp +long Microseconds; +``` + +The number of microseconds between two [`Timestamp`]s. + +### Conversion to/from `TimeSpan` +```csharp +public static implicit operator TimeSpan(TimeDuration d) => + new(d.Microseconds * Util.TicksPerMicrosecond); + +public static implicit operator TimeDuration(TimeSpan timeSpan) => + new(timeSpan.Ticks / Util.TicksPerMicrosecond); +``` + +`TimeDuration` may be converted to/from a [`TimeSpan`], but the conversion can lose precision. +This type has less precision than [`TimeSpan`] (units of microseconds rather than units of 100ns). + +### Static property `TimeDuration.ZERO` +```csharp +public static readonly TimeDuration ZERO = new TimeDuration { Microseconds = 0 }; +``` -[SpacetimeDB.Reducer(ReducerKind.Disconnect)] -public static void OnDisconnect(DbEventArgs ctx) +The duration between any `Timestamp` and itself. + +## Record `TaggedEnum` +```csharp +namespace SpacetimeDB; + +public abstract record TaggedEnum : IEquatable> where Variants : struct, ITuple +``` + +A [tagged enum](https://en.wikipedia.org/wiki/Tagged_union) is a type that can hold a value from any one of several types. `TaggedEnum` uses code generation to accomplish this. + +For example, to declare a type that can be either a `string` or an `int`, write: + +```csharp +[SpacetimeDB.Type] +public partial record ProductId : SpacetimeDB.TaggedEnum<(string Text, uint Number)> { } +``` + +Here there are two **variants**: one is named `Text` and holds a `string`, the other is named `Number` and holds a `uint`. + +To create a value of this type, use `new {Type}.{Variant}({data})`. For example: + +```csharp +ProductId a = new ProductId.Text("apple"); +ProductId b = new ProductId.Number(57); +ProductId c = new ProductId.Number(59); +``` + +To use a value of this type, you need to check which variant it stores. +This is done with [C# pattern matching syntax](https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/functional/pattern-matching). For example: + +```csharp +public static void Print(ProductId id) { - Log($"{ctx.Sender} has disconnected."); -}``` -```` + if (id is ProductId.Text(var s)) + { + Log.Info($"Textual product ID: '{s}'"); + } + else if (id is ProductId.Number(var i)) + { + Log.Info($"Numeric Product ID: {i}"); + } +} +``` + +A `TaggedEnum` can have up to 255 variants, and the variants can be any type marked with [`[SpacetimeDB.Type]`]. + +```csharp +[SpacetimeDB.Type] +public partial record ManyChoices : SpacetimeDB.TaggedEnum<( + string String, + int Int, + List IntList, + Banana Banana, + List> BananaMatrix +)> { } + +[SpacetimeDB.Type] +public partial struct Banana { + public int Sweetness; + public int Rot; +} +``` + +`TaggedEnums` are an excellent alternative to nullable fields when groups of fields are always set together. Consider a data type like: + +```csharp +[SpacetimeDB.Type] +public partial struct ShapeData { + public int? CircleRadius; + public int? RectWidth; + public int? RectHeight; +} +``` + +Often this is supposed to be a circle XOR a rectangle -- that is, not both at the same time. If this is the case, then we don't want to set `circleRadius` at the same time as `rectWidth` or `rectHeight`. Also, if `rectWidth` is set, we expect `rectHeight` to be set. +However, C# doesn't know about this, so code using this type will be littered with extra null checks. + +If we instead write: + +```csharp +[SpacetimeDB.Type] +public partial struct CircleData { + public int Radius; +} + +[SpacetimeDB.Type] +public partial struct RectData { + public int Width; + public int Height; +} + +[SpacetimeDB.Type] +public partial record ShapeData : SpacetimeDB.TaggedEnum<(CircleData Circle, RectData Rect)> { } +``` + +Then code using a `ShapeData` will only have to do one check -- do I have a circle or a rectangle? +And in each case, the data will be guaranteed to have exactly the fields needed. + +## Record `ScheduleAt` +```csharp +namespace SpacetimeDB; + +public partial record ScheduleAt : TaggedEnum<(TimeDuration Interval, Timestamp Time)> +``` + +When a [scheduled reducer](#scheduled-reducers) should execute, either at a specific point in time, or at regular intervals for repeating schedules. + +Stored in reducer-scheduling tables as a column. + +[demo]: /#demo +[client]: https://spacetimedb.com/docs/#client +[clients]: https://spacetimedb.com/docs/#client +[client SDK documentation]: https://spacetimedb.com/docs/#client +[host]: https://spacetimedb.com/docs/#host +[`DateTimeOffset`]: https://learn.microsoft.com/en-us/dotnet/api/system.datetimeoffset?view=net-9.0 +[`TimeSpan`]: https://learn.microsoft.com/en-us/dotnet/api/system.timespan?view=net-9.0 +[unix epoch]: https://en.wikipedia.org/wiki/Unix_time +[`System.Random`]: https://learn.microsoft.com/en-us/dotnet/api/system.random?view=net-9.0 \ No newline at end of file diff --git a/docs/modules/c-sharp/quickstart.md b/docs/modules/c-sharp/quickstart.md index 571351c1..86bcf16f 100644 --- a/docs/modules/c-sharp/quickstart.md +++ b/docs/modules/c-sharp/quickstart.md @@ -10,8 +10,6 @@ Each table is defined as a C# `class` annotated with `[SpacetimeDB.Table]`, wher By default, tables are **private**. This means that they are only readable by the table owner, and by server module code. The `[SpacetimeDB.Table(Public = true))]` annotation makes a table public. **Public** tables are readable by all users, but can still only be modified by your server module code. -_Coming soon: We plan to add much more robust access controls than just public or private tables. Stay tuned!_ - A reducer is a function which traverses and updates the database. Each reducer call runs in its own transaction, and its updates to the database are only committed if the reducer returns successfully. In C#, reducers are defined as functions annotated with `[SpacetimeDB.Reducer]`. If an exception is thrown, the reducer call fails, the database is not updated, and a failed message is reported to the client. ## Install SpacetimeDB @@ -57,21 +55,18 @@ spacetime init --lang csharp server 2. Open `server/Lib.cs`, a trivial module. 3. Clear it out, so we can write a new module that's still pretty simple: a bare-bones chat server. +To start, we'll need to add `SpacetimeDB` to our using statements. This will give us access to everything we need to author our SpacetimeDB server module. + To the top of `server/Lib.cs`, add some imports we'll be using: ```csharp -using System.Runtime.CompilerServices; -using SpacetimeDB.Module; -using static SpacetimeDB.Runtime; +using SpacetimeDB; ``` -- `SpacetimeDB.Module` contains the special attributes we'll use to define tables and reducers in our module. -- `SpacetimeDB.Runtime` contains the raw API bindings SpacetimeDB uses to communicate with the database. - We also need to create our static module class which all of the module code will live in. In `server/Lib.cs`, add: ```csharp -static partial class Module +public static partial class Module { } ``` @@ -85,10 +80,10 @@ For each `User`, we'll store their `Identity`, an optional name they can set to In `server/Lib.cs`, add the definition of the table `User` to the `Module` class: ```csharp -[SpacetimeDB.Table(Public = true)] +[Table(Name = "user", Public = true)] public partial class User { - [SpacetimeDB.Column(ColumnAttrs.PrimaryKey)] + [PrimaryKey] public Identity Identity; public string? Name; public bool Online; @@ -100,11 +95,11 @@ For each `Message`, we'll store the `Identity` of the user who sent it, the `Tim In `server/Lib.cs`, add the definition of the table `Message` to the `Module` class: ```csharp -[SpacetimeDB.Table(Public = true)] +[Table(Name = "message", Public = true)] public partial class Message { public Identity Sender; - public long Sent; + public Timestamp Sent; public string Text = ""; } ``` @@ -113,23 +108,23 @@ public partial class Message We want to allow users to set their names, because `Identity` is not a terribly user-friendly identifier. To that effect, we define a reducer `SetName` which clients can invoke to set their `User.Name`. It will validate the caller's chosen name, using a function `ValidateName` which we'll define next, then look up the `User` record for the caller and update it to store the validated name. If the name fails the validation, the reducer will fail. -Each reducer may accept as its first argument a `ReducerContext`, which includes the `Identity` and `Address` of the client that called the reducer, and the `Timestamp` when it was invoked. For now, we only need the `Identity`, `ctx.Sender`. +Each reducer must accept as its first argument a `ReducerContext`, which includes contextual data such as the `Sender` which contains the Identity of the client that called the reducer, and the `Timestamp` when it was invoked. For now, we only need the `Sender`. It's also possible to call `SetName` via the SpacetimeDB CLI's `spacetime call` command without a connection, in which case no `User` record will exist for the caller. We'll return an error in this case, but you could alter the reducer to insert a `User` row for the module owner. You'll have to decide whether the module owner is always online or always offline, though. In `server/Lib.cs`, add to the `Module` class: ```csharp -[SpacetimeDB.Reducer] +[Reducer] public static void SetName(ReducerContext ctx, string name) { name = ValidateName(name); - var user = User.FindByIdentity(ctx.Sender); + var user = ctx.Db.user.Identity.Find(ctx.Sender); if (user is not null) { user.Name = name; - User.UpdateByIdentity(ctx.Sender, user); + ctx.Db.user.Identity.Update(user); } } ``` @@ -146,7 +141,7 @@ In `server/Lib.cs`, add to the `Module` class: ```csharp /// Takes a name and checks if it's acceptable as a user's name. -public static string ValidateName(string name) +private static string ValidateName(string name) { if (string.IsNullOrEmpty(name)) { @@ -163,17 +158,19 @@ We define a reducer `SendMessage`, which clients will call to send messages. It In `server/Lib.cs`, add to the `Module` class: ```csharp -[SpacetimeDB.Reducer] +[Reducer] public static void SendMessage(ReducerContext ctx, string text) { text = ValidateMessage(text); - Log(text); - new Message - { - Sender = ctx.Sender, - Text = text, - Sent = ctx.Time.ToUnixTimeMilliseconds(), - }.Insert(); + Log.Info(text); + ctx.Db.message.Insert( + new Message + { + Sender = ctx.Sender, + Text = text, + Sent = ctx.Timestamp, + } + ); } ``` @@ -183,7 +180,7 @@ In `server/Lib.cs`, add to the `Module` class: ```csharp /// Takes a message's text and checks if it's acceptable to send. -public static string ValidateMessage(string text) +private static string ValidateMessage(string text) { if (string.IsNullOrEmpty(text)) { @@ -202,58 +199,60 @@ You could extend the validation in `ValidateMessage` in similar ways to `Validat In C# modules, you can register for `Connect` and `Disconnect` events by using a special `ReducerKind`. We'll use the `Connect` event to create a `User` record for the client if it doesn't yet exist, and to set its online status. -We'll use `User.FindByIdentity` to look up a `User` row for `ctx.Sender`, if one exists. If we find one, we'll use `User.UpdateByIdentity` to overwrite it with a row that has `Online: true`. If not, we'll use `User.Insert` to insert a new row for our new user. All three of these methods are generated by the `[SpacetimeDB.Table]` attribute, with rows and behavior based on the row attributes. `FindByIdentity` returns a nullable `User`, because the unique constraint from the `[SpacetimeDB.Column(ColumnAttrs.PrimaryKey)]` attribute means there will be either zero or one matching rows. `Insert` will throw an exception if the insert violates this constraint; if we want to overwrite a `User` row, we need to do so explicitly using `UpdateByIdentity`. +We'll use `reducerContext.Db.User.Identity.Find` to look up a `User` row for `ctx.Sender`, if one exists. If we find one, we'll use `reducerContext.Db.User.Identity.Update` to overwrite it with a row that has `Online: true`. If not, we'll use `User.Insert` to insert a new row for our new user. All three of these methods are generated by the `[SpacetimeDB.Table]` attribute, with rows and behavior based on the row attributes. `User.Identity.Find` returns a nullable `User`, because the unique constraint from the `[PrimaryKey]` attribute means there will be either zero or one matching rows. `Insert` will throw an exception if the insert violates this constraint; if we want to overwrite a `User` row, we need to do so explicitly using `User.Identity.Update`. In `server/Lib.cs`, add the definition of the connect reducer to the `Module` class: ```csharp -[SpacetimeDB.Reducer(ReducerKind.Connect)] -public static void OnConnect(ReducerContext ReducerContext) +[Reducer(ReducerKind.ClientConnected)] +public static void ClientConnected(ReducerContext ctx) { - Log($"Connect {ReducerContext.Sender}"); - var user = User.FindByIdentity(ReducerContext.Sender); + Log.Info($"Connect {ctx.Sender}"); + var user = ctx.Db.user.Identity.Find(ctx.Sender); if (user is not null) { // If this is a returning user, i.e., we already have a `User` with this `Identity`, // set `Online: true`, but leave `Name` and `Identity` unchanged. user.Online = true; - User.UpdateByIdentity(ReducerContext.Sender, user); + ctx.Db.user.Identity.Update(user); } else { // If this is a new user, create a `User` object for the `Identity`, // which is online, but hasn't set a name. - new User - { - Name = null, - Identity = ReducerContext.Sender, - Online = true, - }.Insert(); + ctx.Db.user.Insert( + new User + { + Name = null, + Identity = ctx.Sender, + Online = true, + } + ); } } ``` -Similarly, whenever a client disconnects, the module will execute the `OnDisconnect` event if it's registered with `ReducerKind.Disconnect`. We'll use it to un-set the `Online` status of the `User` for the disconnected client. +Similarly, whenever a client disconnects, the module will execute the `OnDisconnect` event if it's registered with `ReducerKind.ClientDisconnected`. We'll use it to un-set the `Online` status of the `User` for the disconnected client. Add the following code after the `OnConnect` handler: ```csharp -[SpacetimeDB.Reducer(ReducerKind.Disconnect)] -public static void OnDisconnect(ReducerContext ReducerContext) +[Reducer(ReducerKind.ClientDisconnected)] +public static void ClientDisconnected(ReducerContext ctx) { - var user = User.FindByIdentity(ReducerContext.Sender); + var user = ctx.Db.user.Identity.Find(ctx.Sender); if (user is not null) { // This user should exist, so set `Online: false`. user.Online = false; - User.UpdateByIdentity(ReducerContext.Sender, user); + ctx.Db.user.Identity.Update(user); } else { // User does not exist, log warning - Log("Warning: No user found for disconnected client."); + Log.Warn("Warning: No user found for disconnected client."); } } ``` @@ -264,30 +263,28 @@ If you haven't already started the SpacetimeDB server, run the `spacetime start` ## Publish the module -And that's all of our module code! We'll run `spacetime publish` to compile our module and publish it on SpacetimeDB. `spacetime publish` takes an optional name which will map to the database's unique address. Clients can connect either by name or by address, but names are much more pleasant. Come up with a unique name, and fill it in where we've written ``. +And that's all of our module code! We'll run `spacetime publish` to compile our module and publish it on SpacetimeDB. `spacetime publish` takes an optional name which will map to the database's unique address. Clients can connect either by name or by address, but names are much more pleasant. In this example, we'll be using `quickstart-chat`. Feel free to come up with a unique name, and in the CLI commands, replace where we've written `quickstart-chat` with the name you chose. From the `quickstart-chat` directory, run: ```bash -spacetime publish --project-path server +spacetime publish --project-path server quickstart-chat ``` -```bash -npm i wasm-opt -g -``` +Note: If the WebAssembly optimizer `wasm-opt` is installed, `spacetime publish` will automatically optimize the Web Assembly output of the published module. Instruction for installing the `wasm-opt` binary can be found in [Rust's wasm-opt documentation](https://docs.rs/wasm-opt/latest/wasm_opt/). ## Call Reducers You can use the CLI (command line interface) to run reducers. The arguments to the reducer are passed in JSON format. ```bash -spacetime call SendMessage "Hello, World!" +spacetime call quickstart-chat SendMessage "Hello, World!" ``` Once we've called our `SendMessage` reducer, we can check to make sure it ran by running the `logs` command. ```bash -spacetime logs +spacetime logs quickstart-chat ``` You should now see the output that your module printed in the database. @@ -301,7 +298,7 @@ info: Hello, World! SpacetimeDB supports a subset of the SQL syntax so that you can easily query the data of your database. We can run a query using the `sql` command. ```bash -spacetime sql "SELECT * FROM Message" +spacetime sql quickstart-chat "SELECT * FROM Message" ``` ```bash @@ -312,6 +309,8 @@ spacetime sql "SELECT * FROM Message" ## What's next? -You've just set up your first database in SpacetimeDB! The next step would be to create a client module that interacts with this module. You can use any of SpacetimDB's supported client languages to do this. Take a look at the quick start guide for your client language of choice: [Rust](/docs/sdks/rust/quickstart), [C#](/docs/sdks/c-sharp/quickstart), or [TypeScript](/docs/sdks/typescript/quickstart). +You've just set up your first database in SpacetimeDB! You can find the full code for this client [in the C# server module example](https://github.com/clockworklabs/com.clockworklabs.spacetimedbsdk/tree/master/examples~/quickstart-chat/server). + +The next step would be to create a client module that interacts with this module. You can use any of SpacetimDB's supported client languages to do this. Take a look at the quick start guide for your client language of choice: [Rust](/docs/sdks/rust/quickstart), [C#](/docs/sdks/c-sharp/quickstart), or [TypeScript](/docs/sdks/typescript/quickstart). -If you are planning to use SpacetimeDB with the Unity game engine, you can skip right to the [Unity Comprehensive Tutorial](/docs/unity/part-1) or check out our example game, [BitcraftMini](/docs/unity/part-3). +If you are planning to use SpacetimeDB with the Unity game engine, you can skip right to the [Unity Comprehensive Tutorial](/docs/unity/part-1). diff --git a/docs/modules/index.md b/docs/modules/index.md index d7d13685..78d60d9c 100644 --- a/docs/modules/index.md +++ b/docs/modules/index.md @@ -8,7 +8,7 @@ In the following sections, we'll cover the basics of server modules and how to c ### Rust -As of SpacetimeDB 0.6, Rust is the only fully supported language for server modules. Rust is a great option for server modules because it is fast, safe, and has a small runtime. +Rust is the only fully supported language for server modules. Rust is a great option for server modules because it is fast, safe, and has a small runtime. - [Rust Module Reference](/docs/modules/rust) - [Rust Module Quickstart Guide](/docs/modules/rust/quickstart) @@ -19,12 +19,3 @@ We have C# support available in experimental status. C# can be a good choice for - [C# Module Reference](/docs/modules/c-sharp) - [C# Module Quickstart Guide](/docs/modules/c-sharp/quickstart) - -### Coming Soon - -We have plans to support additional languages in the future. - -- Python -- Typescript -- C++ -- Lua diff --git a/docs/modules/rust/index.md b/docs/modules/rust/index.md index dba75ab2..a8681954 100644 --- a/docs/modules/rust/index.md +++ b/docs/modules/rust/index.md @@ -1,525 +1,4 @@ -# SpacetimeDB Rust Modules +# Rust Module SDK Reference -Rust clients of SpacetimeDB use the [Rust SpacetimeDB module library][module library] to write modules which interact with the SpacetimeDB database. +The Rust Module SDK docs are [hosted on docs.rs](https://docs.rs/spacetimedb/latest/spacetimedb/). -First, the `spacetimedb` library provides a number of macros for creating tables and Rust `struct`s corresponding to rows in those tables. - -Then the client API allows interacting with the database inside special functions called reducers. - -This guide assumes you are familiar with some basics of Rust. At the very least, you should be familiar with the idea of using attribute macros. An extremely common example is `derive` macros. - -Derive macros look at the type they are attached to and generate some related code. In this example, `#[derive(Debug)]` generates the formatting code needed to print out a `Location` for debugging purposes. - -```rust -#[derive(Debug)] -struct Location { - x: u32, - y: u32, -} -``` - -## SpacetimeDB Macro basics - -Let's start with a highly commented example, straight from the [demo]. This Rust package defines a SpacetimeDB module, with types we can operate on and functions we can run. - -```rust -// In this small example, we have two Rust imports: -// |spacetimedb::spacetimedb| is the most important attribute we'll be using. -// |spacetimedb::println| is like regular old |println|, but outputting to the module's logs. -use spacetimedb::{spacetimedb, println}; - -// This macro lets us interact with a SpacetimeDB table of Person rows. -// We can insert and delete into, and query, this table by the collection -// of functions generated by the macro. -#[table(name = person, public)] -pub struct Person { - name: String, -} - -// This is the other key macro we will be using. A reducer is a -// stored procedure that lives in the database, and which can -// be invoked remotely. -#[reducer] -pub fn add(ctx: &ReducerContext, name: String) { - // |Person| is a totally ordinary Rust struct. We can construct - // one from the given name as we typically would. - let person = Person { name }; - - // Here's our first generated function! Given a |Person| object, - // we can insert it into the table: - ctx.db.person().insert(person); -} - -// Here's another reducer. Notice that this one doesn't take any arguments, while -// |add| did take one. Reducers can take any number of arguments, as long as -// SpacetimeDB recognizes their types. Reducers also have to be top level -// functions, not methods. -#[reducer] -pub fn say_hello(ctx: &ReducerContext) { - // Here's the next of our generated functions: |iter()|. This - // iterates over all the columns in the |Person| table in SpacetimeDB. - for person in ctx.db.person().iter() { - // Reducers run in a very constrained and sandboxed environment, - // and in particular, can't do most I/O from the Rust standard library. - // We provide an alternative |spacetimedb::println| which is just like - // the std version, excepted it is redirected out to the module's logs. - println!("Hello, {}!", person.name); - } - println!("Hello, World!"); -} - -// Reducers can't return values, but can return errors. To do so, -// the reducer must have a return type of `Result<(), T>`, for any `T` that -// implements `Debug`. Such errors returned from reducers will be formatted and -// printed out to logs. -#[reducer] -pub fn add_person(ctx: &ReducerContext, name: String) -> Result<(), String> { - if name.is_empty() { - return Err("Name cannot be empty"); - } - - ctx.db.person().insert(Person { name }) -} -``` - -## Macro API - -Now we'll get into details on all the macro APIs SpacetimeDB provides, starting with all the variants of the `spacetimedb` attribute. - -### Defining tables - -The `#[table(name = table_name)]` macro is applied to a Rust struct with named fields. -By default, tables are considered **private**. This means that they are only readable by the table owner, and by server module code. -The `#[table(name = table_name, public)]` macro makes a table public. **Public** tables are readable by all users, but can still only be modified by your server module code. - -_Coming soon: We plan to add much more robust access controls than just public or private. Stay tuned!_ - -```rust -#[table(name = my_table, public)] -struct MyTable { - field1: String, - field2: u32, -} -``` - -This attribute is applied to Rust structs in order to create corresponding tables in SpacetimeDB. Fields of the Rust struct correspond to columns of the database table. - -The fields of the struct have to be types that SpacetimeDB knows how to encode into the database. This is captured in Rust by the `SpacetimeType` trait. - -This is automatically defined for built in numeric types: - -- `bool` -- `u8`, `u16`, `u32`, `u64`, `u128` -- `i8`, `i16`, `i32`, `i64`, `i128` -- `f32`, `f64` - -And common data structures: - -- `String` and `&str`, utf-8 string data -- `()`, the unit type -- `Option where T: SpacetimeType` -- `Vec where T: SpacetimeType` - -All `#[table(..)]` types are `SpacetimeType`s, and accordingly, all of their fields have to be. - -```rust -#[table(name = another_table, public)] -struct AnotherTable { - // Fine, some builtin types. - id: u64, - name: Option, - - // Fine, another table type. - table: Table, - - // Fine, another type we explicitly make serializable. - serial: Serial, -} -``` - -If you want to have a field that is not one of the above primitive types, and not a table of its own, you can derive the `SpacetimeType` attribute on it. - -We can derive `SpacetimeType` on `struct`s and `enum`s with members that are themselves `SpacetimeType`s. - -```rust -#[derive(SpacetimeType)] -enum Serial { - Builtin(f64), - Compound { - s: String, - bs: Vec, - } -} -``` - -Once the table is created via the macro, other attributes described below can control more aspects of the table. For instance, a particular column can be indexed, or take on values of an automatically incremented counter. These are described in detail below. - -```rust -#[table(name = person, public)] -struct Person { - #[unique] - id: u64, - - name: String, - address: String, -} -``` - -You can create multiple tables backed by items of the same type by applying it with different names. For example, to store active and archived posts separately and with different privacy rules, you can declare two tables like this: - -```rust -#[table(name = post, public)] -#[table(name = archived_post)] -struct Post { - title: String, - body: String, -} -``` - -### Defining reducers - -`#[reducer]` is always applied to top level Rust functions. They can take arguments of types known to SpacetimeDB (just like fields of structs must be known to SpacetimeDB), and either return nothing, or return a `Result<(), E: Debug>`. - -```rust -#[reducer] -fn give_player_item(ctx: &ReducerContext, player_id: u64, item_id: u64) -> Result<(), GameErr> { - // Notice how the exact name of the filter function derives from - // the name of the field of the struct. - let mut item = ctx.db.item().item_id().find(id).ok_or(GameErr::InvalidId)?; - item.owner = Some(player_id); - ctx.db.item().item_id().update(item); - Ok(()) -} - -#[table(name = item, public)] -struct Item { - #[primary_key] - item_id: u64, - owner: Option, -} -``` - -Note that reducers can call non-reducer functions, including standard library functions. - -There are several macros which modify the semantics of a column, which are applied to the members of the table struct. `#[primary_key]`, `#[unique]` and `#[autoinc]` are covered below, describing how those attributes affect the semantics of inserting, filtering, and so on. - -#[SpacetimeType] - -#[sats] - -### Defining Scheduler Tables - -Tables can be used to schedule a reducer calls either at a specific timestamp or at regular intervals. - -```rust -// The `scheduled` attribute links this table to a reducer. -#[table(name = send_message_timer, scheduled(send_message)] -struct SendMessageTimer { - text: String, -} -``` - -The `scheduled` attribute adds a couple of default fields and expands as follows: - -```rust -#[table(name = send_message_timer, scheduled(send_message)] - struct SendMessageTimer { - text: String, // original field - #[primary_key] - #[autoinc] - scheduled_id: u64, // identifier for internal purpose - scheduled_at: ScheduleAt, //schedule details -} - -pub enum ScheduleAt { - /// A specific time at which the reducer is scheduled. - /// Value is a UNIX timestamp in microseconds. - Time(u64), - /// A regular interval at which the repeated reducer is scheduled. - /// Value is a duration in microseconds. - Interval(u64), -} -``` - -Managing timers with a scheduled table is as simple as inserting or deleting rows from the table. - -```rust -#[reducer] -// Reducers linked to the scheduler table should have their first argument as `&ReducerContext` -// and the second as an instance of the table struct it is linked to. -fn send_message(ctx: &ReducerContext, arg: SendMessageTimer) -> Result<(), String> { - // ... -} - -// Scheduling reducers inside `init` reducer -#[reducer(init)] -fn init(ctx: &ReducerContext) { - // Scheduling a reducer for a specific Timestamp - ctx.db.send_message_timer().insert(SendMessageTimer { - scheduled_id: 1, - text:"bot sending a message".to_string(), - //`spacetimedb::Timestamp` implements `From` trait to `ScheduleAt::Time`. - scheduled_at: ctx.timestamp.plus(Duration::from_secs(10)).into() - }); - - // Scheduling a reducer to be called at fixed interval of 100 milliseconds. - ctx.db.send_message_timer().insert(SendMessageTimer { - scheduled_id: 0, - text:"bot sending a message".to_string(), - //`std::time::Duration` implements `From` trait to `ScheduleAt::Duration`. - scheduled_at: duration!(100ms).into(), - }); -} -``` - -## Client API - -Besides the macros for creating tables and reducers, there's two other parts of the Rust SpacetimeDB library. One is a collection of macros for logging, and the other is all the automatically generated functions for operating on those tables. - -### `println!` and friends - -Because reducers run in a WASM sandbox, they don't have access to general purpose I/O from the Rust standard library. There's no filesystem or network access, and no input or output. This means no access to things like `std::println!`, which prints to standard output. - -SpacetimeDB modules have access to logging output. These are exposed as macros, just like their `std` equivalents. The names, and all the Rust formatting machinery, work the same; just the location of the output is different. - -Logs for a module can be viewed with the `spacetime logs` command from the CLI. - -```rust -use spacetimedb::{ - println, - print, - eprintln, - eprint, - dbg, -}; - -#[reducer] -fn output(ctx: &ReducerContext, i: i32) { - // These will be logged at log::Level::Info. - println!("an int with a trailing newline: {i}"); - print!("some more text...\n"); - - // These log at log::Level::Error. - eprint!("Oops..."); - eprintln!(", we hit an error"); - - // Just like std::dbg!, this prints its argument and returns the value, - // as a drop-in way to print expressions. So this will print out |i| - // before passing the value of |i| along to the calling function. - // - // The output is logged log::Level::Debug. - ctx.db.outputted_number().insert(dbg!(i)); -} -``` - -### Generated functions on a SpacetimeDB table - -We'll work off these structs to see what functions SpacetimeDB generates: - -This table has a plain old column. - -```rust -#[table(name = ordinary, public)] -struct Ordinary { - ordinary_field: u64, -} -``` - -This table has a unique column. Every row in the `Unique` table must have distinct values of the `unique_field` column. Attempting to insert a row with a duplicate value will fail. - -```rust -#[table(name = unique, public)] -struct Unique { - // A unique column: - #[unique] - unique_field: u64, -} -``` - -This table has an automatically incrementing column. SpacetimeDB automatically provides an incrementing sequence of values for this field, and sets the field to that value when you insert the row. - -Only integer types can be `#[unique]`: `u8`, `u16`, `u32`, `u64`, `u128`, `i8`, `i16`, `i32`, `i64` and `i128`. - -```rust -#[table(name = autoinc, public)] -struct Autoinc { - #[autoinc] - autoinc_field: u64, -} -``` - -These attributes can be combined, to create an automatically assigned ID usable for filtering. - -```rust -#[table(name = identity, public)] -struct Identity { - #[autoinc] - #[unique] - id_field: u64, -} -``` - -### Insertion - -We'll talk about insertion first, as there a couple of special semantics to know about. - -When we define |Ordinary| as a SpacetimeDB table, we get the ability to insert into it with the generated `ctx.db.ordinary().insert(..)` method. - -Inserting takes a single argument, the row to insert. When there are no unique fields in the row, the return value is the inserted row. - -```rust -#[reducer] -fn insert_ordinary(ctx: &ReducerContext, value: u64) { - let ordinary = Ordinary { ordinary_field: value }; - let result = ctx.db.ordinary().insert(ordinary); - assert_eq!(ordinary.ordinary_field, result.ordinary_field); -} -``` - -When there is a unique column constraint on the table, insertion can fail if a uniqueness constraint is violated. - -If we insert two rows which have the same value of a unique column, the second will fail. - -```rust -#[reducer] -fn insert_unique(ctx: &ReducerContext, value: u64) { - let result = ctx.db.unique().insert(Unique { unique_field: value }); - assert!(result.is_ok()); - - let result = ctx.db.unique().insert(Unique { unique_field: value }); - assert!(result.is_err()); -} -``` - -When inserting a table with an `#[autoinc]` column, the database will automatically overwrite whatever we give it with an atomically increasing value. - -The returned row has the `autoinc` column set to the value that was actually written into the database. - -```rust -#[reducer] -fn insert_autoinc(ctx: &ReducerContext) { - for i in 1..=10 { - // These will have values of 1, 2, ..., 10 - // at rest in the database, regardless of - // what value is actually present in the - // insert call. - let actual = ctx.db.autoinc().insert(Autoinc { autoinc_field: 23 }) - assert_eq!(actual.autoinc_field, i); - } -} - -#[reducer] -fn insert_id(ctx: &ReducerContext) { - for _ in 0..10 { - // These also will have values of 1, 2, ..., 10. - // There's no collision and silent failure to insert, - // because the value of the field is ignored and overwritten - // with the automatically incremented value. - ctx.db.identity().insert(Identity { id_field: 23 }) - } -} -``` - -### Iterating - -Given a table, we can iterate over all the rows in it. - -```rust -#[table(name = person, public)] -struct Person { - #[unique] - id: u64, - - #[index(btree)] - age: u32, - name: String, - address: String, -} -``` - -// Every table structure has a generated iter function, like: - -```rust -ctx.db.my_table().iter() -``` - -`iter()` returns a regular old Rust iterator, giving us a sequence of `Person`. The database sends us over rows, one at a time, for each time through the loop. This means we get them by value, and own the contents of `String` fields and so on. - -``` -#[reducer] -fn iteration(ctx: &ReducerContext) { - let mut addresses = HashSet::new(); - - for person in ctx.db.person().iter() { - addresses.insert(person.address); - } - - for address in addresses.iter() { - println!("{address}"); - } -} -``` - -### Filtering - -Often, we don't need to look at the entire table, and instead are looking for rows with specific values in certain columns. - -Our `Person` table has a unique id column, so we can filter for a row matching that ID. Since it is unique, we will find either 0 or 1 matching rows in the database. This gets represented naturally as an `Option` in Rust. SpacetimeDB automatically creates and uses indexes for filtering on unique columns, so it is very efficient. - -The name of the filter method just corresponds to the column name. - -```rust -#[reducer] -fn filtering(ctx: &ReducerContext, id: u64) { - match ctx.db.person().id().find(id) { - Some(person) => println!("Found {person}"), - None => println!("No person with id {id}"), - } -} -``` - -Our `Person` table also has an index on its `age` column. Unlike IDs, ages aren't unique. Filtering for every person who is 21, then, gives us an `Iterator` rather than an `Option`. - -```rust -#[reducer] -fn filtering_non_unique(ctx: &ReducerContext) { - for person in ctx.db.person().age().filter(21u32) { - println!("{} has turned 21", person.name); - } -} -``` - -> NOTE: An unfortunate interaction between Rust's trait solver and integer literal defaulting rules means that you must specify the types of integer literals passed to `filter` and `find` methods via the suffix syntax, like `21u32`. If you don't, you'll see a compiler error like: -> ``` -> error[E0271]: type mismatch resolving `::Column == u32` -> --> modules/rust-wasm-test/src/lib.rs:356:48 -> | -> 356 | for person in ctx.db.person().age().filter(21) { -> | ------ ^^ expected `u32`, found `i32` -> | | -> | required by a bound introduced by this call -> | -> = note: required for `i32` to implement `BTreeIndexBounds<(u32,), SingleBound>` -> note: required by a bound in `BTreeIndex::::filter` -> | -> 410 | pub fn filter(&self, b: B) -> impl Iterator -> | ------ required by a bound in this associated function -> 411 | where -> 412 | B: BTreeIndexBounds, -> | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `BTreeIndex::::filter` -> ``` - -### Deleting - -Like filtering, we can delete by an indexed or unique column instead of the entire row. - -```rust -#[reducer] -fn delete_id(ctx: &ReducerContext, id: u64) { - ctx.db.person().id().delete(id) -} -``` - -[macro library]: https://github.com/clockworklabs/SpacetimeDB/tree/master/crates/bindings-macro -[module library]: https://github.com/clockworklabs/SpacetimeDB/tree/master/crates/lib -[demo]: /#demo diff --git a/docs/modules/rust/quickstart.md b/docs/modules/rust/quickstart.md index 9fcfe30d..04b7d206 100644 --- a/docs/modules/rust/quickstart.md +++ b/docs/modules/rust/quickstart.md @@ -11,8 +11,6 @@ Each table is defined as a Rust struct annotated with `#[table(name = table_name By default, tables are **private**. This means that they are only readable by the table owner, and by server module code. The `#[table(name = table_name, public)]` macro makes a table public. **Public** tables are readable by all users but can still only be modified by your server module code. -_Coming soon: We plan to add much more robust access controls than just public or private. Stay tuned!_ - A reducer is a function that traverses and updates the database. Each reducer call runs in its own transaction, and its updates to the database are only committed if the reducer returns successfully. In Rust, reducers are defined as functions annotated with `#[reducer]`, and may return a `Result<()>`, with an `Err` return aborting the transaction. ## Install SpacetimeDB @@ -100,7 +98,7 @@ pub struct Message { We want to allow users to set their names, because `Identity` is not a terribly user-friendly identifier. To that effect, we define a reducer `set_name` which clients can invoke to set their `User.name`. It will validate the caller's chosen name, using a function `validate_name` which we'll define next, then look up the `User` record for the caller and update it to store the validated name. If the name fails the validation, the reducer will fail. -Each reducer may accept as its first argument a `ReducerContext`, which includes the `Identity` and `Address` of the client that called the reducer, and the `Timestamp` when it was invoked. It also allows us access to the `db`, which is used to read and manipulate rows in our tables. For now, we only need the `db`, `Identity`, and `ctx.sender`. +Each reducer must accept as its first argument a `ReducerContext`, which includes the `Identity` and `ConnectionId` of the client that called the reducer, and the `Timestamp` when it was invoked. It also allows us access to the `db`, which is used to read and manipulate rows in our tables. For now, we only need the `db`, `Identity`, and `ctx.sender`. It's also possible to call `set_name` via the SpacetimeDB CLI's `spacetime call` command without a connection, in which case no `User` record will exist for the caller. We'll return an error in this case, but you could alter the reducer to insert a `User` row for the module owner. You'll have to decide whether the module owner is always online or always offline, though. @@ -227,12 +225,12 @@ pub fn identity_disconnected(ctx: &ReducerContext) { ## Publish the module -And that's all of our module code! We'll run `spacetime publish` to compile our module and publish it on SpacetimeDB. `spacetime publish` takes an optional name which will map to the database's unique address. Clients can connect either by name or by address, but names are much more user-friendly. Come up with a unique name that contains only URL-safe characters (letters, numbers, hyphens and underscores), and fill it in where we've written ``. +And that's all of our module code! We'll run `spacetime publish` to compile our module and publish it on SpacetimeDB. `spacetime publish` takes an optional name which will map to the database's unique `Identity`. Clients can connect either by name or by `Identity`, but names are much more user-friendly. If you'd like, come up with a unique name that contains only URL-safe characters (letters, numbers, hyphens and underscores), and fill it in where we've written `quickstart-chat`. From the `quickstart-chat` directory, run: ```bash -spacetime publish --project-path server +spacetime publish --project-path server quickstart-chat ``` ## Call Reducers @@ -240,13 +238,13 @@ spacetime publish --project-path server You can use the CLI (command line interface) to run reducers. The arguments to the reducer are passed in JSON format. ```bash -spacetime call send_message 'Hello, World!' +spacetime call quickstart-chat send_message 'Hello, World!' ``` Once we've called our `send_message` reducer, we can check to make sure it ran by running the `logs` command. ```bash -spacetime logs +spacetime logs quickstart-chat ``` You should now see the output that your module printed in the database. @@ -263,7 +261,7 @@ You should now see the output that your module printed in the database. SpacetimeDB supports a subset of the SQL syntax so that you can easily query the data of your database. We can run a query using the `sql` command. ```bash -spacetime sql "SELECT * FROM message" +spacetime sql quickstart-chat "SELECT * FROM message" ``` ```bash @@ -278,4 +276,4 @@ You can find the full code for this module [in the SpacetimeDB module examples]( You've just set up your first database in SpacetimeDB! The next step would be to create a client module that interacts with this module. You can use any of SpacetimDB's supported client languages to do this. Take a look at the quickstart guide for your client language of choice: [Rust](/docs/sdks/rust/quickstart), [C#](/docs/sdks/c-sharp/quickstart), or [TypeScript](/docs/sdks/typescript/quickstart). -If you are planning to use SpacetimeDB with the Unity game engine, you can skip right to the [Unity Comprehensive Tutorial](/docs/unity/part-1) or check out our example game, [BitcraftMini](/docs/unity/part-3). +If you are planning to use SpacetimeDB with the Unity game engine, you can skip right to the [Unity Comprehensive Tutorial](/docs/unity/part-1). diff --git a/docs/nav.js b/docs/nav.js index bdf49517..aed58053 100644 --- a/docs/nav.js +++ b/docs/nav.js @@ -10,15 +10,16 @@ const nav = { page('Overview', 'index', 'index.md'), // TODO(BREAKING): For consistency & clarity, 'index' slug should be renamed 'intro'? page('Getting Started', 'getting-started', 'getting-started.md'), section('Deploying'), - page('Testnet', 'deploying/testnet', 'deploying/testnet.md'), - section('Migration Guides'), - page('v0.12', 'migration/v0.12', 'migration/v0.12.md'), + page('Maincloud', 'deploying/maincloud', 'deploying/maincloud.md'), section('Unity Tutorial - Basic Multiplayer'), page('Overview', 'unity', 'unity/index.md'), page('1 - Setup', 'unity/part-1', 'unity/part-1.md'), 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'), @@ -27,26 +28,28 @@ const nav = { page('C# Reference', 'modules/c-sharp', 'modules/c-sharp/index.md'), section('Client SDK Languages'), page('Overview', 'sdks', 'sdks/index.md'), - page('Typescript Quickstart', 'sdks/typescript/quickstart', 'sdks/typescript/quickstart.md'), - page('Typescript Reference', 'sdks/typescript', 'sdks/typescript/index.md'), - page('Rust Quickstart', 'sdks/rust/quickstart', 'sdks/rust/quickstart.md'), - page('Rust Reference', 'sdks/rust', 'sdks/rust/index.md'), page('C# Quickstart', 'sdks/c-sharp/quickstart', 'sdks/c-sharp/quickstart.md'), page('C# Reference', 'sdks/c-sharp', 'sdks/c-sharp/index.md'), - section('WebAssembly ABI'), - page('Module ABI Reference', 'webassembly-abi', 'webassembly-abi/index.md'), + page('Rust Quickstart', 'sdks/rust/quickstart', 'sdks/rust/quickstart.md'), + page('Rust Reference', 'sdks/rust', 'sdks/rust/index.md'), + page('TypeScript Quickstart', 'sdks/typescript/quickstart', 'sdks/typescript/quickstart.md'), + page('TypeScript Reference', 'sdks/typescript', 'sdks/typescript/index.md'), + section('SQL'), + page('SQL Reference', 'sql', 'sql/index.md'), + section('Subscriptions'), + page('Subscription Reference', 'subscriptions', 'subscriptions/index.md'), + section('How To'), + page('Incremental Migrations', 'how-to/incremental-migrations', 'how-to/incremental-migrations.md'), section('HTTP API'), page('HTTP', 'http', 'http/index.md'), page('`/identity`', 'http/identity', 'http/identity.md'), page('`/database`', 'http/database', 'http/database.md'), - page('`/energy`', 'http/energy', 'http/energy.md'), - section('WebSocket API Reference'), - page('WebSocket', 'ws', 'ws/index.md'), - section('Data Format'), - page('SATN', 'satn', 'satn.md'), - page('BSATN', 'bsatn', 'bsatn.md'), - section('SQL'), - page('SQL Reference', 'sql', 'sql/index.md'), + section('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/satn.md b/docs/sats-json.md similarity index 86% rename from docs/satn.md rename to docs/sats-json.md index 6fb0ee9f..38f08756 100644 --- a/docs/satn.md +++ b/docs/sats-json.md @@ -1,6 +1,6 @@ -# SATN JSON Format +# SATS-JSON Format -The Spacetime Algebraic Type Notation JSON format defines how Spacetime `AlgebraicType`s and `AlgebraicValue`s are encoded as JSON. Algebraic types and values are JSON-encoded for transport via the [HTTP Databases API](/docs/http/database) and the [WebSocket text protocol](/docs/ws#text-protocol). +The Spacetime Algebraic Type System JSON format defines how Spacetime `AlgebraicType`s and `AlgebraicValue`s are encoded as JSON. Algebraic types and values are JSON-encoded for transport via the [HTTP Databases API](/docs/http/database) and the WebSocket text protocol. Note that SATS-JSON is not self-describing, and so a SATS value represented in JSON requires knowing the value's schema to meaningfully understand it - for example, it's not possible to tell whether a JSON object with a single field is a `ProductValue` with one element or a `SumValue`. ## Values @@ -32,6 +32,8 @@ The tag is an index into the [`SumType.variants`](#sumtype) array of the value's } ``` +The tag may also be the name of one of the variants. + ### `ProductValue` An instance of a [`ProductType`](#producttype). `ProductValue`s are encoded as JSON arrays. Each element of the `ProductValue` array is of the type of the corresponding index in the [`ProductType.elements`](#producttype) array of the value's [`ProductType`](#producttype). @@ -40,6 +42,10 @@ An instance of a [`ProductType`](#producttype). `ProductValue`s are encoded as J array ``` +`ProductValue`s may also be encoded as a JSON object with the keys as the field +names of the `ProductValue` and the values as the corresponding +`AlgebraicValue`s. + ### `BuiltinValue` An instance of a [`BuiltinType`](#builtintype). `BuiltinValue`s are encoded as JSON values of corresponding types. @@ -69,7 +75,7 @@ All SATS types are JSON-encoded by converting them to an `AlgebraicValue`, then | --------------------------------------- | ------------------------------------------------------------------------------------ | | [`AlgebraicType`](#algebraictype) | Any SATS type. | | [`SumType`](#sumtype) | Sum types, i.e. tagged unions. | -| [`ProductType`](#producttype) | Product types, i.e. structures. | +| [`ProductType`](#producttype) | Product types, i.e. structures. | | [`BuiltinType`](#builtintype) | Built-in and primitive types, including booleans, numbers, strings, arrays and maps. | | [`AlgebraicTypeRef`](#algebraictyperef) | An indirect reference to a type, used to implement recursive types. | @@ -160,4 +166,4 @@ SATS array and map types are homogeneous, meaning that each array has a single e ### `AlgebraicTypeRef` -`AlgebraicTypeRef`s are JSON-encoded as non-negative integers. These are indices into a typespace, like the one returned by the [`/database/schema/:name_or_address GET` HTTP endpoint](/docs/http/database#databaseschemaname_or_address-get). +`AlgebraicTypeRef`s are JSON-encoded as non-negative integers. These are indices into a typespace, like the one returned by the [`GET /v1/database/:name_or_identity/schema` HTTP endpoint](/docs/http/database#get-v1databasename_or_identityschema). diff --git a/docs/sdks/c-sharp/index.md b/docs/sdks/c-sharp/index.md index e9c5f23a..16fd2068 100644 --- a/docs/sdks/c-sharp/index.md +++ b/docs/sdks/c-sharp/index.md @@ -1,56 +1,22 @@ # The SpacetimeDB C# client SDK -The SpacetimeDB client C# for Rust contains all the tools you need to build native clients for SpacetimeDB modules using C#. - -## Table of Contents - -- [The SpacetimeDB C# client SDK](#the-spacetimedb-c-client-sdk) - - [Table of Contents](#table-of-contents) - - [Install the SDK](#install-the-sdk) - - [Using the `dotnet` CLI tool](#using-the-dotnet-cli-tool) - - [Using Unity](#using-unity) - - [Generate module bindings](#generate-module-bindings) - - [Initialization](#initialization) - - [Property `SpacetimeDBClient.instance`](#property-spacetimedbclientinstance) - - [Class `NetworkManager`](#class-networkmanager) - - [Method `SpacetimeDBClient.Connect`](#method-spacetimedbclientconnect) - - [Event `SpacetimeDBClient.onIdentityReceived`](#event-spacetimedbclientonidentityreceived) - - [Event `SpacetimeDBClient.onConnect`](#event-spacetimedbclientonconnect) - - [Subscribe to queries](#subscribe-to-queries) - - [Method `SpacetimeDBClient.Subscribe`](#method-spacetimedbclientsubscribe) - - [Event `SpacetimeDBClient.onSubscriptionApplied`](#event-spacetimedbclientonsubscriptionapplied) - - [Method \[`SpacetimeDBClient.OneOffQuery`\]](#method-spacetimedbclientoneoffquery) - - [View rows of subscribed tables](#view-rows-of-subscribed-tables) - - [Class `{TABLE}`](#class-table) - - [Static Method `{TABLE}.Iter`](#static-method-tableiter) - - [Static Method `{TABLE}.FilterBy{COLUMN}`](#static-method-tablefilterbycolumn) - - [Static Method `{TABLE}.FindBy{COLUMN}`](#static-method-tablefindbycolumn) - - [Static Method `{TABLE}.Count`](#static-method-tablecount) - - [Static Event `{TABLE}.OnInsert`](#static-event-tableoninsert) - - [Static Event `{TABLE}.OnBeforeDelete`](#static-event-tableonbeforedelete) - - [Static Event `{TABLE}.OnDelete`](#static-event-tableondelete) - - [Static Event `{TABLE}.OnUpdate`](#static-event-tableonupdate) - - [Observe and invoke reducers](#observe-and-invoke-reducers) - - [Class `Reducer`](#class-reducer) - - [Static Method `Reducer.{REDUCER}`](#static-method-reducerreducer) - - [Static Event `Reducer.On{REDUCER}`](#static-event-reduceronreducer) - - [Class `ReducerEvent`](#class-reducerevent) - - [Enum `Status`](#enum-status) - - [Variant `Status.Committed`](#variant-statuscommitted) - - [Variant `Status.Failed`](#variant-statusfailed) - - [Variant `Status.OutOfEnergy`](#variant-statusoutofenergy) - - [Identity management](#identity-management) - - [Class `AuthToken`](#class-authtoken) - - [Static Method `AuthToken.Init`](#static-method-authtokeninit) - - [Static Property `AuthToken.Token`](#static-property-authtokentoken) - - [Static Method `AuthToken.SaveToken`](#static-method-authtokensavetoken) - - [Class `Identity`](#class-identity) - - [Customizing logging](#customizing-logging) - - [Interface `ISpacetimeDBLogger`](#interface-ispacetimedblogger) - - [Class `ConsoleLogger`](#class-consolelogger) - - [Class `UnityDebugLogger`](#class-unitydebuglogger) - -## Install the SDK +The SpacetimeDB client for C# contains all the tools you need to build native clients for SpacetimeDB modules using C#. + +| Name | Description | +|---------------------------------------------------------|---------------------------------------------------------------------------| +| [Project setup](#project-setup) | Configure a C# project to use the SpacetimeDB C# client SDK. | +| [Generate module bindings](#generate-module-bindings) | Use the SpacetimeDB CLI to generate module-specific types and interfaces. | +| [`DbConnection` type](#type-dbconnection) | A connection to a remote database. | +| [`IDbContext` interface](#interface-idbcontext) | Methods for interacting with the remote database. | +| [`EventContext` type](#type-eventcontext) | Implements [`IDbContext`](##interface-idbcontext) for [row callbacks](#callback-oninsert). | +| [`ReducerEventContext` type](#type-reducereventcontext) | Implements [`IDbContext`](##interface-idbcontext) for [reducer callbacks](#observe-and-invoke-reducers). | +| [`SubscriptionEventContext` type](#type-subscriptioneventcontext) | Implements [`IDbContext`](##interface-idbcontext) for [subscription callbacks](#subscribe-to-queries). | +| [`ErrorContext` type](#type-errorcontext) | Implements [`IDbContext`](##interface-idbcontext) for error-related callbacks. | +| [Access the client cache](#access-the-client-cache) | Access to your local view of the database. | +| [Observe and invoke reducers](#observe-and-invoke-reducers) | Send requests to the database to run reducers, and register callbacks to run when notified of reducers. | +| [Identify a client](#identify-a-client) | Types for identifying users and client connections. | + +## Project setup ### Using the `dotnet` CLI tool @@ -81,853 +47,878 @@ spacetime generate --lang cs --out-dir module_bindings --project-path PATH-TO-MO Replace `PATH-TO-MODULE-DIRECTORY` with the path to your SpacetimeDB module. -## Initialization +## Type `DbConnection` -### Property `SpacetimeDBClient.instance` +A connection to a remote database is represented by the `DbConnection` class. This class is generated per module and contains information about the types, tables, and reducers defined by your module. -```cs -namespace SpacetimeDB { +| Name | Description | +|------------------------------------------------------------------------|-------------------------------------------------------------------------------| +| [Connect to a module](#connect-to-a-module) | Construct a `DbConnection` instance. | +| [Advance the connection](#advance-the-connection-and-process-messages) | Poll the `DbConnection` or run it in the background. | +| [Access tables and reducers](#access-tables-and-reducers) | Access the client cache, request reducer invocations, and register callbacks. | -public class SpacetimeDBClient { - public static SpacetimeDBClient instance; -} +## Connect to a module +```csharp +class DbConnection +{ + public static DbConnectionBuilder Builder(); } ``` -This is the global instance of a SpacetimeDB client in a particular .NET/Unity process. Much of the SDK is accessible through this instance. +Construct a `DbConnection` by calling `DbConnection.Builder()`, chaining configuration methods, and finally calling `.Build()`. At a minimum, you must specify `WithUri` to provide the URI of the SpacetimeDB instance, and `WithModuleName` to specify the module's name or identity. -### Class `NetworkManager` +| Name | Description | +|---------------------------------------------------------|--------------------------------------------------------------------------------------------| +| [WithUri method](#method-withuri) | Set the URI of the SpacetimeDB instance hosting the remote database. | +| [WithModuleName method](#method-withmodulename) | Set the name or identity of the remote module. | +| [OnConnect callback](#callback-onconnect) | Register a callback to run when the connection is successfully established. | +| [OnConnectError callback](#callback-onconnecterror) | Register a callback to run if the connection is rejected or the host is unreachable. | +| [OnDisconnect callback](#callback-ondisconnect) | Register a callback to run when the connection ends. | +| [WithToken method](#method-withtoken) | Supply a token to authenticate with the remote database. | +| [Build method](#method-build) | Finalize configuration and open the connection. | -The Unity SpacetimeDB SDK relies on there being a `NetworkManager` somewhere in the scene. Click on the GameManager object in the scene, and in the inspector, add the `NetworkManager` component. +### Method `WithUri` -This component will handle updating and closing the [`SpacetimeDBClient.instance`](#property-spacetimedbclientinstance) for you, but will not call [`SpacetimeDBClient.Connect`](#method-spacetimedbclientconnect), you still need to handle that yourself. See the [Unity Tutorial](/docs/unity) for more information. +```csharp +class DbConnectionBuilder +{ + public DbConnectionBuilder WithUri(Uri uri); +} +``` -### Method `SpacetimeDBClient.Connect` +Configure the URI of the SpacetimeDB instance or cluster which hosts the remote module. -```cs -namespace SpacetimeDB { +### Method `WithModuleName` -class SpacetimeDBClient { - public void Connect( - string? token, - string host, - string addressOrName, - bool sslEnabled = true - ); +```csharp +class DbConnectionBuilder +{ + public DbConnectionBuilder WithModuleName(string nameOrIdentity); } +``` + +Configure the SpacetimeDB domain name or `Identity` of the remote module which identifies it within the SpacetimeDB instance or cluster. +### Callback `OnConnect` + +```csharp +class DbConnectionBuilder +{ + public DbConnectionBuilder OnConnect(Action callback); } ``` - +Chain a call to `.OnConnect(callback)` to your builder to register a callback to run when your new `DbConnection` successfully initiates its connection to the remote module. The callback accepts three arguments: a reference to the `DbConnection`, the `Identity` by which SpacetimeDB identifies this connection, and a private access token which can be saved and later passed to [`WithToken`](#method-withtoken) to authenticate the same user in future connections. -Connect to a database named `addressOrName` accessible over the internet at the URI `host`. +### Callback `OnConnectError` -| Argument | Type | Meaning | -| --------------- | --------- | -------------------------------------------------------------------------- | -| `token` | `string?` | Identity token to use, if one is available. | -| `host` | `string` | URI of the SpacetimeDB instance running the module. | -| `addressOrName` | `string` | Address or name of the module. | -| `sslEnabled` | `bool` | Whether or not to use SSL when connecting to SpacetimeDB. Default: `true`. | +```csharp +class DbConnectionBuilder +{ + public DbConnectionBuilder OnConnectError(Action callback); +} +``` -If a `token` is supplied, it will be passed to the new connection to identify and authenticate the user. Otherwise, a new token and [`Identity`](#class-identity) will be generated by the server and returned in [`onConnect`](#event-spacetimedbclientonconnect). +Chain a call to `.OnConnectError(callback)` to your builder to register a callback to run when your connection fails. -```cs -using SpacetimeDB; -using SpacetimeDB.Types; +A known bug in the SpacetimeDB Rust client SDK currently causes this callback never to be invoked. [`OnDisconnect`](#callback-ondisconnect) callbacks are invoked instead. -const string DBNAME = "chat"; +### Callback `OnDisconnect` + +```csharp +class DbConnectionBuilder +{ + public DbConnectionBuilder OnDisconnect(Action callback); +} +``` -// Connect to a local DB with a fresh identity -SpacetimeDBClient.instance.Connect(null, "localhost:3000", DBNAME, false); +Chain a call to `.OnDisconnect(callback)` to your builder to register a callback to run when your `DbConnection` disconnects from the remote module, either as a result of a call to [`Disconnect`](#method-disconnect) or due to an error. -// Connect to cloud with a fresh identity -SpacetimeDBClient.instance.Connect(null, "dev.spacetimedb.net", DBNAME, true); +### Method `WithToken` -// Connect to cloud using a saved identity from the filesystem, or get a new one and save it -AuthToken.Init(); -Identity localIdentity; -SpacetimeDBClient.instance.Connect(AuthToken.Token, "dev.spacetimedb.net", DBNAME, true); -SpacetimeDBClient.instance.onIdentityReceived += (string authToken, Identity identity, Address address) { - AuthToken.SaveToken(authToken); - localIdentity = identity; +```csharp +class DbConnectionBuilder +{ + public DbConnectionBuilder WithToken(string token = null); } ``` -(You should probably also store the returned `Identity` somewhere; see the [`onIdentityReceived`](#event-spacetimedbclientonidentityreceived) event.) +Chain a call to `.WithToken(token)` to your builder to provide an OpenID Connect compliant JSON Web Token to authenticate with, or to explicitly select an anonymous connection. If this method is not called or `None` is passed, SpacetimeDB will generate a new `Identity` and sign a new private access token for the connection. -### Event `SpacetimeDBClient.onIdentityReceived` +### Method `Build` -```cs -namespace SpacetimeDB { - -class SpacetimeDBClient { - public event Action onIdentityReceived; +```csharp +class DbConnectionBuilder +{ + public DbConnection Build(); } +``` + +After configuring the connection and registering callbacks, attempt to open the connection. + +## Advance the connection and process messages + +In the interest of supporting a wide variety of client applications with different execution strategies, the SpacetimeDB SDK allows you to choose when the `DbConnection` spends compute time and processes messages. If you do not arrange for the connection to advance by calling one of these methods, the `DbConnection` will never advance, and no callbacks will ever be invoked. +| Name | Description | +|---------------------------------------------|-------------------------------------------------------| +| [`FrameTick` method](#method-frametick) | Process messages on the main thread without blocking. | + +#### Method `FrameTick` + +```csharp +class DbConnection { + public void FrameTick(); } ``` -Called when we receive an auth token, [`Identity`](#class-identity) and `Address` from the server. The [`Identity`](#class-identity) serves as a unique public identifier for a user of the database. It can be for several purposes, such as filtering rows in a database for the rows created by a particular user. The auth token is a private access token that allows us to assume an identity. The `Address` is opaque identifier for a client connection to a database, intended to differentiate between connections from the same [`Identity`](#class-identity). +`FrameTick` will advance the connection until no work remains or until it is disconnected, then return rather than blocking. Games might arrange for this message to be called every frame. -To store the auth token to the filesystem, use the static method [`AuthToken.SaveToken`](#static-method-authtokensavetoken). You may also want to store the returned [`Identity`](#class-identity) in a local variable. +It is not advised to run `FrameTick` on a background thread, since it modifies [`dbConnection.Db`](#property-db). If main thread code is also accessing the `Db`, it may observe data races when `FrameTick` runs on another thread. -If an existing auth token is used to connect to the database, the same auth token and the identity it came with will be returned verbatim in `onIdentityReceived`. +(Note that the SDK already does most of the work for parsing messages on a background thread. `FrameTick()` does the minimal amount of work needed to apply updates to the `Db`.) -```cs -// Connect to cloud using a saved identity from the filesystem, or get a new one and save it -AuthToken.Init(); -Identity localIdentity; -SpacetimeDBClient.instance.Connect(AuthToken.Token, "dev.spacetimedb.net", DBNAME, true); -SpacetimeDBClient.instance.onIdentityReceived += (string authToken, Identity identity, Address address) { - AuthToken.SaveToken(authToken); - localIdentity = identity; +## Access tables and reducers + +### Property `Db` + +```csharp +class DbConnection +{ + public RemoteTables Db; + /* other members */ } ``` -### Event `SpacetimeDBClient.onConnect` +The `Db` property of the `DbConnection` provides access to the subscribed view of the remote database's tables. See [Access the client cache](#access-the-client-cache). -```cs -namespace SpacetimeDB { +### Property `Reducers` -class SpacetimeDBClient { - public event Action onConnect; +```csharp +class DbConnection +{ + public RemoteReducers Reducers; + /* other members */ } +``` +The `Reducers` field of the `DbConnection` provides access to reducers exposed by the remote module. See [Observe and invoke reducers](#observe-and-invoke-reducers). + +## Interface `IDbContext` + +```csharp +interface IDbContext +{ + /* methods */ } ``` -Allows registering delegates to be invoked upon authentication with the database. +[`DbConnection`](#type-dbconnection), [`EventContext`](#type-eventcontext), [`ReducerEventContext`](#type-reducereventcontext), [`SubscriptionEventContext`](#type-subscriptioneventcontext) and [`ErrorContext`](#type-errorcontext) all implement `IDbContext`. `IDbContext` has methods for inspecting and configuring your connection to the remote database. -Once this occurs, the SDK is prepared for calls to [`SpacetimeDBClient.Subscribe`](#method-spacetimedbclientsubscribe). +The `IDbContext` interface is implemented by connections and contexts to *every* module - hence why it takes [`DbView`](#method-db) and [`RemoteReducers`](#method-reducers) as type parameters. -## Subscribe to queries +| Name | Description | +|---------------------------------------------------------------|--------------------------------------------------------------------------| +| [`IRemoteDbContext` interface](#interface-iremotedbcontext) | Module-specific `IDbContext`. | +| [`Db` method](#method-db) | Provides access to the subscribed view of the remote database's tables. | +| [`Reducers` method](#method-reducers) | Provides access to reducers exposed by the remote module. | +| [`Disconnect` method](#method-disconnect) | End the connection. | +| [Subscribe to queries](#subscribe-to-queries) | Register SQL queries to receive updates about matching rows. | +| [Read connection metadata](#read-connection-metadata) | Access the connection's `Identity` and `ConnectionId` | -### Method `SpacetimeDBClient.Subscribe` +### Interface `IRemoteDbContext` -```cs -namespace SpacetimeDB { +Each module's `module_bindings` exports an interface `IRemoteDbContext` which inherits from `IDbContext`, with the type parameters `DbView` and `RemoteReducers` bound to the types defined for that module. This can be more convenient when creating functions that can be called from any callback for a specific module, but which access the database or invoke reducers, and so must know the type of the `DbView` or `Reducers`. -class SpacetimeDBClient { - public void Subscribe(List queries); -} +### Method `Db` +```csharp +interface IRemoteDbContext +{ + public DbView Db { get; } } ``` -| Argument | Type | Meaning | -| --------- | -------------- | ---------------------------- | -| `queries` | `List` | SQL queries to subscribe to. | +`Db` will have methods to access each table defined by the module. -Subscribe to a set of queries, to be notified when rows which match those queries are altered. +#### Example -`Subscribe` will return an error if called before establishing a connection with the [`SpacetimeDBClient.Connect`](#method-spacetimedbclientconnect) function. In that case, the queries are not registered. - -The `Subscribe` method does not return data directly. `spacetime generate` will generate classes [`SpacetimeDB.Types.{TABLE}`](#class-table) for each table in your module. These classes are used to reecive information from the database. See the section [View Rows of Subscribed Tables](#view-rows-of-subscribed-tables) for more information. +```csharp +var conn = ConnectToDB(); -A new call to `Subscribe` will remove all previous subscriptions and replace them with the new `queries`. If any rows matched the previous subscribed queries but do not match the new queries, those rows will be removed from the client cache, and [`{TABLE}.OnDelete`](#static-event-tableoninsert) callbacks will be invoked for them. +// Get a handle to the User table +var tableHandle = conn.Db.User; +``` -```cs -using SpacetimeDB; -using SpacetimeDB.Types; +### Method `Reducers` -void Main() +```csharp +interface IRemoteDbContext { - AuthToken.Init(); + public RemoteReducers Reducers { get; } +} +``` - SpacetimeDBClient.instance.onConnect += OnConnect; +`Reducers` will have methods to invoke each reducer defined by the module, +plus methods for adding and removing callbacks on each of those reducers. - // Our module contains a table named "Loot" - Loot.OnInsert += Loot_OnInsert; +#### Example - SpacetimeDBClient.instance.Connect(/* ... */); -} +```csharp +var conn = ConnectToDB(); -void OnConnect() -{ - SpacetimeDBClient.instance.Subscribe(new List { - "SELECT * FROM Loot" - }); -} +// Register a callback to be run every time the SendMessage reducer is invoked +conn.Reducers.OnSendMessage += Reducer_OnSendMessageEvent; +``` + +### Method `Disconnect` -void Loot_OnInsert( - Loot loot, - ReducerEvent? event -) { - Console.Log($"Loaded loot {loot.itemType} at coordinates {loot.position}"); +```csharp +interface IRemoteDbContext +{ + public void Disconnect(); } ``` -### Event `SpacetimeDBClient.onSubscriptionApplied` +Gracefully close the `DbConnection`. Throws an error if the connection is already closed. -```cs -namespace SpacetimeDB { +### Subscribe to queries -class SpacetimeDBClient { - public event Action onSubscriptionApplied; -} +| Name | Description | +|---------------------------------------------------------|-------------------------------------------------------------| +| [`SubscriptionBuilder` type](#type-subscriptionbuilder) | Builder-pattern constructor to register subscribed queries. | +| [`SubscriptionHandle` type](#type-subscriptionhandle) | Manage an active subscripion. | -} -``` +#### Type `SubscriptionBuilder` -Register a delegate to be invoked when a subscription is registered with the database. +| Name | Description | +|----------------------------------------------------------------------------------|-----------------------------------------------------------------| +| [`ctx.SubscriptionBuilder()` constructor](#constructor-ctxsubscriptionbuilder) | Begin configuring a new subscription. | +| [`OnApplied` callback](#callback-onapplied) | Register a callback to run when matching rows become available. | +| [`OnError` callback](#callback-onerror) | Register a callback to run if the subscription fails. | +| [`Subscribe` method](#method-subscribe) | Finish configuration and subscribe to one or more SQL queries. | +| [`SubscribeToAllTables` method](#method-subscribetoalltables) | Convenience method to subscribe to the entire database. | -```cs -using SpacetimeDB; +##### Constructor `ctx.SubscriptionBuilder()` -void OnSubscriptionApplied() +```csharp +interface IRemoteDbContext { - Console.WriteLine("Now listening on queries."); + public SubscriptionBuilder SubscriptionBuilder(); } +``` + +Subscribe to queries by calling `ctx.SubscriptionBuilder()` and chaining configuration methods, then calling `.Subscribe(queries)`. -void Main() +##### Callback `OnApplied` + +```csharp +class SubscriptionBuilder { - // ...initialize... - SpacetimeDBClient.instance.onSubscriptionApplied += OnSubscriptionApplied; + public SubscriptionBuilder OnApplied(Action callback); } ``` -### Method [`SpacetimeDBClient.OneOffQuery`] +Register a callback to run when the subscription is applied and the matching rows are inserted into the client cache. -You may not want to subscribe to a query, but instead want to run a query once and receive the results immediately via a `Task` result: +##### Callback `OnError` ```csharp -// Query all Messages from the sender "bob" -SpacetimeDBClient.instance.OneOffQuery("WHERE sender = \"bob\""); +class SubscriptionBuilder +{ + public SubscriptionBuilder OnError(Action callback); +} ``` -## View rows of subscribed tables +Register a callback to run if the subscription is rejected or unexpectedly terminated by the server. This is most frequently caused by passing an invalid query to [`Subscribe`](#method-subscribe). -The SDK maintains a local view of the database called the "client cache". This cache contains whatever rows are selected via a call to [`SpacetimeDBClient.Subscribe`](#method-spacetimedbclientsubscribe). These rows are represented in the SpacetimeDB .Net SDK as instances of [`SpacetimeDB.Types.{TABLE}`](#class-table). -ONLY the rows selected in a [`SpacetimeDBClient.Subscribe`](#method-spacetimedbclientsubscribe) call will be available in the client cache. All operations in the client sdk operate on these rows exclusively, and have no information about the state of the rest of the database. +##### Method `Subscribe` -In particular, SpacetimeDB does not support foreign key constraints. This means that if you are using a column as a foreign key, SpacetimeDB will not automatically bring in all of the rows that key might reference. You will need to manually subscribe to all tables you need information from. - -To optimize network performance, prefer selecting as few rows as possible in your [`Subscribe`](#method-spacetimedbclientsubscribe) query. Processes that need to view the entire state of the database are better run inside the database -- that is, inside modules. +```csharp +class SubscriptionBuilder +{ + public SubscriptionHandle Subscribe(string[] querySqls); +} +``` -### Class `{TABLE}` +Subscribe to a set of queries. `queries` should be an array of SQL query strings. -For each table defined by a module, `spacetime generate` will generate a class [`SpacetimeDB.Types.{TABLE}`](#class-table) whose name is that table's name converted to `PascalCase`. The generated class contains a property for each of the table's columns, whose names are the column names converted to `camelCase`. It also contains various static events and methods. +See [the SpacetimeDB SQL Reference](/docs/sql#subscriptions) for information on the queries SpacetimeDB supports as subscriptions. -Static Methods: +##### Method `SubscribeToAllTables` -- [`{TABLE}.Iter()`](#static-method-tableiter) iterates all subscribed rows in the client cache. -- [`{TABLE}.FilterBy{COLUMN}(value)`](#static-method-tablefilterbycolumn) filters subscribed rows in the client cache by a column value. -- [`{TABLE}.FindBy{COLUMN}(value)`](#static-method-tablefindbycolumn) finds a subscribed row in the client cache by a unique column value. -- [`{TABLE}.Count()`](#static-method-tablecount) counts the number of subscribed rows in the client cache. +```csharp +class SubscriptionBuilder +{ + public void SubscribeToAllTables(); +} +``` -Static Events: +Subscribe to all rows from all public tables. This method is provided as a convenience for simple clients. The subscription initiated by `SubscribeToAllTables` cannot be canceled after it is initiated. You should [`subscribe` to specific queries](#method-subscribe) if you need fine-grained control over the lifecycle of your subscriptions. -- [`{TABLE}.OnInsert`](#static-event-tableoninsert) is called when a row is inserted into the client cache. -- [`{TABLE}.OnBeforeDelete`](#static-event-tableonbeforedelete) is called when a row is about to be removed from the client cache. -- If the table has a primary key attribute, [`{TABLE}.OnUpdate`](#static-event-tableonupdate) is called when a row is updated. -- [`{TABLE}.OnDelete`](#static-event-tableondelete) is called while a row is being removed from the client cache. You should almost always use [`{TABLE}.OnBeforeDelete`](#static-event-tableonbeforedelete) instead. +#### Type `SubscriptionHandle` -Note that it is not possible to directly insert into the database from the client SDK! All insertion validation should be performed inside serverside modules for security reasons. You can instead [invoke reducers](#observe-and-invoke-reducers), which run code inside the database that can insert rows for you. +A `SubscriptionHandle` represents a subscribed query or a group of subscribed queries. -#### Static Method `{TABLE}.Iter` +The `SubscriptionHandle` does not contain or provide access to the subscribed rows. Subscribed rows of all subscriptions by a connection are contained within that connection's [`ctx.Db`](#property-db). See [Access the client cache](#access-the-client-cache). -```cs -namespace SpacetimeDB.Types { +| Name | Description | +|-------------------------------------------------------|------------------------------------------------------------------------------------------------------------------| +| [`IsEnded` property](#property-isended) | Determine whether the subscription has ended. | +| [`IsActive` property](#property-isactive) | Determine whether the subscription is active and its matching rows are present in the client cache. | +| [`Unsubscribe` method](#method-unsubscribe) | Discard a subscription. | +| [`UnsubscribeThen` method](#method-unsubscribethen) | Discard a subscription, and register a callback to run when its matching rows are removed from the client cache. | -class TABLE { - public static IEnumerable Iter(); -} +##### Property `IsEnded` +```csharp +class SubscriptionHandle +{ + public bool IsEnded; } ``` -Iterate over all the subscribed rows in the table. This method is only available after [`SpacetimeDBClient.onSubscriptionApplied`](#event-spacetimedbclientonsubscriptionapplied) has occurred. +True if this subscription has been terminated due to an unsubscribe call or an error. -When iterating over rows and filtering for those containing a particular column, [`{TABLE}.FilterBy{COLUMN}`](#static-method-tablefilterbycolumn) and [`{TABLE}.FindBy{COLUMN}`](#static-method-tablefindbycolumn) will be more efficient, so prefer those when possible. +##### Property `IsActive` -```cs -using SpacetimeDB; -using SpacetimeDB.Types; - -SpacetimeDBClient.instance.onConnect += (string authToken, Identity identity) => { - SpacetimeDBClient.instance.Subscribe(new List { "SELECT * FROM User" }); -}; -SpacetimeDBClient.instance.onSubscriptionApplied += () => { - // Will print a line for each `User` row in the database. - foreach (var user in User.Iter()) { - Console.WriteLine($"User: {user.Name}"); - } -}; -SpacetimeDBClient.instance.connect(/* ... */); +```csharp +class SubscriptionHandle +{ + public bool IsActive; +} ``` -#### Static Method `{TABLE}.FilterBy{COLUMN}` - -```cs -namespace SpacetimeDB.Types { +True if this subscription has been applied and has not yet been unsubscribed. -class TABLE { - public static IEnumerable
FilterBySender(COLUMNTYPE value); -} +##### Method `Unsubscribe` +```csharp +class SubscriptionHandle +{ + public void Unsubscribe(); } ``` -For each column of a table, `spacetime generate` generates a static method on the [table class](#class-table) to filter subscribed rows where that column matches a requested value. +Terminate this subscription, causing matching rows to be removed from the client cache. Any rows removed from the client cache this way will have [`OnDelete` callbacks](#callback-ondelete) run for them. -These methods are named `filterBy{COLUMN}`, where `{COLUMN}` is the column name converted to `PascalCase`. The method's return type is an `IEnumerable` over the [table class](#class-table). +Unsubscribing is an asynchronous operation. Matching rows are not removed from the client cache immediately. Use [`UnsubscribeThen`](#method-unsubscribethen) to run a callback once the unsubscribe operation is completed. -#### Static Method `{TABLE}.FindBy{COLUMN}` +Returns an error if the subscription has already ended, either due to a previous call to `Unsubscribe` or [`UnsubscribeThen`](#method-unsubscribethen), or due to an error. -```cs -namespace SpacetimeDB.Types { - -class TABLE { - // If the column has a #[unique] or #[primarykey] constraint - public static TABLE? FindBySender(COLUMNTYPE value); -} +##### Method `UnsubscribeThen` +```csharp +class SubscriptionHandle +{ + public void UnsubscribeThen(Action? onEnded); } ``` -For each unique column of a table (those annotated `#[unique]` or `#[primarykey]`), `spacetime generate` generates a static method on the [table class](#class-table) to seek a subscribed row where that column matches a requested value. +Terminate this subscription, and run the `onEnded` callback when the subscription is ended and its matching rows are removed from the client cache. Any rows removed from the client cache this way will have [`OnDelete` callbacks](#callback-ondelete) run for them. -These methods are named `findBy{COLUMN}`, where `{COLUMN}` is the column name converted to `PascalCase`. Those methods return a single instance of the [table class](#class-table) if a row is found, or `null` if no row matches the query. +Returns an error if the subscription has already ended, either due to a previous call to [`Unsubscribe`](#method-unsubscribe) or `UnsubscribeThen`, or due to an error. -#### Static Method `{TABLE}.Count` +### Read connection metadata -```cs -namespace SpacetimeDB.Types { - -class TABLE { - public static int Count(); -} +#### Property `Identity` +```csharp +interface IDbContext +{ + public Identity? Identity { get; } } ``` -Return the number of subscribed rows in the table, or 0 if there is no active connection. +Get the `Identity` with which SpacetimeDB identifies the connection. This method returns null if the connection was initiated anonymously and the newly-generated `Identity` has not yet been received, i.e. if called before the [`OnConnect` callback](#callback-onconnect) is invoked. -```cs -using SpacetimeDB; -using SpacetimeDB.Types; +#### Property `ConnectionId` -SpacetimeDBClient.instance.onConnect += (string authToken, Identity identity) => { - SpacetimeDBClient.instance.Subscribe(new List { "SELECT * FROM User" }); -}; -SpacetimeDBClient.instance.onSubscriptionApplied += () => { - Console.WriteLine($"There are {User.Count()} users in the database."); -}; -SpacetimeDBClient.instance.connect(/* ... */); +```csharp +interface IDbContext +{ + public ConnectionId ConnectionId { get; } +} ``` -#### Static Event `{TABLE}.OnInsert` +Get the [`ConnectionId`](#type-connectionid) with which SpacetimeDB identifies the connection. -```cs -namespace SpacetimeDB.Types { - -class TABLE { - public delegate void InsertEventHandler( - TABLE insertedValue, - ReducerEvent? dbEvent - ); - public static event InsertEventHandler OnInsert; -} +#### Property `IsActive` +```csharp +interface IDbContext +{ + public bool IsActive { get; } } ``` -Register a delegate for when a subscribed row is newly inserted into the database. +`true` if the connection has not yet disconnected. Note that a connection `IsActive` when it is constructed, before its [`OnConnect` callback](#callback-onconnect) is invoked. -The delegate takes two arguments: +## Type `EventContext` -- A [`{TABLE}`](#class-table) instance with the data of the inserted row -- A [`ReducerEvent?`], which contains the data of the reducer that inserted the row, or `null` if the row is being inserted while initializing a subscription. +An `EventContext` is an [`IDbContext`](#interface-idbcontext) augmented with an [`Event`](#record-event) property. `EventContext`s are passed as the first argument to row callbacks [`OnInsert`](#callback-oninsert), [`OnDelete`](#callback-ondelete) and [`OnUpdate`](#callback-onupdate). -```cs -using SpacetimeDB; -using SpacetimeDB.Types; +| Name | Description | +|-------------------------------------------|---------------------------------------------------------------| +| [`Event` property](#property-event) | Enum describing the cause of the current row callback. | +| [`Db` property](#property-db) | Provides access to the client cache. | +| [`Reducers` property](#property-reducers) | Allows requesting reducers run on the remote database. | +| [`Event` record](#record-event) | Possible events which can cause a row callback to be invoked. | -/* initialize, subscribe to table User... */ +### Property `Event` -User.OnInsert += (User user, ReducerEvent? reducerEvent) => { - if (reducerEvent == null) { - Console.WriteLine($"New user '{user.Name}' received during subscription update."); - } else { - Console.WriteLine($"New user '{user.Name}' inserted by reducer {reducerEvent.Reducer}."); - } -}; +```csharp +class EventContext { + public readonly Event Event; + /* other fields */ +} ``` -#### Static Event `{TABLE}.OnBeforeDelete` +The [`Event`](#record-event) contained in the `EventContext` describes what happened to cause the current row callback to be invoked. -```cs -namespace SpacetimeDB.Types { - -class TABLE { - public delegate void DeleteEventHandler( - TABLE deletedValue, - ReducerEvent dbEvent - ); - public static event DeleteEventHandler OnBeforeDelete; -} +### Property `Db` +```csharp +class EventContext { + public RemoteTables Db; + /* other fields */ } ``` -Register a delegate for when a subscribed row is about to be deleted from the database. If a reducer deletes many rows at once, this delegate will be invoked for each of those rows before any of them is deleted. +The `Db` property of the context provides access to the subscribed view of the remote database's tables. See [Access the client cache](#access-the-client-cache). + +### Field `Reducers` -The delegate takes two arguments: +```csharp +class EventContext { + public RemoteReducers Reducers; + /* other fields */ +} +``` -- A [`{TABLE}`](#class-table) instance with the data of the deleted row -- A [`ReducerEvent`](#class-reducerevent), which contains the data of the reducer that deleted the row. +The `Reducers` property of the context provides access to reducers exposed by the remote module. See [Observe and invoke reducers](#observe-and-invoke-reducers). -This event should almost always be used instead of [`OnDelete`](#static-event-tableondelete). This is because often, many rows will be deleted at once, and `OnDelete` can be invoked in an arbitrary order on these rows. This means that data related to a row may already be missing when `OnDelete` is called. `OnBeforeDelete` does not have this problem. +### Record `Event` -```cs -using SpacetimeDB; -using SpacetimeDB.Types; +| Name | Description | +|-------------------------------------------------------------|--------------------------------------------------------------------------| +| [`Reducer` variant](#variant-reducer) | A reducer ran in the remote database. | +| [`SubscribeApplied` variant](#variant-subscribeapplied) | A new subscription was applied to the client cache. | +| [`UnsubscribeApplied` variant](#variant-unsubscribeapplied) | A previous subscription was removed from the client cache after a call to [`Unsubscribe`](#method-unsubscribe). | +| [`SubscribeError` variant](#variant-subscribeerror) | A previous subscription was removed from the client cache due to an error. | +| [`UnknownTransaction` variant](#variant-unknowntransaction) | A transaction ran in the remote database, but was not attributed to a known reducer. | +| [`ReducerEvent` record](#record-reducerevent) | Metadata about a reducer run. Contained in a [`Reducer` event](#variant-reducer) and [`ReducerEventContext`](#type-reducereventcontext). | +| [`Status` record](#record-status) | Completion status of a reducer run. | +| [`Reducer` record](#record-reducer) | Module-specific generated record with a variant for each reducer defined by the module. | -/* initialize, subscribe to table User... */ +#### Variant `Reducer` -User.OnBeforeDelete += (User user, ReducerEvent reducerEvent) => { - Console.WriteLine($"User '{user.Name}' deleted by reducer {reducerEvent.Reducer}."); -}; +```csharp +record Event +{ + public record Reducer(ReducerEvent ReducerEvent) : Event; +} ``` -#### Static Event `{TABLE}.OnDelete` +Event when we are notified that a reducer ran in the remote module. The [`ReducerEvent`](#record-reducerevent) contains metadata about the reducer run, including its arguments and termination [`Status`](#record-status). -```cs -namespace SpacetimeDB.Types { +This event is passed to row callbacks resulting from modifications by the reducer. -class TABLE { - public delegate void DeleteEventHandler( - TABLE deletedValue, - SpacetimeDB.ReducerEvent dbEvent - ); - public static event DeleteEventHandler OnDelete; -} +#### Variant `SubscribeApplied` +```csharp +record Event +{ + public record SubscribeApplied : Event; } ``` -Register a delegate for when a subscribed row is being deleted from the database. If a reducer deletes many rows at once, this delegate will be invoked on those rows in arbitrary order, and data for some rows may already be missing when it is invoked. For this reason, prefer the event [`{TABLE}.OnBeforeDelete`](#static-event-tableonbeforedelete). - -The delegate takes two arguments: +Event when our subscription is applied and its rows are inserted into the client cache. -- A [`{TABLE}`](#class-table) instance with the data of the deleted row -- A [`ReducerEvent`](#class-reducerevent), which contains the data of the reducer that deleted the row. +This event is passed to [row `OnInsert` callbacks](#callback-oninsert) resulting from the new subscription. -```cs -using SpacetimeDB; -using SpacetimeDB.Types; +#### Variant `UnsubscribeApplied` -/* initialize, subscribe to table User... */ - -User.OnBeforeDelete += (User user, ReducerEvent reducerEvent) => { - Console.WriteLine($"User '{user.Name}' deleted by reducer {reducerEvent.Reducer}."); -}; +```csharp +record Event +{ + public record UnsubscribeApplied : Event; +} ``` -#### Static Event `{TABLE}.OnUpdate` +Event when our subscription is removed after a call to [`SubscriptionHandle.Unsubscribe`](#method-unsubscribe) or [`SubscriptionHandle.UnsubscribeTthen`](#method-unsubscribethen) and its matching rows are deleted from the client cache. -```cs -namespace SpacetimeDB.Types { +This event is passed to [row `OnDelete` callbacks](#callback-ondelete) resulting from the subscription ending. -class TABLE { - public delegate void UpdateEventHandler( - TABLE oldValue, - TABLE newValue, - ReducerEvent dbEvent - ); - public static event UpdateEventHandler OnUpdate; -} +#### Variant `SubscribeError` +```csharp +record Event +{ + public record SubscribeError(Exception Exception) : Event; } ``` -Register a delegate for when a subscribed row is being updated. This event is only available if the row has a column with the `#[primary_key]` attribute. +Event when a subscription ends unexpectedly due to an error. -The delegate takes three arguments: +This event is passed to [row `OnDelete` callbacks](#callback-ondelete) resulting from the subscription ending. -- A [`{TABLE}`](#class-table) instance with the old data of the updated row -- A [`{TABLE}`](#class-table) instance with the new data of the updated row -- A [`ReducerEvent`](#class-reducerevent), which contains the data of the reducer that updated the row. +#### Variant `UnknownTransaction` -```cs -using SpacetimeDB; -using SpacetimeDB.Types; - -/* initialize, subscribe to table User... */ - -User.OnUpdate += (User oldUser, User newUser, ReducerEvent reducerEvent) => { - Debug.Assert(oldUser.UserId == newUser.UserId, "Primary key never changes in an update"); - - Console.WriteLine($"User with ID {oldUser.UserId} had name changed "+ - $"from '{oldUser.Name}' to '{newUser.Name}' by reducer {reducerEvent.Reducer}."); -}; +```csharp +record Event +{ + public record UnknownTransaction : Event; +} ``` -## Observe and invoke reducers +Event when we are notified of a transaction in the remote module which we cannot associate with a known reducer. This may be an ad-hoc SQL query or a reducer for which we do not have bindings. -"Reducer" is SpacetimeDB's name for the stored procedures that run in modules inside the database. You can invoke reducers from a connected client SDK, and also receive information about which reducers are running. +This event is passed to [row callbacks](#callback-oninsert) resulting from modifications by the transaction. -`spacetime generate` generates a class [`SpacetimeDB.Types.Reducer`](#class-reducer) that contains methods and events for each reducer defined in a module. To invoke a reducer, use the method [`Reducer.{REDUCER}`](#static-method-reducerreducer) generated for it. To receive a callback each time a reducer is invoked, use the static event [`Reducer.On{REDUCER}`](#static-event-reduceronreducer). +### Record `ReducerEvent` -### Class `Reducer` +```csharp +record ReducerEvent( + Timestamp Timestamp, + Status Status, + Identity CallerIdentity, + ConnectionId? CallerConnectionId, + U128? EnergyConsumed, + R Reducer +) +``` -```cs -namespace SpacetimeDB.Types { +A `ReducerEvent` contains metadata about a reducer run. -class Reducer {} +### Record `Status` -} +```csharp +record Status : TaggedEnum<( + Unit Committed, + string Failed, + Unit OutOfEnergy +)>; ``` -This class contains a static method and event for each reducer defined in a module. + -#### Static Method `Reducer.{REDUCER}` +| Name | Description | +|-----------------------------------------------|-----------------------------------------------------| +| [`Committed` variant](#variant-committed) | The reducer ran successfully. | +| [`Failed` variant](#variant-failed) | The reducer errored. | +| [`OutOfEnergy` variant](#variant-outofenergy) | The reducer was aborted due to insufficient energy. | -```cs -namespace SpacetimeDB.Types { -class Reducer { +#### Variant `Committed` -/* void {REDUCER_NAME}(...ARGS...) */ +The reducer returned successfully and its changes were committed into the database state. An [`Event.Reducer`](#variant-reducer) passed to a row callback must have this status in its [`ReducerEvent`](#record-reducerevent). -} -} -``` +#### Variant `Failed` -For each reducer defined by a module, `spacetime generate` generates a static method which sends a request to the database to invoke that reducer. The generated function's name is the reducer's name converted to `PascalCase`. +The reducer returned an error, panicked, or threw an exception. The record payload is the stringified error message. Formatting of the error message is unstable and subject to change, so clients should use it only as a human-readable diagnostic, and in particular should not attempt to parse the message. -Reducers don't run immediately! They run as soon as the request reaches the database. Don't assume data inserted by a reducer will be available immediately after you call this method. +#### Variant `OutOfEnergy` -For reducers which accept a `ReducerContext` as their first argument, the `ReducerContext` is not included in the generated function's argument list. +The reducer was aborted due to insufficient energy balance of the module owner. -For example, if we define a reducer in Rust as follows: +### Record `Reducer` -```rust -#[spacetimedb(reducer)] -pub fn set_name( - ctx: ReducerContext, - user_id: u64, - name: String -) -> Result<(), Error>; -``` +The module bindings contains an record `Reducer` with a variant for each reducer defined by the module. Each variant has a payload containing the arguments to the reducer. -The following C# static method will be generated: +## Type `ReducerEventContext` -```cs -namespace SpacetimeDB.Types { -class Reducer { +A `ReducerEventContext` is an [`IDbContext`](#interface-idbcontext) augmented with an [`Event`](#record-reducerevent) property. `ReducerEventContext`s are passed as the first argument to [reducer callbacks](#observe-and-invoke-reducers). -public static void SendMessage(UInt64 userId, string name); +| Name | Description | +|-------------------------------------------|---------------------------------------------------------------------| +| [`Event` property](#property-event) | [`ReducerEvent`](#record-reducerevent) containing reducer metadata. | +| [`Db` property](#property-db) | Provides access to the client cache. | +| [`Reducers` property](#property-reducers) | Allows requesting reducers run on the remote database. | -} +### Property `Event` + +```csharp +class ReducerEventContext { + public readonly ReducerEvent Event; + /* other fields */ } ``` -#### Static Event `Reducer.On{REDUCER}` +The [`ReducerEvent`](#record-reducerevent) contained in the `ReducerEventContext` has metadata about the reducer which ran. -```cs -namespace SpacetimeDB.Types { -class Reducer { +### Property `Db` -public delegate void /*{REDUCER}*/Handler(ReducerEvent reducerEvent, /* {ARGS...} */); - -public static event /*{REDUCER}*/Handler On/*{REDUCER}*/Event; - -} +```csharp +class ReducerEventContext { + public RemoteTables Db; + /* other fields */ } ``` -For each reducer defined by a module, `spacetime generate` generates an event to run each time the reducer is invoked. The generated functions are named `on{REDUCER}Event`, where `{REDUCER}` is the reducer's name converted to `PascalCase`. - -The first argument to the event handler is an instance of [`SpacetimeDB.Types.ReducerEvent`](#class-reducerevent) describing the invocation -- its timestamp, arguments, and whether it succeeded or failed. The remaining arguments are the arguments passed to the reducer. Reducers cannot have return values, so no return value information is included. +The `Db` property of the context provides access to the subscribed view of the remote database's tables. See [Access the client cache](#access-the-client-cache). -For example, if we define a reducer in Rust as follows: +### Property `Reducers` -```rust -#[spacetimedb(reducer)] -pub fn set_name( - ctx: ReducerContext, - user_id: u64, - name: String -) -> Result<(), Error>; +```csharp +class ReducerEventContext { + public RemoteReducers Reducers; + /* other fields */ +} ``` -The following C# static method will be generated: +The `Reducers` property of the context provides access to reducers exposed by the remote module. See [Observe and invoke reducers](#observe-and-invoke-reducers). -```cs -namespace SpacetimeDB.Types { -class Reducer { +## Type `SubscriptionEventContext` -public delegate void SetNameHandler( - ReducerEvent reducerEvent, - UInt64 userId, - string name -); -public static event SetNameHandler OnSetNameEvent; +A `SubscriptionEventContext` is an [`IDbContext`](#interface-idbcontext). Unlike the other context types, `SubscriptionEventContext` doesn't have an `Event` property. `SubscriptionEventContext`s are passed to subscription [`OnApplied`](#callback-onapplied) and [`UnsubscribeThen`](#method-unsubscribethen) callbacks. -} +| Name | Description | +|-------------------------------------------|------------------------------------------------------------| +| [`Db` property](#property-db) | Provides access to the client cache. | +| [`Reducers` property](#property-reducers) | Allows requesting reducers run on the remote database. | + +### Property `Db` + +```csharp +class SubscriptionEventContext { + public RemoteTables Db; + /* other fields */ } ``` -Which can be used as follows: +The `Db` property of the context provides access to the subscribed view of the remote database's tables. See [Access the client cache](#access-the-client-cache). -```cs -/* initialize, wait for onSubscriptionApplied... */ +### Property `Reducers` -Reducer.SetNameHandler += ( - ReducerEvent reducerEvent, - UInt64 userId, - string name -) => { - if (reducerEvent.Status == ClientApi.Event.Types.Status.Committed) { - Console.WriteLine($"User with id {userId} set name to {name}"); - } else if (reducerEvent.Status == ClientApi.Event.Types.Status.Failed) { - Console.WriteLine( - $"User with id {userId} failed to set name to {name}:" - + reducerEvent.ErrMessage - ); - } else if (reducerEvent.Status == ClientApi.Event.Types.Status.OutOfEnergy) { - Console.WriteLine( - $"User with id {userId} failed to set name to {name}:" - + "Invoker ran out of energy" - ); - } -}; -Reducer.SetName(USER_ID, NAME); +```csharp +class SubscriptionEventContext { + public RemoteReducers Reducers; + /* other fields */ +} ``` -### Class `ReducerEvent` +The `Reducers` property of the context provides access to reducers exposed by the remote module. See [Observe and invoke reducers](#observe-and-invoke-reducers). -`spacetime generate` defines an class `ReducerEvent` containing an enum `ReducerType` with a variant for each reducer defined by a module. The variant's name will be the reducer's name converted to `PascalCase`. +## Type `ErrorContext` -For example, the example project shown in the Rust Module quickstart will generate the following (abridged) code. +An `ErrorContext` is an [`IDbContext`](#interface-idbcontext) augmented with an `Event` property. `ErrorContext`s are to connections' [`OnDisconnect`](#callback-ondisconnect) and [`OnConnectError`](#callback-onconnecterror) callbacks, and to subscriptions' [`OnError`](#callback-onerror) callbacks. -```cs -namespace SpacetimeDB.Types { +| Name | Description | +|-------------------------------------------|--------------------------------------------------------| +| [`Event` property](#property-event) | The error which caused the current error callback. | +| [`Db` property](#property-db) | Provides access to the client cache. | +| [`Reducers` property](#property-reducers) | Allows requesting reducers run on the remote database. | -public enum ReducerType -{ - /* A member for each reducer in the module, with names converted to PascalCase */ - None, - SendMessage, - SetName, -} -public partial class SendMessageArgsStruct -{ - /* A member for each argument of the reducer SendMessage, with names converted to PascalCase. */ - public string Text; -} -public partial class SetNameArgsStruct -{ - /* A member for each argument of the reducer SetName, with names converted to PascalCase. */ - public string Name; -} -public partial class ReducerEvent : ReducerEventBase { - // Which reducer was invoked - public ReducerType Reducer { get; } - // If event.Reducer == ReducerType.SendMessage, the arguments - // sent to the SendMessage reducer. Otherwise, accesses will - // throw a runtime error. - public SendMessageArgsStruct SendMessageArgs { get; } - // If event.Reducer == ReducerType.SetName, the arguments - // passed to the SetName reducer. Otherwise, accesses will - // throw a runtime error. - public SetNameArgsStruct SetNameArgs { get; } - /* Additional information, present on any ReducerEvent */ - // The name of the reducer. - public string ReducerName { get; } - // The timestamp of the reducer invocation inside the database. - public ulong Timestamp { get; } - // The identity of the client that invoked the reducer. - public SpacetimeDB.Identity Identity { get; } - // Whether the reducer succeeded, failed, or ran out of energy. - public ClientApi.Event.Types.Status Status { get; } - // If event.Status == Status.Failed, the error message returned from inside the module. - public string ErrMessage { get; } -} +### Property `Event` +```csharp +class SubscriptionEventContext { + public readonly Exception Event; + /* other fields */ } ``` -#### Enum `Status` - -```cs -namespace ClientApi { -public sealed partial class Event { -public static partial class Types { - -public enum Status { - Committed = 0, - Failed = 1, - OutOfEnergy = 2, -} +### Property `Db` -} -} +```csharp +class ErrorContext { + public RemoteTables Db; + /* other fields */ } ``` -An enum whose variants represent possible reducer completion statuses of a reducer invocation. +The `Db` property of the context provides access to the subscribed view of the remote database's tables. See [Access the client cache](#access-the-client-cache). -##### Variant `Status.Committed` +### Property `Reducers` -The reducer finished successfully, and its row changes were committed to the database. - -##### Variant `Status.Failed` +```csharp +class ErrorContext { + public RemoteReducers Reducers; + /* other fields */ +} +``` -The reducer failed, either by panicking or returning a `Err`. +The `Reducers` property of the context provides access to reducers exposed by the remote module. See [Observe and invoke reducers](#observe-and-invoke-reducers). -##### Variant `Status.OutOfEnergy` +## Access the client cache -The reducer was canceled because the module owner had insufficient energy to allow it to run to completion. +All [`IDbContext`](#interface-idbcontext) implementors, including [`DbConnection`](#type-dbconnection) and [`EventContext`](#type-eventcontext), have `.Db` properties, which in turn have methods for accessing tables in the client cache. -## Identity management +Each table defined by a module has an accessor method, whose name is the table name converted to `snake_case`, on this `.Db` property. The table accessor methods return table handles which inherit from [`RemoteTableHandle`](#type-remotetablehandle) and have methods for searching by index. -### Class `AuthToken` +| Name | Description | +|-------------------------------------------------------------------|---------------------------------------------------------------------------------| +| [`RemoteTableHandle`](#type-remotetablehandle) | Provides access to subscribed rows of a specific table within the client cache. | +| [Unique constraint index access](#unique-constraint-index-access) | Seek a subscribed row by the value in its unique or primary key column. | +| [BTree index access](#btree-index-access) | Seek subscribed rows by the value in its indexed column. | -The AuthToken helper class handles creating and saving SpacetimeDB identity tokens in the filesystem. +### Type `RemoteTableHandle` -#### Static Method `AuthToken.Init` +Implemented by all table handles. -```cs -namespace SpacetimeDB { +| Name | Description | +|-----------------------------------------------|------------------------------------------------------------------------------| +| [`Row` type parameter](#type-row) | The type of rows in the table. | +| [`Count` property](#property-count) | The number of subscribed rows in the table. | +| [`Iter` method](#method-iter) | Iterate over all subscribed rows in the table. | +| [`OnInsert` callback](#callback-oninsert) | Register a callback to run whenever a row is inserted into the client cache. | +| [`OnDelete` callback](#callback-ondelete) | Register a callback to run whenever a row is deleted from the client cache. | +| [`OnUpdate` callback](#callback-onupdate) | Register a callback to run whenever a subscribed row is replaced with a new version. | -class AuthToken { - public static void Init( - string configFolder = ".spacetime_csharp_sdk", - string configFile = "settings.ini", - string? configRoot = null - ); -} +#### Type `Row` +```csharp +class RemoteTableHandle +{ + /* members */ } ``` -Creates a file `$"{configRoot}/{configFolder}/{configFile}"` to store tokens. -If no arguments are passed, the default is `"%HOME%/.spacetime_csharp_sdk/settings.ini"`. +The type of rows in the table. -| Argument | Type | Meaning | -| -------------- | -------- | ---------------------------------------------------------------------------------- | -| `configFolder` | `string` | The folder to store the config file in. Default is `"spacetime_csharp_sdk"`. | -| `configFile` | `string` | The name of the config file. Default is `"settings.ini"`. | -| `configRoot` | `string` | The root folder to store the config file in. Default is the user's home directory. | +#### Property `Count` -#### Static Property `AuthToken.Token` +```csharp +class RemoteTableHandle +{ + public int Count; +} +``` -```cs -namespace SpacetimeDB { +The number of rows of this table resident in the client cache, i.e. the total number which match any subscribed query. -class AuthToken { - public static string? Token { get; } -} +#### Method `Iter` +```csharp +class RemoteTableHandle +{ + public IEnumerable Iter(); } ``` -The auth token stored on the filesystem, if one exists. - -#### Static Method `AuthToken.SaveToken` - -```cs -namespace SpacetimeDB { +An iterator over all the subscribed rows in the client cache, i.e. those which match any subscribed query. -class AuthToken { - public static void SaveToken(string token); -} +#### Callback `OnInsert` +```csharp +class RemoteTableHandle +{ + public delegate void RowEventHandler(EventContext context, Row row); + public event RowEventHandler? OnInsert; } ``` -Save a token to the filesystem. +The `OnInsert` callback runs whenever a new row is inserted into the client cache, either when applying a subscription or being notified of a transaction. The passed [`EventContext`](#type-eventcontext) contains an [`Event`](#record-event) which can identify the change which caused the insertion, and also allows the callback to interact with the connection, inspect the client cache and invoke reducers. Newly registered or canceled callbacks do not take effect until the following event. + +See [the quickstart](/docs/sdks/c-sharp/quickstart#register-callbacks) for examples of regstering and unregistering row callbacks. -### Class `Identity` +#### Callback `OnDelete` -```cs -namespace SpacetimeDB +```csharp +class RemoteTableHandle { - public struct Identity : IEquatable - { - public byte[] Bytes { get; } - public static Identity From(byte[] bytes); - public bool Equals(Identity other); - public static bool operator ==(Identity a, Identity b); - public static bool operator !=(Identity a, Identity b); - } + public delegate void RowEventHandler(EventContext context, Row row); + public event RowEventHandler? OnDelete; } ``` -A unique public identifier for a user of a database. +The `OnDelete` callback runs whenever a previously-resident row is deleted from the client cache. Newly registered or canceled callbacks do not take effect until the following event. - +See [the quickstart](/docs/sdks/c-sharp/quickstart#register-callbacks) for examples of regstering and unregistering row callbacks. -Columns of type `Identity` inside a module will be represented in the C# SDK as properties of type `byte[]`. `Identity` is essentially just a wrapper around `byte[]`, and you can use the `Bytes` property to get a `byte[]` that can be used to filter tables and so on. +#### Callback `OnUpdate` -```cs -namespace SpacetimeDB +```csharp +class RemoteTableHandle { - public struct Address : IEquatable
- { - public byte[] Bytes { get; } - public static Address? From(byte[] bytes); - public bool Equals(Address other); - public static bool operator ==(Address a, Address b); - public static bool operator !=(Address a, Address b); - } + public delegate void RowEventHandler(EventContext context, Row row); + public event RowEventHandler? OnUpdate; } ``` -An opaque identifier for a client connection to a database, intended to differentiate between connections from the same [`Identity`](#class-identity). - -## Customizing logging +The `OnUpdate` callback runs whenever an already-resident row in the client cache is updated, i.e. replaced with a new row that has the same primary key. The table must have a primary key for callbacks to be triggered. Newly registered or canceled callbacks do not take effect until the following event. -The SpacetimeDB C# SDK performs internal logging. +See [the quickstart](/docs/sdks/c-sharp/quickstart#register-callbacks) for examples of regstering and unregistering row callbacks. -A default logger is set up automatically for you - a [`ConsoleLogger`](#class-consolelogger) for C# projects and [`UnityDebugLogger`](#class-unitydebuglogger) for Unity projects. +### Unique constraint index access -If you want to redirect SDK logs elsewhere, you can inherit from the [`ISpacetimeDBLogger`](#interface-ispacetimedblogger) and assign an instance of your class to the `SpacetimeDB.Logger.Current` static property. +For each unique constraint on a table, its table handle has a property which is a unique index handle and whose name is the unique column name. This unique index handle has a method `.Find(Column value)`. If a `Row` with `value` in the unique column is resident in the client cache, `.Find` returns it. Otherwise it returns null. -### Interface `ISpacetimeDBLogger` -```cs -namespace SpacetimeDB -{ +#### Example -public interface ISpacetimeDBLogger +Given the following module-side `User` definition: +```csharp +[Table(Name = "User", Public = true)] +public partial class User { - void Log(string message); - void LogError(string message); - void LogWarning(string message); - void LogException(Exception e); + [Unique] // Or [PrimaryKey] + public Identity Identity; + .. } +``` -} +a client would lookup a user as follows: +```csharp +User? FindUser(RemoteTables tables, Identity id) => tables.User.Identity.Find(id); ``` -This interface provides methods that are invoked when the SpacetimeDB C# SDK needs to log at various log levels. You can create custom implementations if needed to integrate with existing logging solutions. +### BTree index access -### Class `ConsoleLogger` +For each btree index defined on a remote table, its corresponding table handle has a property which is a btree index handle and whose name is the name of the index. This index handle has a method `IEnumerable Filter(Column value)` which will return `Row`s with `value` in the indexed `Column`, if there are any in the cache. -```cs -namespace SpacetimeDB { +#### Example -public class ConsoleLogger : ISpacetimeDBLogger {} +Given the following module-side `Player` definition: +```csharp +[Table(Name = "Player", Public = true)] +public partial class Player +{ + [PrimaryKey] + public Identity id; + [Index.BTree(Name = "Level")] + public uint level; + .. } ``` -An `ISpacetimeDBLogger` implementation for regular .NET applications, using `Console.Write` when logs are received. +a client would count the number of `Player`s at a certain level as follows: +```csharp +int CountPlayersAtLevel(RemoteTables tables, uint level) => tables.Player.Level.Filter(level).Count(); +``` -### Class `UnityDebugLogger` +## Observe and invoke reducers -```cs -namespace SpacetimeDB { +All [`IDbContext`](#interface-idbcontext) implementors, including [`DbConnection`](#type-dbconnection) and [`EventContext`](#type-eventcontext), have a `.Reducers` property, which in turn has methods for invoking reducers defined by the module and registering callbacks on it. -public class UnityDebugLogger : ISpacetimeDBLogger {} +Each reducer defined by the module has three methods on the `.Reducers`: -} -``` +- An invoke method, whose name is the reducer's name converted to snake case, like `set_name`. This requests that the module run the reducer. +- A callback registation method, whose name is prefixed with `on_`, like `on_set_name`. This registers a callback to run whenever we are notified that the reducer ran, including successfully committed runs and runs we requested which failed. This method returns a callback id, which can be passed to the callback remove method. +- A callback remove method, whose name is prefixed with `remove_on_`, like `remove_on_set_name`. This cancels a callback previously registered via the callback registration method. + +## Identify a client + +### Type `Identity` + +A unique public identifier for a client connected to a database. +See the [module docs](/docs/modules/c-sharp#struct-identity) for more details. + +### Type `ConnectionId` + +An opaque identifier for a client connection to a database, intended to differentiate between connections from the same [`Identity`](#type-identity). +See the [module docs](/docs/modules/c-sharp#struct-connectionid) for more details. + +### Type `Timestamp` + +A point in time, measured in microseconds since the Unix epoch. +See the [module docs](/docs/modules/c-sharp#struct-timestamp) for more details. + +### Type `TaggedEnum` -An `ISpacetimeDBLogger` implementation for Unity, using the Unity `Debug.Log` api. +A [tagged union](https://en.wikipedia.org/wiki/Tagged_union) type. +See the [module docs](/docs/modules/c-sharp#record-taggedenum) for more details. diff --git a/docs/sdks/c-sharp/quickstart.md b/docs/sdks/c-sharp/quickstart.md index db06d9a4..aba4b77c 100644 --- a/docs/sdks/c-sharp/quickstart.md +++ b/docs/sdks/c-sharp/quickstart.md @@ -28,6 +28,10 @@ Add the `SpacetimeDB.ClientSDK` [NuGet package](https://www.nuget.org/packages/s dotnet add package SpacetimeDB.ClientSDK ``` +## Clear `client/Program.cs` + +Clear out any data from `client/Program.cs` so we can write our chat client. + ## Generate your module types The `spacetime` CLI's `generate` command will generate client-side interfaces for the tables, reducers and types defined in your server module. @@ -39,15 +43,22 @@ mkdir -p client/module_bindings spacetime generate --lang csharp --out-dir client/module_bindings --project-path server ``` -Take a look inside `client/module_bindings`. The CLI should have generated five files: +Take a look inside `client/module_bindings`. The CLI should have generated three folders and nine files: ``` module_bindings -├── Message.cs -├── ReducerEvent.cs -├── SendMessageReducer.cs -├── SetNameReducer.cs -└── User.cs +├── Reducers +│ ├── ClientConnected.g.cs +│ ├── ClientDisconnected.g.cs +│ ├── SendMessage.g.cs +│ └── SetName.g.cs +├── Tables +│ ├── Message.g.cs +│ └── User.g.cs +├── Types +│ ├── Message.g.cs +│ └── User.g.cs +└── SpacetimeDBClient.g.cs ``` ## Add imports to Program.cs @@ -60,17 +71,16 @@ using SpacetimeDB.Types; using System.Collections.Concurrent; ``` -We will also need to create some global variables that will be explained when we use them later. Add the following to the top of `Program.cs`: +We will also need to create some global variables. 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 in format (command, args) -ConcurrentQueue<(string,string)> input_queue = new ConcurrentQueue<(string, string)>(); - -// declare a threadsafe cancel token to cancel the process loop -CancellationTokenSource cancel_token = new CancellationTokenSource(); +// declare a thread safe queue to store commands +var input_queue = new ConcurrentQueue<(string Command, string Args)>(); ``` ## Define Main function @@ -78,58 +88,152 @@ CancellationTokenSource cancel_token = new CancellationTokenSource(); We'll work outside-in, first defining our `Main` function at a high level, then implementing each behavior it needs. We need `Main` to do several things: 1. Initialize the `AuthToken` module, which loads and stores our authentication token to/from local storage. -2. Create the `SpacetimeDBClient` instance. -3. Register callbacks on any events we want to handle. These will print to standard output messages received from the database and updates about users' names and online statuses. +2. Connect to the database. +3. Register a number of callbacks to run in response to various database events. 4. Start our processing thread which connects to the SpacetimeDB module, updates the SpacetimeDB client and processes commands that come in from the input loop running in the main thread. 5. Start the input loop, which reads commands from standard input and sends them to the processing thread. 6. When the input loop exits, stop the processing thread and wait for it to exit. +To `Program.cs`, add: + ```csharp void Main() { + // Initialize the `AuthToken` module AuthToken.Init(".spacetime_csharp_quickstart"); + // Builds and connects to the database + DbConnection? conn = null; + conn = ConnectToDB(); + // Registers to run in response to database events. + RegisterCallbacks(conn); + // Declare a threadsafe cancel token to cancel the process loop + var cancellationTokenSource = new CancellationTokenSource(); + // Spawn a thread to call process updates and process commands + var thread = new Thread(() => ProcessThread(conn, cancellationTokenSource.Token)); + thread.Start(); + // Handles CLI input + InputLoop(); + // This signals the ProcessThread to stop + cancellationTokenSource.Cancel(); + thread.Join(); +} +``` - RegisterCallbacks(); +## Connect to database - // spawn a thread to call process updates and process commands - var thread = new Thread(ProcessThread); - thread.Start(); +Before we connect, we'll store the SpacetimeDB host name and our module name in constants `HOST` and `DB_NAME`. - InputLoop(); +A connection to a SpacetimeDB database is represented by a `DbConnection`. We configure `DbConnection`s using the builder pattern, by calling `DbConnection.Builder()`, chaining method calls to set various connection parameters and register callbacks, then we cap it off with a call to `.Build()` to begin the connection. - // this signals the ProcessThread to stop - cancel_token.Cancel(); - thread.Join(); +In our case, we'll supply the following options: + +1. A `WithUri` call, to specify the URI of the SpacetimeDB host where our module is running. +2. A `WithModuleName` call, to specify the name or `Identity` of our database. Make sure to pass the same name here as you supplied to `spacetime publish`. +3. A `WithToken` call, to supply a token to authenticate with. +4. An `OnConnect` callback, to run when the remote database acknowledges and accepts our connection. +5. An `OnConnectError` callback, to run if the remote database is unreachable or it rejects our connection. +6. An `OnDisconnect` callback, to run when our connection ends. + +To `Program.cs`, add: + +```csharp +/// The URI of the SpacetimeDB instance hosting our chat module. +const string HOST = "http://localhost:3000"; + +/// The module name we chose when we published our module. +const string DBNAME = "quickstart-chat"; + +/// Load credentials from a file and connect to the database. +DbConnection ConnectToDB() +{ + DbConnection? conn = null; + conn = DbConnection.Builder() + .WithUri(HOST) + .WithModuleName(DBNAME) + .WithToken(AuthToken.Token) + .OnConnect(OnConnected) + .OnConnectError(OnConnectError) + .OnDisconnect(OnDisconnected) + .Build(); + return conn; } ``` -## Register callbacks +### Save credentials + +SpacetimeDB will accept any [OpenID Connect](https://openid.net/developers/how-connect-works/) compliant [JSON Web Token](https://jwt.io/) and use it to compute an `Identity` for the user. More complex applications will generally authenticate their user somehow, generate or retrieve a token, and attach it to their connection via `WithToken`. In our case, though, we'll connect anonymously the first time, let SpacetimeDB generate a fresh `Identity` and corresponding JWT for us, and save that token locally to re-use the next time we connect. + +Once we are connected, we'll use the `AuthToken` module to save our token to local storage, so that we can re-authenticate as the same user the next time we connect. We'll also store the identity in a global variable `local_identity` so that we can use it to check if we are the sender of a message or name change. This callback also notifies us of our client's `Address`, an opaque identifier SpacetimeDB modules can use to distinguish connections by the same `Identity`, but we won't use it in our app. + +To `Program.cs`, add: + +```csharp +/// Our `OnConnected` callback: save our credentials to a file. +void OnConnected(DbConnection conn, Identity identity, string authToken) +{ + local_identity = identity; + AuthToken.SaveToken(authToken); +} +``` + +### Connect Error callback + +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: -We need to handle several sorts of events: +```csharp +/// Our `OnConnectError` callback: print the error, then exit the process. +void OnConnectError(Exception e) +{ + Console.Write($"Error while connecting: {e}"); +} +``` -1. `onConnect`: When we connect, we will call `Subscribe` to tell the module what tables we care about. -2. `onIdentityReceived`: When we receive our credentials, we'll use the `AuthToken` module to save our token so that the next time we connect, we can re-authenticate as the same user. -3. `onSubscriptionApplied`: When we get the onSubscriptionApplied callback, that means our local client cache has been fully populated. At this time we'll print the user menu. -4. `User.OnInsert`: When a new user joins, we'll print a message introducing them. -5. `User.OnUpdate`: When a user is updated, we'll print their new name, or declare their new online status. -6. `Message.OnInsert`: When we receive a new message, we'll print it. -7. `Reducer.OnSetNameEvent`: If the server rejects our attempt to set our name, we'll print an error. -8. `Reducer.OnSendMessageEvent`: If the server rejects a message we send, we'll print an error. +### 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 -void RegisterCallbacks() +/// Our `OnDisconnect` callback: print a note, then exit the process. +void OnDisconnected(DbConnection conn, Exception? e) { - SpacetimeDBClient.instance.onConnect += OnConnect; - SpacetimeDBClient.instance.onIdentityReceived += OnIdentityReceived; - SpacetimeDBClient.instance.onSubscriptionApplied += OnSubscriptionApplied; + 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: - User.OnInsert += User_OnInsert; - User.OnUpdate += User_OnUpdate; +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. - Message.OnInsert += Message_OnInsert; +To `Program.cs`, add: - Reducer.OnSetNameEvent += Reducer_OnSetNameEvent; - Reducer.OnSendMessageEvent += Reducer_OnSendMessageEvent; +```csharp +/// Register all the callbacks our app will use to respond to database events. +void RegisterCallbacks(DbConnection conn) +{ + conn.Db.User.OnInsert += User_OnInsert; + conn.Db.User.OnUpdate += User_OnUpdate; + + conn.Db.Message.OnInsert += Message_OnInsert; + + conn.Reducers.OnSetName += Reducer_OnSetNameEvent; + conn.Reducers.OnSendMessage += Reducer_OnSendMessageEvent; } ``` @@ -144,14 +248,18 @@ These callbacks can fire in two contexts: This second case means that, even though the module only ever inserts online users, the client's `User.OnInsert` callbacks may be invoked with users who are offline. We'll only notify about online users. -`OnInsert` and `OnDelete` callbacks take two arguments: the altered row, and a `ReducerEvent`. This will be `null` for rows inserted when initializing the cache for a subscription. `ReducerEvent` is an enum autogenerated by `spacetime generate` with a variant for each reducer defined by the module. For now, we can ignore this argument. +`OnInsert` and `OnDelete` callbacks take two arguments: an `EventContext` and the altered row. The `EventContext.Event` is an enum which describes the event that caused the row to be inserted or deleted. All SpacetimeDB callbacks accept a context argument, which you can use in place of your top-level `DbConnection`. Whenever we want to print a user, if they have set a name, we'll use that. If they haven't set a name, we'll instead print the first 8 bytes of their identity, encoded as hexadecimal. We'll define a function `UserNameOrIdentity` to handle this. +To `Program.cs`, add: + ```csharp -string UserNameOrIdentity(User user) => user.Name ?? user.Identity.ToString()!.Substring(0, 8); +/// If the user has no set name, use the first 8 characters from their identity. +string UserNameOrIdentity(User user) => user.Name ?? user.Identity.ToString()[..8]; -void User_OnInsert(User insertedValue, ReducerEvent? dbEvent) +/// Our `User.OnInsert` callback: if the user is online, print a notification. +void User_OnInsert(EventContext ctx, User insertedValue) { if (insertedValue.Online) { @@ -162,9 +270,9 @@ void User_OnInsert(User insertedValue, ReducerEvent? dbEvent) ### Notify about updated users -Because we declared a primary key column in our `User` table, we can also register on-update callbacks. These run whenever a row is replaced by a row with the same primary key, like our module's `User::update_by_identity` calls. We register these callbacks using the `OnUpdate` method, which is automatically implemented by `spacetime generate` for any table with a primary key column. +Because we declared a primary key column in our `User` table, we can also register on-update callbacks. These run whenever a row is replaced by a row with the same primary key, like our module's `User.Identity.Update` calls. We register these callbacks using the `OnUpdate` method, which is automatically implemented by `spacetime generate` for any table with a primary key column. -`OnUpdate` callbacks take three arguments: the old row, the new row, and a `ReducerEvent`. +`OnUpdate` callbacks take three arguments: the old row, the new row, and a `EventContext`. In our module, users can be updated for three reasons: @@ -174,24 +282,27 @@ In our module, users can be updated for three reasons: We'll print an appropriate message in each of these cases. +To `Program.cs`, add: + ```csharp -void User_OnUpdate(User oldValue, User newValue, ReducerEvent dbEvent) +/// Our `User.OnUpdate` callback: +/// print a notification about name and status changes. +void User_OnUpdate(EventContext ctx, User oldValue, User newValue) { if (oldValue.Name != newValue.Name) { Console.WriteLine($"{UserNameOrIdentity(oldValue)} renamed to {newValue.Name}"); } - - if (oldValue.Online == newValue.Online) - return; - - if (newValue.Online) + if (oldValue.Online != newValue.Online) { - Console.WriteLine($"{UserNameOrIdentity(newValue)} connected."); - } - else - { - Console.WriteLine($"{UserNameOrIdentity(newValue)} disconnected."); + if (newValue.Online) + { + Console.WriteLine($"{UserNameOrIdentity(newValue)} connected."); + } + else + { + Console.WriteLine($"{UserNameOrIdentity(newValue)} disconnected."); + } } } ``` @@ -200,29 +311,35 @@ void User_OnUpdate(User oldValue, User newValue, ReducerEvent dbEvent) When we receive a new message, we'll print it to standard output, along with the name of the user who sent it. Keep in mind that we only want to do this for new messages, i.e. those inserted by a `SendMessage` reducer invocation. We have to handle the backlog we receive when our subscription is initialized separately, to ensure they're printed in the correct order. To that effect, our `OnInsert` callback will check if its `ReducerEvent` argument is not `null`, and only print in that case. -To find the `User` based on the message's `Sender` identity, we'll use `User::FindByIdentity`, which behaves like the same function on the server. +To find the `User` based on the message's `Sender` identity, we'll use `User.Identity.Find`, which behaves like the same function on the server. We'll print the user's name or identity in the same way as we did when notifying about `User` table events, but here we have to handle the case where we don't find a matching `User` row. This can happen when the module owner sends a message using the CLI's `spacetime call`. In this case, we'll print `unknown`. +To `Program.cs`, add: + ```csharp -void PrintMessage(Message message) +/// Our `Message.OnInsert` callback: print new messages. +void Message_OnInsert(EventContext ctx, Message insertedValue) { - var sender = User.FindByIdentity(message.Sender); - var senderName = "unknown"; - if (sender != null) + // 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) { - senderName = UserNameOrIdentity(sender); + PrintMessage(ctx.Db, insertedValue); } - - Console.WriteLine($"{senderName}: {message.Text}"); } -void Message_OnInsert(Message insertedValue, ReducerEvent? dbEvent) +void PrintMessage(RemoteTables tables, Message message) { - if (dbEvent != null) + var sender = tables.User.Identity.Find(message.Sender); + var senderName = "unknown"; + if (sender != null) { - PrintMessage(insertedValue); + senderName = UserNameOrIdentity(sender); } + + Console.WriteLine($"{senderName}: {message.Text}"); } ``` @@ -232,11 +349,11 @@ We can also register callbacks to run each time a reducer is invoked. We registe Each reducer callback takes one fixed argument: -The ReducerEvent that triggered the callback. It contains several fields. The ones we care about are: +The `ReducerEventContext` of the callback, which contains an `Event` that contains several fields. The ones we care about are: -1. The `Identity` of the client that called the reducer. +1. The `CallerIdentity`, the `Identity` of the client that called the reducer. 2. The `Status` of the reducer run, one of `Committed`, `Failed` or `OutOfEnergy`. -3. The error message, if any, that the reducer returned. +3. If we get a `Status.Failed`, an error message is nested inside that we'll want to write to the console. It also takes a variable amount of additional arguments that match the reducer's arguments. @@ -251,16 +368,16 @@ We already handle successful `SetName` invocations using our `User.OnUpdate` cal We'll test both that our identity matches the sender and that the status is `Failed`, even though the latter implies the former, for demonstration purposes. +To `Program.cs`, add: + ```csharp -void Reducer_OnSetNameEvent(ReducerEvent reducerEvent, string name) +/// Our `OnSetNameEvent` callback: print a warning if the reducer failed. +void Reducer_OnSetNameEvent(ReducerEventContext ctx, string name) { - bool localIdentityFailedToChangeName = - reducerEvent.Identity == local_identity && - reducerEvent.Status == ClientApi.Event.Types.Status.Failed; - - if (localIdentityFailedToChangeName) + var e = ctx.Event; + if (e.CallerIdentity == local_identity && e.Status is Status.Failed(var error)) { - Console.Write($"Failed to change name to {name}"); + Console.Write($"Failed to change name to {name}: {error}"); } } ``` @@ -269,43 +386,42 @@ void Reducer_OnSetNameEvent(ReducerEvent reducerEvent, string name) We handle warnings on rejected messages the same way as rejected names, though the types and the error message are different. +To `Program.cs`, add: + ```csharp -void Reducer_OnSendMessageEvent(ReducerEvent reducerEvent, string text) +/// Our `OnSendMessageEvent` callback: print a warning if the reducer failed. +void Reducer_OnSendMessageEvent(ReducerEventContext ctx, string text) { - bool localIdentityFailedToSendMessage = - reducerEvent.Identity == local_identity && - reducerEvent.Status == ClientApi.Event.Types.Status.Failed; - - if (localIdentityFailedToSendMessage) + var e = ctx.Event; + if (e.CallerIdentity == local_identity && e.Status is Status.Failed(var error)) { - Console.Write($"Failed to send message {text}"); + Console.Write($"Failed to send message {text}: {error}"); } } ``` -## Connect callback +## Subscribe to queries -Once we are connected, we can send our subscription to the SpacetimeDB module. SpacetimeDB is set up so that each client subscribes via SQL queries to some subset of the database, and is notified about changes only to that subset. For complex apps with large databases, judicious subscriptions can save each client significant network bandwidth, memory and computation compared. For example, in [BitCraft](https://bitcraftonline.com), each player's client subscribes only to the entities in the "chunk" of the world where that player currently resides, rather than the entire game world. Our app is much simpler than BitCraft, so we'll just subscribe to the whole database. +SpacetimeDB is set up so that each client subscribes via SQL queries to some subset of the database, and is notified about changes only to that subset. For complex apps with large databases, judicious subscriptions can save each client significant network bandwidth, memory and computation. For example, in [BitCraft](https://bitcraftonline.com), each player's client subscribes only to the entities in the "chunk" of the world where that player currently resides, rather than the entire game world. Our app is much simpler than BitCraft, so we'll just subscribe to the whole database using `SubscribeToAllTables`. -```csharp -void OnConnect() -{ - SpacetimeDBClient.instance.Subscribe(new List - { - "SELECT * FROM User", "SELECT * FROM Message" - }); -} -``` +You can also subscribe to specific tables using SQL syntax, e.g. `SELECT * FROM my_table`. Our [SQL documentation](/docs/sql) enumerates the operations that are accepted in our SQL syntax. + +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. -## OnIdentityReceived callback +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. -This callback is executed when we receive our credentials from the SpacetimeDB module. We'll use the `AuthToken` module to save our token to local storage, so that we can re-authenticate as the same user the next time we connect. We'll also store the identity in a global variable `local_identity` so that we can use it to check if we are the sender of a message or name change. This callback also notifies us of our client's `Address`, an opaque identifier SpacetimeDB modules can use to distinguish connections by the same `Identity`, but we won't use it in our app. +In `Program.cs`, update our `OnConnected` function to include `conn.SubscriptionBuilder().OnApplied(OnSubscriptionApplied).SubscribeToAllTables();` so that it reads: ```csharp -void OnIdentityReceived(string authToken, Identity identity, Address _address) +/// Our `OnConnect` callback: save our credentials to a file. +void OnConnected(DbConnection conn, Identity identity, string authToken) { local_identity = identity; AuthToken.SaveToken(authToken); + + conn.SubscriptionBuilder() + .OnApplied(OnSubscriptionApplied) + .SubscribeToAllTables(); } ``` @@ -313,59 +429,60 @@ void OnIdentityReceived(string authToken, Identity identity, Address _address) Once our subscription is applied, we'll print all the previously sent messages. We'll define a function `PrintMessagesInOrder` to do this. `PrintMessagesInOrder` calls the automatically generated `Iter` function on our `Message` table, which returns an iterator over all rows in the table. We'll use the `OrderBy` method on the iterator to sort the messages by their `Sent` timestamp. +To `Program.cs`, add: + ```csharp -void PrintMessagesInOrder() +/// Our `OnSubscriptionApplied` callback: +/// sort all past messages and print them in timestamp order. +void OnSubscriptionApplied(SubscriptionEventContext ctx) { - foreach (Message message in Message.Iter().OrderBy(item => item.Sent)) - { - PrintMessage(message); - } + Console.WriteLine("Connected"); + PrintMessagesInOrder(ctx.Db); } -void OnSubscriptionApplied() +void PrintMessagesInOrder(RemoteTables tables) { - Console.WriteLine("Connected"); - PrintMessagesInOrder(); + foreach (Message message in tables.Message.Iter().OrderBy(item => item.Sent)) + { + PrintMessage(tables, message); + } } ``` - - ## Process thread -Since the input loop will be blocking, we'll run our processing code in a separate thread. This thread will: - -1. Connect to the module. We'll store the SpacetimeDB host name and our module name in constants `HOST` and `DB_NAME`. We will also store if SSL is enabled in a constant called `SSL_ENABLED`. This only needs to be `true` if we are using `SpacetimeDB Cloud`. Replace `` with the name you chose when publishing your module during the module quickstart. +Since the input loop will be blocking, we'll run our processing code in a separate thread. -`Connect` takes an auth token, which is `null` for a new connection, or a stored string for a returning user. We are going to use the optional AuthToken module which uses local storage to store the auth token. If you want to use your own way to associate an auth token with a user, you can pass in your own auth token here. +This thread will loop until the thread is signaled to exit, calling the update function `FrameTick` on the `DbConnection` to process any updates received from the module, and `ProcessCommand` to process any commands received from the input loop. -2. Loop until the thread is signaled to exit, calling `Update` on the SpacetimeDBClient to process any updates received from the module, and `ProcessCommand` to process any commands received from the input loop. +Afterward, close the connection to the module. -3. Finally, Close the connection to the module. +To `Program.cs`, add: ```csharp -const string HOST = "http://localhost:3000"; -const string DBNAME = "module"; - -void ProcessThread() +/// Our separate thread from main, where we can call process updates and process commands without blocking the main thread. +void ProcessThread(DbConnection conn, CancellationToken ct) { - SpacetimeDBClient.instance.Connect(AuthToken.Token, HOST, DBNAME); - - // loop until cancellation token - while (!cancel_token.IsCancellationRequested) + try { - SpacetimeDBClient.instance.Update(); + // loop until cancellation token + while (!ct.IsCancellationRequested) + { + conn.FrameTick(); - ProcessCommands(); + ProcessCommands(conn.Reducers); - Thread.Sleep(100); + Thread.Sleep(100); + } + } + finally + { + conn.Disconnect(); } - - SpacetimeDBClient.instance.Close(); } ``` -## Input loop and ProcessCommands +## Handle user input The input loop will read commands from standard input and send them to the processing thread using the input queue. The `ProcessCommands` function is called every 100ms by the processing thread to process any pending commands. @@ -375,7 +492,10 @@ Supported Commands: 2. Set name: `name`, will send the new name to the module by calling `Reducer.SetName` which is automatically generated by `spacetime generate`. +To `Program.cs`, add: + ```csharp +/// Read each line of standard input, and either set our name or send a message as appropriate. void InputLoop() { while (true) @@ -388,7 +508,7 @@ void InputLoop() if (input.StartsWith("/name ")) { - input_queue.Enqueue(("name", input.Substring(6))); + input_queue.Enqueue(("name", input[6..])); continue; } else @@ -398,18 +518,18 @@ void InputLoop() } } -void ProcessCommands() +void ProcessCommands(RemoteReducers reducers) { // process input queue commands while (input_queue.TryDequeue(out var command)) { - switch (command.Item1) + switch (command.Command) { case "message": - Reducer.SendMessage(command.Item2); + reducers.SendMessage(command.Args); break; case "name": - Reducer.SetName(command.Item2); + reducers.SetName(command.Args); break; } } @@ -418,7 +538,9 @@ void ProcessCommands() ## Run the client -Finally we just need to add a call to `Main` in `Program.cs`: +Finally, we just need to add a call to `Main`. + +To `Program.cs`, add: ```csharp Main(); @@ -432,4 +554,10 @@ dotnet run --project client ## What's next? -Congratulations! You've built a simple chat app using SpacetimeDB. You can look at the C# SDK Reference for more information about the client SDK. If you are interested in developing in the Unity game engine, check out our Unity3d Comprehensive Tutorial and BitcraftMini game example. +Congratulations! You've built a simple chat app using SpacetimeDB. + +You can find the full code for this client [in the C# client SDK's examples](https://github.com/clockworklabs/com.clockworklabs.spacetimedbsdk/tree/master/examples~/quickstart-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/sdks/rust/index.md b/docs/sdks/rust/index.md index d8befe53..a6dd23bb 100644 --- a/docs/sdks/rust/index.md +++ b/docs/sdks/rust/index.md @@ -2,7 +2,21 @@ The SpacetimeDB client SDK for Rust contains all the tools you need to build native clients for SpacetimeDB modules using Rust. -## Install the SDK +| Name | Description | +|-------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------| +| [Project setup](#project-setup) | Configure a Rust crate to use the SpacetimeDB Rust client SDK. | +| [Generate module bindings](#generate-module-bindings) | Use the SpacetimeDB CLI to generate module-specific types and interfaces. | +| [`DbConnection` type](#type-dbconnection) | A connection to a remote database. | +| [`DbContext` trait](#trait-dbcontext) | Methods for interacting with the remote database. Implemented by [`DbConnection`](#type-dbconnection) and various event context types. | +| [`EventContext` type](#type-eventcontext) | [`DbContext`](#trait-dbcontext) available in [row callbacks](#callback-on_insert). | +| [`ReducerEventContext` type](#type-reducereventcontext) | [`DbContext`](#trait-dbcontext) available in [reducer callbacks](#observe-and-invoke-reducers). | +| [`SubscriptionEventContext` type](#type-subscriptioneventcontext) | [`DbContext`](#trait-dbcontext) available in [subscription-related callbacks](#subscribe-to-queries). | +| [`ErrorContext` type](#type-errorcontext) | [`DbContext`](#trait-dbcontext) available in error-related callbacks. | +| [Access the client cache](#access-the-client-cache) | Make local queries against subscribed rows, and register [row callbacks](#callback-on_insert) to run when subscribed rows change. | +| [Observe and invoke reducers](#observe-and-invoke-reducers) | Send requests to the database to run reducers, and register callbacks to run when notified of reducers. | +| [Identify a client](#identify-a-client) | Types for identifying users and client connections. | + +## Project setup First, create a new project using `cargo new` and add the SpacetimeDB SDK to your dependencies: @@ -37,7 +51,13 @@ module_bindings::DbConnection A connection to a remote database is represented by the `module_bindings::DbConnection` type. This type is generated per-module, and contains information about the types, tables and reducers defined by your module. -### Connect to a module - `DbConnection::builder()` and `.build()` +| Name | Description | +|------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------| +| [Connect to a module](#connect-to-a-module) | Construct a `DbConnection`. | +| [Advance the connection](#advance-the-connection-and-process-messages) | Poll the `DbConnection`, or set up a background worker to run it. | +| [Access tables and reducers](#access-tables-and-reducers) | Access subscribed rows in the client cache, request reducer invocations, and register callbacks. | + +### Connect to a module ```rust impl DbConnection { @@ -45,7 +65,17 @@ impl DbConnection { } ``` -Construct a `DbConnection` by calling `DbConnection::builder()` and chaining configuration methods, then calling `.build()`. You must at least specify `with_uri`, to supply the URI of the SpacetimeDB to which you published your module, and `with_module_name`, to supply the human-readable SpacetimeDB domain name or the raw address which identifies the module. +Construct a `DbConnection` by calling `DbConnection::builder()` and chaining configuration methods, then calling `.build()`. You must at least specify `with_uri`, to supply the URI of the SpacetimeDB to which you published your module, and `with_module_name`, to supply the human-readable SpacetimeDB domain name or the raw `Identity` which identifies the module. + +| Name | Description | +|-----------------------------------------------------------|--------------------------------------------------------------------------------------| +| [`with_uri` method](#method-with_uri) | Set the URI of the SpacetimeDB instance which hosts the remote database. | +| [`with_module_name` method](#method-with_module_name) | Set the name or `Identity` of the remote database. | +| [`on_connect` callback](#callback-on_connect) | Register a callback to run when the connection is successfully established. | +| [`on_connect_error` callback](#callback-on_connect_error) | Register a callback to run if the connection is rejected or the host is unreachable. | +| [`on_disconnect` callback](#callback-on_disconnect) | Register a callback to run when the connection ends. | +| [`with_token` method](#method-with_token) | Supply a token to authenticate with the remote database. | +| [`build` method](#method-build) | Finalize configuration and connect. | #### Method `with_uri` @@ -61,11 +91,11 @@ Configure the URI of the SpacetimeDB instance or cluster which hosts the remote ```rust impl DbConnectionBuilder { - fn with_module_name(self, name_or_address: impl ToString) -> Self; + fn with_module_name(self, name_or_identity: impl ToString) -> Self; } ``` -Configure the SpacetimeDB domain name or address of the remote module which identifies it within the SpacetimeDB instance or cluster. +Configure the SpacetimeDB domain name or `Identity` of the remote module which identifies it within the SpacetimeDB instance or cluster. #### Callback `on_connect` @@ -75,41 +105,54 @@ impl DbConnectionBuilder { } ``` -Chain a call to `.on_connect(callback)` to your builder to register a callback to run when your new `DbConnection` successfully initiates its connection to the remote module. The callback accepts three arguments: a reference to the `DbConnection`, the `Identity` by which SpacetimeDB identifies this connection, and a private access token which can be saved and later passed to [`with_credentials`](#method-with_credentials) to authenticate the same user in future connections. +Chain a call to `.on_connect(callback)` to your builder to register a callback to run when your new `DbConnection` successfully initiates its connection to the remote module. The callback accepts three arguments: a reference to the `DbConnection`, the `Identity` by which SpacetimeDB identifies this connection, and a private access token which can be saved and later passed to [`with_token`](#method-with_token) to authenticate the same user in future connections. This interface may change in an upcoming release as we rework SpacetimeDB's authentication model. #### Callback `on_connect_error` -Currently unused. +```rust +impl DbConnectionBuilder { + fn on_connect_error( + self, + callback: impl FnOnce(&ErrorContext, spacetimedb_sdk::Error), + ) -> DbConnectionBuilder; +} +``` + +Chain a call to `.on_connect_error(callback)` to your builder to register a callback to run when your connection fails. + +A known bug in the SpacetimeDB Rust client SDK currently causes this callback never to be invoked. [`on_disconnect`](#callback-on_disconnect) callbacks are invoked instead. #### Callback `on_disconnect` ```rust impl DbConnectionBuilder { - fn on_disconnect(self, callback: impl FnOnce(&DbConnection, Option<&anyhow::Error>)) -> DbConnectionBuilder; + fn on_disconnect( + self, + callback: impl FnOnce(&ErrorContext, Option), + ) -> DbConnectionBuilder; } ``` -Chain a call to `.on_connect(callback)` to your builder to register a callback to run when your `DbConnection` disconnects from the remote module, either as a result of a call to [`disconnect`](#method-disconnect) or due to an error. +Chain a call to `.on_disconnect(callback)` to your builder to register a callback to run when your `DbConnection` disconnects from the remote module, either as a result of a call to [`disconnect`](#method-disconnect) or due to an error. -#### Method `with_credentials` +#### Method `with_token` ```rust impl DbConnectionBuilder { - fn with_credentials(self, credentials: Option<(Identity, String)>) -> Self; + fn with_token(self, token: Option>) -> Self; } ``` -Chain a call to `.with_credentials(credentials)` to your builder to provide an `Identity` and private access token to authenticate with, or to explicitly select an anonymous connection. If this method is not called or `None` is passed, SpacetimeDB will generate a new `Identity` and sign a new private access token for the connection. +Chain a call to `.with_token(token)` to your builder to provide an OpenID Connect compliant JSON Web Token to authenticate with, or to explicitly select an anonymous connection. If this method is not called or `None` is passed, SpacetimeDB will generate a new `Identity` and sign a new private access token for the connection. -This interface may change in an upcoming release as we rework SpacetimeDB's authentication model. #### Method `build` ```rust impl DbConnectionBuilder { - fn build(self) -> anyhow::Result; + fn build(self) -> Result; } ``` @@ -119,7 +162,13 @@ After configuring the connection and registering callbacks, attempt to open the In the interest of supporting a wide variety of client applications with different execution strategies, the SpacetimeDB SDK allows you to choose when the `DbConnection` spends compute time and processes messages. If you do not arrange for the connection to advance by calling one of these methods, the `DbConnection` will never advance, and no callbacks will ever be invoked. -#### Run in the background - method `run_threaded` +| Name | Description | +|-----------------------------------------------|-------------------------------------------------------| +| [`run_threaded` method](#method-run_threaded) | Spawn a thread to process messages in the background. | +| [`run_async` method](#method-run_async) | Process messages in an async task. | +| [`frame_tick` method](#method-frame_tick) | Process messages on the main thread without blocking. | + +#### Method `run_threaded` ```rust impl DbConnection { @@ -129,45 +178,150 @@ impl DbConnection { `run_threaded` spawns a thread which will continuously advance the connection, sleeping when there is no work to do. The thread will panic if the connection disconnects erroneously, or return if it disconnects as a result of a call to [`disconnect`](#method-disconnect). -#### Run asynchronously - method `run_async` +#### Method `run_async` ```rust impl DbConnection { - async fn run_async(&self) -> anyhow::Result<()>; + async fn run_async(&self) -> Result<(), spacetimedb_sdk::Error>; } ``` `run_async` will continuously advance the connection, `await`-ing when there is no work to do. The task will return an `Err` if the connection disconnects erroneously, or return `Ok(())` if it disconnects as a result of a call to [`disconnect`](#method-disconnect). -#### Run on the main thread without blocking - method `frame_tick` +#### Method `frame_tick` ```rust impl DbConnection { - fn frame_tick(&self) -> anyhow::Result<()>; + fn frame_tick(&self) -> Result<(), spacetimedb_sdk::Error>; } ``` `frame_tick` will advance the connection until no work remains, then return rather than blocking or `await`-ing. Games might arrange for this message to be called every frame. `frame_tick` returns `Ok` if the connection remains active afterwards, or `Err` if the connection disconnected before or during the call. +### Access tables and reducers + +#### Field `db` + +```rust +struct DbConnection { + pub db: RemoteTables, + /* other members */ +} +``` + +The `db` field of the `DbConnection` provides access to the subscribed view of the remote database's tables. See [Access the client cache](#access-the-client-cache). + +#### Field `reducers` + +```rust +struct DbConnection { + pub reducers: RemoteReducers, + /* other members */ +} +``` + +The `reducers` field of the `DbConnection` provides access to reducers exposed by the remote module. See [Observe and invoke reducers](#observe-and-invoke-reducers). + ## Trait `DbContext` -[`DbConnection`](#type-dbconnection) and [`EventContext`](#type-eventcontext) both implement `DbContext`, which allows +```rust +trait spacetimedb_sdk::DbContext { + /* methods */ +} +``` + +[`DbConnection`](#type-dbconnection), [`EventContext`](#type-eventcontext), [`ReducerEventContext`](#type-reducereventcontext), [`SubscriptionEventContext`](#type-subscriptioneventcontext) and [`ErrorContext`](#type-errorcontext) all implement `DbContext`. `DbContext` has methods for inspecting and configuring your connection to the remote database, including [`ctx.db()`](#method-db), a trait-generic alternative to reading the `.db` property on a concrete-typed context object. + +The `DbContext` trait is implemented by connections and contexts to *every* module. This means that its [`DbView`](#method-db) and [`Reducers`](#method-reducers) are associated types. + +| Name | Description | +|-------------------------------------------------------|--------------------------------------------------------------------------| +| [`RemoteDbContext` trait](#trait-remotedbcontext) | Module-specific `DbContext` extension trait with associated types bound. | +| [`db` method](#method-db) | Trait-generic alternative to the `db` field of `DbConnection`. | +| [`reducers` method](#method-reducers) | Trait-generic alternative to the `reducers` field of `DbConnection`. | +| [`disconnect` method](#method-disconnect) | End the connection. | +| [Subscribe to queries](#subscribe-to-queries) | Register SQL queries to receive updates about matching rows. | +| [Read connection metadata](#read-connection-metadata) | Access the connection's `Identity` and `ConnectionId` | + +### Trait `RemoteDbContext` + +```rust +trait module_bindings::RemoteDbContext + : spacetimedb_sdk::DbContext {} +``` + +Each module's `module_bindings` exports a trait `RemoteDbContext` which extends `DbContext`, with the associated types `DbView` and `Reducers` bound to the types defined for that module. This can be more convenient when creating functions that can be called from any callback for a specific module, but which access the database or invoke reducers, and so must know the type of the `DbView` or `Reducers`. + +### Method `db` + +```rust +trait DbContext { + fn db(&self) -> &Self::DbView; +} +``` + +When operating in trait-generic contexts, it is necessary to call the `ctx.db()` method, rather than accessing the `ctx.db` field, as Rust traits cannot expose fields. + +#### Example + +```rust +fn print_users(ctx: &impl RemoteDbContext) { + for user in ctx.db().user().iter() { + println!("{}", user.name); + } +} +``` + +### Method `reducers` + +```rust +trait DbContext { + fn reducerrs(&self) -> &Self::Reducers; +} +``` + +When operating in trait-generic contexts, it is necessary to call the `ctx.reducers()` method, rather than accessing the `ctx.reducers` field, as Rust traits cannot expose fields. + +#### Example + +```rust +fn call_say_hello(ctx: &impl RemoteDbContext) { + ctx.reducers.say_hello(); +} +``` ### Method `disconnect` ```rust trait DbContext { - fn disconnect(&self) -> anyhow::Result<()>; + fn disconnect(&self) -> spacetimedb_sdk::Result<()>; } ``` Gracefully close the `DbConnection`. Returns an `Err` if the connection is already disconnected. -### Subscribe to queries - `DbContext::subscription_builder` and `.subscribe()` +### Subscribe to queries -This interface is subject to change in an upcoming SpacetimeDB release. +| Name | Description | +|---------------------------------------------------------|-------------------------------------------------------------| +| [`SubscriptionBuilder` type](#type-subscriptionbuilder) | Builder-pattern constructor to register subscribed queries. | +| [`SubscriptionHandle` type](#type-subscriptionhandle) | Manage an active subscripion. | -A known issue in the SpacetimeDB Rust SDK causes inconsistent behaviors after re-subscribing. This will be fixed in an upcoming SpacetimeDB release. For now, Rust clients should issue only one subscription per `DbConnection`. +#### Type `SubscriptionBuilder` + +```rust +spacetimedb_sdk::SubscriptionBuilder +``` + +| Name | Description | +|----------------------------------------------------------------------------------|-----------------------------------------------------------------| +| [`ctx.subscription_builder()` constructor](#constructor-ctxsubscription_builder) | Begin configuring a new subscription. | +| [`on_applied` callback](#callback-on_applied) | Register a callback to run when matching rows become available. | +| [`on_error` callback](#callback-on_error) | Register a callback to run if the subscription fails. | +| [`subscribe` method](#method-subscribe) | Finish configuration and subscribe to one or more SQL queries. | +| [`subscribe_to_all_tables` method](#method-subscribe_to_all_tables) | Convenience method to subscribe to the entire database. | + +##### Constructor `ctx.subscription_builder()` ```rust trait DbContext { @@ -177,17 +331,28 @@ trait DbContext { Subscribe to queries by calling `ctx.subscription_builder()` and chaining configuration methods, then calling `.subscribe(queries)`. -#### Callback `on_applied` +##### Callback `on_applied` + +```rust +impl SubscriptionBuilder { + fn on_applied(self, callback: impl FnOnce(&SubscriptionEventContext)) -> Self; +} +``` + +Register a callback to run when the subscription is applied and the matching rows are inserted into the client cache. + +##### Callback `on_error` ```rust impl SubscriptionBuilder { - fn on_applied(self, callback: impl FnOnce(&EventContext)) -> Self; + fn on_error(self, callback: impl FnOnce(&ErrorContext, spacetimedb_sdk::Error)) -> Self; } ``` -Register a callback to run when the subscription is applied and the matching rows are inserted into the client cache. The [`EventContext`](#type-eventcontext) passed to the callback will have `Event::SubscribeApplied` as its `event`. +Register a callback to run if the subscription is rejected or unexpectedly terminated by the server. This is most frequently caused by passing an invalid query to [`subscribe`](#method-subscribe). -#### Method `subscribe` + +##### Method `subscribe` ```rust impl SubscriptionBuilder { @@ -195,11 +360,87 @@ impl SubscriptionBuilder { } ``` -Subscribe to a set of queries. `queries` should be an array or slice of strings. +Subscribe to a set of queries. `queries` should be a string or an array, vec or slice of strings. + +See [the SpacetimeDB SQL Reference](/docs/sql#subscriptions) for information on the queries SpacetimeDB supports as subscriptions. + +##### Method `subscribe_to_all_tables` + +```rust +impl SubscriptionBuilder { + fn subscribe_to_all_tables(self); +} +``` + +Subscribe to all rows from all public tables. This method is provided as a convenience for simple clients. The subscription initiated by `subscribe_to_all_tables` cannot be canceled after it is initiated. You should [`subscribe` to specific queries](#method-subscribe) if you need fine-grained control over the lifecycle of your subscriptions. + +#### Type `SubscriptionHandle` + +```rust +module_bindings::SubscriptionHandle +``` + +A `SubscriptionHandle` represents a subscribed query or a group of subscribed queries. + +The `SubscriptionHandle` does not contain or provide access to the subscribed rows. Subscribed rows of all subscriptions by a connection are contained within that connection's [`ctx.db`](#field-db). See [Access the client cache](#access-the-client-cache). + +| Name | Description | +|-------------------------------------------------------|------------------------------------------------------------------------------------------------------------------| +| [`is_ended` method](#method-is_ended) | Determine whether the subscription has ended. | +| [`is_active` method](#method-is_active) | Determine whether the subscription is active and its matching rows are present in the client cache. | +| [`unsubscribe` method](#method-unsubscribe) | Discard a subscription. | +| [`unsubscribe_then` method](#method-unsubscribe_then) | Discard a subscription, and register a callback to run when its matching rows are removed from the client cache. | + +##### Method `is_ended` + +```rust +impl SubscriptionHandle { + fn is_ended(&self) -> bool; +} +``` + +Returns true if this subscription has been terminated due to an unsubscribe call or an error. + +##### Method `is_active` + +```rust +impl SubscriptionHandle { + fn is_active(&self) -> bool; +} +``` + +Returns true if this subscription has been applied and has not yet been unsubscribed. + +##### Method `unsubscribe` + +```rust +impl SubscriptionHandle { + fn unsubscribe(&self) -> Result<(), spacetimedb_sdk::Error>; +} +``` + +Terminate this subscription, causing matching rows to be removed from the client cache. Any rows removed from the client cache this way will have [`on_delete` callbacks](#callback-on_delete) run for them. -The returned `SubscriptionHandle` is currently not useful, but will become significant in a future version of SpacetimeDB. +Unsubscribing is an asynchronous operation. Matching rows are not removed from the client cache immediately. Use [`unsubscribe_then`](#method-unsubscribe_then) to run a callback once the unsubscribe operation is completed. -### Identity a client +Returns an error if the subscription has already ended, either due to a previous call to `unsubscribe` or [`unsubscribe_then`](#method-unsubscribe_then), or due to an error. + +##### Method `unsubscribe_then` + +```rust +impl SubscriptionHandle { + fn unsubscribe_then( + self, + on_end: impl FnOnce(&SubscriptionEventContext), + ) -> Result<(), spacetimedb_sdk::Error>; +} +``` + +Terminate this subscription, and run the `on_end` callback when the subscription is ended and its matching rows are removed from the client cache. Any rows removed from the client cache this way will have [`on_delete` callbacks](#callback-on_delete) run for them. + +Returns an error if the subscription has already ended, either due to a previous call to [`unsubscribe`](#method-unsubscribe) or `unsubscribe_then`, or due to an error. + +### Read connection metadata #### Method `identity` @@ -221,6 +462,16 @@ trait DbContext { Like [`DbContext::identity`](#method-identity), but returns `None` instead of panicking if the `Identity` is not yet available. +#### Method `connection_id` + +```rust +trait DbContext { + fn connection_id(&self) -> ConnectionId; +} +``` + +Get the [`ConnectionId`](#type-connectionid) with which SpacetimeDB identifies the connection. + #### Method `is_active` ```rust @@ -237,7 +488,47 @@ trait DbContext { module_bindings::EventContext ``` -An `EventContext` is a [`DbContext`](#trait-dbcontext) augmented with a field `event: Event`. +An `EventContext` is a [`DbContext`](#trait-dbcontext) augmented with a field [`event: Event`](#enum-event). `EventContext`s are passed as the first argument to row callbacks [`on_insert`](#callback-on_insert), [`on_delete`](#callback-on_delete) and [`on_update`](#callback-on_update). + +| Name | Description | +|-------------------------------------|---------------------------------------------------------------| +| [`event` field](#field-event) | Enum describing the cause of the current row callback. | +| [`db` field](#field-db) | Provides access to the client cache. | +| [`reducers` field](#field-reducers) | Allows requesting reducers run on the remote database. | +| [`Event` enum](#enum-event) | Possible events which can cause a row callback to be invoked. | + +### Field `event` + +```rust +struct EventContext { + pub event: spacetimedb_sdk::Event, + /* other fields */ +} +``` + +The [`Event`](#enum-event) contained in the `EventContext` describes what happened to cause the current row callback to be invoked. + +### Field `db` + +```rust +struct EventContext { + pub db: RemoteTables, + /* other members */ +} +``` + +The `db` field of the context provides access to the subscribed view of the remote database's tables. See [Access the client cache](#access-the-client-cache). + +### Field `reducers` + +```rust +struct EventContext { + pub reducers: RemoteReducers, + /* other members */ +} +``` + +The `reducers` field of the context provides access to reducers exposed by the remote module. See [Observe and invoke reducers](#observe-and-invoke-reducers). ### Enum `Event` @@ -245,6 +536,17 @@ An `EventContext` is a [`DbContext`](#trait-dbcontext) augmented with a field `e spacetimedb_sdk::Event ``` +| Name | Description | +|-------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------| +| [`Reducer` variant](#variant-reducer) | A reducer ran in the remote database. | +| [`SubscribeApplied` variant](#variant-subscribeapplied) | A new subscription was applied to the client cache. | +| [`UnsubscribeApplied` variant](#variant-unsubscribeapplied) | A previous subscription was removed from the client cache after a call to [`unsubscribe`](#method-unsubscribe). | +| [`SubscribeError` variant](#variant-subscribeerror) | A previous subscription was removed from the client cache due to an error. | +| [`UnknownTransaction` variant](#variant-unknowntransaction) | A transaction ran in the remote database, but was not attributed to a known reducer. | +| [`ReducerEvent` struct](#struct-reducerevent) | Metadata about a reducer run. Contained in [`Event::Reducer`](#variant-reducer) and [`ReducerEventContext`](#type-reducereventcontext). | +| [`Status` enum](#enum-status) | Completion status of a reducer run. | +| [`Reducer` enum](#enum-reducer) | Module-specific generated enum with a variant for each reducer defined by the module. | + #### Variant `Reducer` ```rust @@ -253,7 +555,7 @@ spacetimedb_sdk::Event::Reducer(spacetimedb_sdk::ReducerEvent { /// The `Identity` of the SpacetimeDB actor which invoked the reducer. caller_identity: Identity, - /// The `Address` of the SpacetimeDB actor which invoked the reducer, - /// or `None` if the actor did not supply an address. - caller_address: Option
, + /// The `ConnectionId` of the SpacetimeDB actor which invoked the reducer, + /// or `None` for scheduled reducers. + caller_connection_id: Option, /// The amount of energy consumed by the reducer run, in eV. /// (Not literal eV, but our SpacetimeDB energy unit eV.) @@ -321,6 +635,12 @@ struct spacetimedb_sdk::ReducerEvent { spacetimedb_sdk::Status ``` +| Name | Description | +|-----------------------------------------------|-----------------------------------------------------| +| [`Committed` variant](#variant-committed) | The reducer ran successfully. | +| [`Failed` variant](#variant-failed) | The reducer errored. | +| [`OutOfEnergy` variant](#variant-outofenergy) | The reducer was aborted due to insufficient energy. | + #### Variant `Committed` ```rust @@ -349,12 +669,135 @@ module_bindings::Reducer The module bindings contains an enum `Reducer` with a variant for each reducer defined by the module. Each variant has a payload containing the arguments to the reducer. +## Type `ReducerEventContext` + +A `ReducerEventContext` is a [`DbContext`](#trait-dbcontext) augmented with a field [`event: ReducerEvent`](#struct-reducerevent). `ReducerEventContext`s are passed as the first argument to [reducer callbacks](#observe-and-invoke-reducers). + +| Name | Description | +|-------------------------------------|---------------------------------------------------------------------| +| [`event` field](#field-event) | [`ReducerEvent`](#struct-reducerevent) containing reducer metadata. | +| [`db` field](#field-db) | Provides access to the client cache. | +| [`reducers` field](#field-reducers) | Allows requesting reducers run on the remote database. | + +### Field `event` + +```rust +struct ReducerEventContext { + pub event: spacetimedb_sdk::ReducerEvent, + /* other fields */ +} +``` + +The [`ReducerEvent`](#struct-reducerevent) contained in the `ReducerEventContext` has metadata about the reducer which ran. + +### Field `db` + +```rust +struct ReducerEventContext { + pub db: RemoteTables, + /* other members */ +} +``` + +The `db` field of the context provides access to the subscribed view of the remote database's tables. See [Access the client cache](#access-the-client-cache). + +### Field `reducers` + +```rust +struct ReducerEventContext { + pub reducers: RemoteReducers, + /* other members */ +} +``` + +The `reducers` field of the context provides access to reducers exposed by the remote module. See [Observe and invoke reducers](#observe-and-invoke-reducers). + +## Type `SubscriptionEventContext` + +A `SubscriptionEventContext` is a [`DbContext`](#trait-dbcontext). Unlike the other context types, `SubscriptionEventContext` doesn't have an `event` field. `SubscriptionEventContext`s are passed to subscription [`on_applied`](#callback-on_applied) and [`unsubscribe_then`](#method-unsubscribe_then) callbacks. + +| Name | Description | +|-------------------------------------|------------------------------------------------------------| +| [`db` field](#field-db) | Provides access to the client cache. | +| [`reducers` field](#field-reducers) | Allows requesting reducers run on the remote database. | + +### Field `db` + +```rust +struct SubscriptionEventContext { + pub db: RemoteTables, + /* other members */ +} +``` + +The `db` field of the context provides access to the subscribed view of the remote database's tables. See [Access the client cache](#access-the-client-cache). + +### Field `reducers` + +```rust +struct SubscriptionEventContext { + pub reducers: RemoteReducers, + /* other members */ +} +``` + +The `reducers` field of the context provides access to reducers exposed by the remote module. See [Observe and invoke reducers](#observe-and-invoke-reducers). + +## Type `ErrorContext` + +An `ErrorContext` is a [`DbContext`](#trait-dbcontext) augmented with a field `event: spacetimedb_sdk::Error`. `ErrorContext`s are to connections' [`on_disconnect`](#callback-on_disconnect) and [`on_connect_error`](#callback-on_connect_error) callbacks, and to subscriptions' [`on_error`](#callback-on_error) callbacks. + +| Name | Description | +|-------------------------------------|--------------------------------------------------------| +| [`event` field](#field-event) | The error which caused the current error callback. | +| [`db` field](#field-db) | Provides access to the client cache. | +| [`reducers` field](#field-reducers) | Allows requesting reducers run on the remote database. | + + +### Field `event` + +```rust +struct ErrorContext { + pub event: spacetimedb_sdk::Error, + /* other fields */ +} +``` + +### Field `db` + +```rust +struct ErrorContext { + pub db: RemoteTables, + /* other members */ +} +``` + +The `db` field of the context provides access to the subscribed view of the remote database's tables. See [Access the client cache](#access-the-client-cache). + +### Field `reducers` + +```rust +struct ErrorContext { + pub reducers: RemoteReducers, + /* other members */ +} +``` + +The `reducers` field of the context provides access to reducers exposed by the remote module. See [Observe and invoke reducers](#observe-and-invoke-reducers). + ## Access the client cache -Both [`DbConnection`](#type-dbconnection) and [`EventContext`](#type-eventcontext) have fields `.db`, which in turn has methods for accessing tables in the client cache. The trait method `DbContext::db(&self)` can also be used in contexts with an `impl DbContext` rather than a concrete-typed `EventContext` or `DbConnection`. +All [`DbContext`](#trait-dbcontext) implementors, including [`DbConnection`](#type-dbconnection) and [`EventContext`](#type-eventcontext), have fields `.db`, which in turn has methods for accessing tables in the client cache. The trait method `DbContext::db(&self)` can also be used in contexts with an `impl DbContext` rather than a concrete-typed `EventContext` or `DbConnection`. Each table defined by a module has an accessor method, whose name is the table name converted to `snake_case`, on this `.db` field. The methods are defined via extension traits, which `rustc` or your IDE should help you identify and import where necessary. The table accessor methods return table handles, which implement [`Table`](#trait-table), may implement [`TableWithPrimaryKey`](#trait-tablewithprimarykey), and have methods for searching by unique index. +| Name | Description | +|-------------------------------------------------------------------|---------------------------------------------------------------------------------| +| [`Table` trait](#trait-table) | Provides access to subscribed rows of a specific table within the client cache. | +| [`TableWithPrimaryKey` trait](#trait-tablewithprimarykey) | Extension trait for tables which have a column designated as a primary key. | +| [Unique constraint index access](#unique-constraint-index-access) | Seek a subscribed row by the value in its unique or primary key column. | +| [BTree index access](#btree-index-access) | Not supported. | + ### Trait `Table` ```rust @@ -363,6 +806,14 @@ spacetimedb_sdk::Table Implemented by all table handles. +| Name | Description | +|-----------------------------------------------|------------------------------------------------------------------------------| +| [`Row` associated type](#associated-type-row) | The type of rows in the table. | +| [`count` method](#method-count) | The number of subscribed rows in the table. | +| [`iter` method](#method-iter) | Iterate over all subscribed rows in the table. | +| [`on_insert` callback](#callback-on_insert) | Register a callback to run whenever a row is inserted into the client cache. | +| [`on_delete` callback](#callback-on_delete) | Register a callback to run whenever a row is deleted from the client cache. | + #### Associated type `Row` ```rust @@ -431,7 +882,11 @@ spacetimedb_sdk::TableWithPrimaryKey Implemented for table handles whose tables have a primary key. -#### Callback `on_delete` +| Name | Description | +|---------------------------------------------|--------------------------------------------------------------------------------------| +| [`on_update` callback](#callback-on_update) | Register a callback to run whenever a subscribed row is replaced with a new version. | + +#### Callback `on_update` ```rust trait spacetimedb_sdk::TableWithPrimaryKey { @@ -451,17 +906,17 @@ For each unique constraint on a table, its table handle has a method whose name ### BTree index access -Not currently implemented in the Rust SDK. Coming soon! +The SpacetimeDB Rust client SDK does not support non-unique BTree indexes. ## Observe and invoke reducers -Both [`DbConnection`](#type-dbconnection) and [`EventContext`](#type-eventcontext) have fields `.reducers`, which in turn has methods for invoking reducers defined by the module and registering callbacks on it. The trait method `DbContext::reducers(&self)` can also be used in contexts with an `impl DbContext` rather than a concrete-typed `EventContext` or `DbConnection`. +All [`DbContext`](#trait-dbcontext) implementors, including [`DbConnection`](#type-dbconnection) and [`EventContext`](#type-eventcontext), have fields `.reducers`, which in turn has methods for invoking reducers defined by the module and registering callbacks on it. The trait method `DbContext::reducers(&self)` can also be used in contexts with an `impl DbContext` rather than a concrete-typed `EventContext` or `DbConnection`. Each reducer defined by the module has three methods on the `.reducers`: -- An invoke method, whose name is the reducer's name converted to snake case. This requests that the module run the reducer. -- A callback registation method, whose name is prefixed with `on_`. This registers a callback to run whenever we are notified that the reducer ran, including successfully committed runs and runs we requested which failed. This method returns a callback id, which can be passed to the callback remove method. -- A callback remove method, whose name is prefixed with `remove_`. This cancels a callback previously registered via the callback registration method. +- An invoke method, whose name is the reducer's name converted to snake case, like `set_name`. This requests that the module run the reducer. +- A callback registation method, whose name is prefixed with `on_`, like `on_set_name`. This registers a callback to run whenever we are notified that the reducer ran, including successfully committed runs and runs we requested which failed. This method returns a callback id, which can be passed to the callback remove method. +- A callback remove method, whose name is prefixed with `remove_on_`, like `remove_on_set_name`. This cancels a callback previously registered via the callback registration method. ## Identify a client @@ -473,10 +928,10 @@ spacetimedb_sdk::Identity A unique public identifier for a client connected to a database. -### Type `Address` +### Type `ConnectionId` ```rust -spacetimedb_sdk::Address +spacetimedb_sdk::ConnectionId ``` -An opaque identifier for a client connection to a database, intended to differentiate between connections from the same [`Identity`](#type-identity). This will be removed in a future SpacetimeDB version in favor of a connection or session ID. +An opaque identifier for a client connection to a database, intended to differentiate between connections from the same [`Identity`](#type-identity). diff --git a/docs/sdks/rust/quickstart.md b/docs/sdks/rust/quickstart.md index e7e3fd3e..888782e6 100644 --- a/docs/sdks/rust/quickstart.md +++ b/docs/sdks/rust/quickstart.md @@ -28,7 +28,7 @@ cargo new client Below the `[dependencies]` line in `client/Cargo.toml`, add: ```toml -spacetimedb-sdk = "0.12" +spacetimedb-sdk = "1.0" hex = "0.4" ``` @@ -59,7 +59,9 @@ spacetime generate --lang rust --out-dir client/src/module_bindings --project-pa Take a look inside `client/src/module_bindings`. The CLI should have generated a few files: ``` -module_bindings +module_bindings/ +├── identity_connected_reducer.rs +├── identity_disconnected_reducer.rs ├── message_table.rs ├── message_type.rs ├── mod.rs @@ -85,128 +87,174 @@ We'll need additional imports from `spacetimedb_sdk` for interacting with the da To `client/src/main.rs`, add: ```rust -use spacetimedb_sdk::{anyhow, DbContext, Event, Identity, Status, Table, TableWithPrimaryKey}; -use spacetimedb_sdk::credentials::File; +use spacetimedb_sdk::{credentials, DbContext, Error, Event, Identity, Status, Table, TableWithPrimaryKey}; ``` ## Define the main function Our `main` function will do the following: -1. Connect to the database. This will also start a new thread for handling network messages. -2. Handle user input from the command line. +1. Connect to the database. +2. Register a number of callbacks to run in response to various database events. +3. Subscribe to a set of SQL queries, whose results will be replicated and automatically updated in our client. +4. Spawn a background thread where our connection will process messages and invoke callbacks. +5. Enter a loop to handle user input from the command line. We'll see the implementation of these functions a bit later, but for now add to `client/src/main.rs`: ```rust fn main() { // Connect to the database - let conn = connect_to_db(); + let ctx = connect_to_db(); + + // Register callbacks to run in response to database events. + register_callbacks(&ctx); + + // Subscribe to SQL queries in order to construct a local partial replica of the database. + subscribe_to_tables(&ctx); + + // Spawn a thread, where the connection will process messages and invoke callbacks. + ctx.run_threaded(); + // Handle CLI input - user_input_loop(&conn); + user_input_loop(&ctx); } ``` +## Connect to the database -## Register callbacks +A connection to a SpacetimeDB database is represented by a `DbConnection`. We configure `DbConnection`s using the builder pattern, by calling `DbConnection::builder()`, chaining method calls to set various connection parameters and register callbacks, then we cap it off with a call to `.build()` to begin the connection. -We need to handle several sorts of events: +In our case, we'll supply the following options: -1. When we connect and receive our credentials, we'll save them to a file so that the next time we connect, we can re-authenticate as the same user. -2. When a new user joins, we'll print a message introducing them. -3. When a user is updated, we'll print their new name, or declare their new online status. -4. When we receive a new message, we'll print it. -5. When we're informed of the backlog of past messages, we'll sort them and print them in order. -6. If the server rejects our attempt to set our name, we'll print an error. -7. If the server rejects a message we send, we'll print an error. -8. When our connection ends, we'll print a note, then exit the process. +1. An `on_connect` callback, to run when the remote database acknowledges and accepts our connection. +2. An `on_connect_error` callback, to run if the remote database is unreachable or it rejects our connection. +3. An `on_disconnect` callback, to run when our connection ends. +4. A `with_token` call, to supply a token to authenticate with. +5. A `with_module_name` call, to specify the name or `Identity` of our database. Make sure to pass the same name here as you supplied to `spacetime publish`. +6. A `with_uri` call, to specify the URI of the SpacetimeDB host where our module is running. To `client/src/main.rs`, add: ```rust -/// Register all the callbacks our app will use to respond to database events. -fn register_callbacks(conn: &DbConnection) { - // When a new user joins, print a notification. - conn.db.user().on_insert(on_user_inserted); - - // When a user's status changes, print a notification. - conn.db.user().on_update(on_user_updated); - - // When a new message is received, print it. - conn.db.message().on_insert(on_message_inserted); +/// The URI of the SpacetimeDB instance hosting our chat module. +const HOST: &str = "http://localhost:3000"; - // When we receive the message backlog, print it in timestamp order. - conn.subscription_builder().on_applied(on_sub_applied); +/// The database name we chose when we published our module. +const DB_NAME: &str = "quickstart-chat"; - // When we fail to set our name, print a warning. - conn.reducers.on_set_name(on_name_set); - - // When we fail to send a message, print a warning. - conn.reducers.on_send_message(on_message_sent); +/// Load credentials from a file and connect to the database. +fn connect_to_db() -> DbConnection { + DbConnection::builder() + // Register our `on_connect` callback, which will save our auth token. + .on_connect(on_connected) + // Register our `on_connect_error` callback, which will print a message, then exit the process. + .on_connect_error(on_connect_error) + // Our `on_disconnect` callback, which will print a message, then exit the process. + .on_disconnect(on_disconnected) + // If the user has previously connected, we'll have saved a token in the `on_connect` callback. + // In that case, we'll load it and pass it to `with_token`, + // so we can re-authenticate as the same `Identity`. + .with_token(creds_store().load().expect("Error loading credentials")) + // Set the database name we chose when we called `spacetime publish`. + .with_module_name(DB_NAME) + // Set the URI of the SpacetimeDB host that's running our database. + .with_uri(HOST) + // Finalize configuration and connect! + .build() + .expect("Failed to connect") } ``` -## Save credentials +### Save credentials -Each user has a `Credentials`, which consists of two parts: +SpacetimeDB will accept any [OpenID Connect](https://openid.net/developers/how-connect-works/) compliant [JSON Web Token](https://jwt.io/) and use it to compute an `Identity` for the user. More complex applications will generally authenticate their user somehow, generate or retrieve a token, and attach it to their connection via `with_token`. In our case, though, we'll connect anonymously the first time, let SpacetimeDB generate a fresh `Identity` and corresponding JWT for us, and save that token locally to re-use the next time we connect. -- An `Identity`, a unique public identifier. We're using these to identify `User` rows. -- A `Token`, a private key which SpacetimeDB uses to authenticate the client. - -`Credentials` are generated by SpacetimeDB each time a new client connects, and sent to the client so they can be saved, in order to re-connect with the same identity. The Rust SDK provides a pair of functions in `File`, `save` and `load`, for saving and storing these credentials in a file. By default the `save` and `load` will look for credentials in the `$HOME/.spacetimedb_client_credentials/` directory, which should be unintrusive. If saving our credentials fails, we'll print a message to standard error, but otherwise continue normally; even though the user won't be able to reconnect with the same identity, they can still chat normally. +The Rust SDK provides a pair of functions in `File`, `save` and `load`, for saving and storing these credentials in a file. By default the `save` and `load` will look for credentials in the `$HOME/.spacetimedb_client_credentials/` directory, which should be unintrusive. If saving our credentials fails, we'll print a message to standard error, but otherwise continue; even though the user won't be able to reconnect with the same identity, they can still chat normally. To `client/src/main.rs`, add: ```rust +fn creds_store() -> credentials::File { + credentials::File::new("quickstart-chat") +} + /// Our `on_connect` callback: save our credentials to a file. -fn on_connected(conn: &DbConnection, ident: Identity, token: &str) { - let file = File::new(CREDS_NAME); - if let Err(e) = file.save(ident, token) { +fn on_connected(_ctx: &DbConnection, _identity: Identity, token: &str) { + if let Err(e) = creds_store().save(token) { eprintln!("Failed to save credentials: {:?}", e); } - - println!("Connected to SpacetimeDB."); - println!("Use /name to set your username, otherwise enter your message!"); - - // Subscribe to the data we care about - subscribe_to_tables(&conn); - // Register callbacks for reducers - register_callbacks(&conn); } ``` -You can see here that when we connect we're going to register our callbacks, which we defined above. +### Handle errors and disconnections -## Handle errors and disconnections - -We need to handle connection errors and disconnections by printing appropriate messages and exiting the program. +We need to handle connection errors and disconnections by printing appropriate messages and exiting the program. These callbacks take an `ErrorContext`, a `DbConnection` that's been augmented with information about the error that occured. To `client/src/main.rs`, add: ```rust /// Our `on_connect_error` callback: print the error, then exit the process. -fn on_connect_error(err: &anyhow::Error) { +fn on_connect_error(_ctx: &ErrorContext, err: Error) { eprintln!("Connection error: {:?}", err); + std::process::exit(1); } /// Our `on_disconnect` callback: print a note, then exit the process. -fn on_disconnected(_conn: &DbConnection, _err: Option<&anyhow::Error>) { - eprintln!("Disconnected!"); - std::process::exit(0) +fn on_disconnected(_ctx: &ErrorContext, err: Option) { + if let Some(err) = err { + eprintln!("Disconnected: {}", err); + std::process::exit(1); + } else { + println!("Disconnected."); + std::process::exit(0); + } +} +``` + +## Register callbacks + +We need to handle several sorts of events: + +1. When a new user joins, we'll print a message introducing them. +2. When a user is updated, we'll print their new name, or declare their new online status. +3. When we receive a new message, we'll print it. +4. If the server rejects our attempt to set our name, we'll print an error. +5. If the server rejects a message we send, we'll print an error. + +To `client/src/main.rs`, add: + +```rust +/// Register all the callbacks our app will use to respond to database events. +fn register_callbacks(ctx: &DbConnection) { + // When a new user joins, print a notification. + ctx.db.user().on_insert(on_user_inserted); + + // When a user's status changes, print a notification. + ctx.db.user().on_update(on_user_updated); + + // When a new message is received, print it. + ctx.db.message().on_insert(on_message_inserted); + + // When we fail to set our name, print a warning. + ctx.reducers.on_set_name(on_name_set); + + // When we fail to send a message, print a warning. + ctx.reducers.on_send_message(on_message_sent); } ``` -## Notify about new users +### Notify about new users For each table, we can register on-insert and on-delete callbacks to be run whenever a subscribed row is inserted or deleted. We register these callbacks using the `on_insert` and `on_delete`, which is automatically implemented for each table by `spacetime generate`. -These callbacks can fire in two contexts: +These callbacks can fire in several contexts, of which we care about two: - After a reducer runs, when the client's cache is updated about changes to subscribed rows. - After calling `subscribe`, when the client's cache is initialized with all existing matching rows. This second case means that, even though the module only ever inserts online users, the client's `conn.db.user().on_insert(..)` callbacks may be invoked with users who are offline. We'll only notify about online users. -`on_insert` and `on_delete` callbacks take two arguments: `&EventContext` and the row data (in the case of insert it's a new row and in the case of delete it's the row that was deleted). You can determine whether the insert/delete operation was caused by a reducer or subscription update by checking the type of `ctx.event`. If `ctx.event` is a `Event::Reducer` then the row was changed by a reducer call, otherwise it was modified by a subscription update. `Reducer` is an enum autogenerated by `spacetime generate` with a variant for each reducer defined by the module. For now, we can ignore this argument. +`on_insert` and `on_delete` callbacks take two arguments: an `&EventContext` and the modified row. Like the `ErrorContext` above, `EventContext` is a `DbConnection` that's been augmented with information about the event that caused the row to be modified. You can determine whether the insert/delete operation was caused by a reducer, a newly-applied subscription, or some other event by pattern-matching on `ctx.event`. Whenever we want to print a user, if they have set a name, we'll use that. If they haven't set a name, we'll instead print the first 8 bytes of their identity, encoded as hexadecimal. We'll define functions `user_name_or_identity` and `identity_leading_hex` to handle this. @@ -230,9 +278,9 @@ fn user_name_or_identity(user: &User) -> String { ### Notify about updated users -Because we declared a `#[primary_key]` column in our `User` table, we can also register on-update callbacks. These run whenever a row is replaced by a row with the same primary key, like our module's `ctx.db.user().identity().update(..) calls. We register these callbacks using the `on_update` method of the trait `TableWithPrimaryKey`, which is automatically implemented by `spacetime generate` for any table with a `#[primary_key]` column. +Because we declared a `#[primary_key]` column in our `User` table, we can also register on-update callbacks. These run whenever a row is replaced by a row with the same primary key, like our module's `ctx.db.user().identity().update(..)` calls. We register these callbacks using the `on_update` method of the trait `TableWithPrimaryKey`, which is automatically implemented by `spacetime generate` for any table with a `#[primary_key]` column. -`on_update` callbacks take three arguments: the old row, the new row, and an `Option<&ReducerEvent>`. +`on_update` callbacks take three arguments: the `&EventContext`, the old row, and the new row. In our module, users can be updated for three reasons: @@ -247,7 +295,7 @@ To `client/src/main.rs`, add: ```rust /// Our `User::on_update` callback: /// print a notification about name and status changes. -fn on_user_updated(old: &User, new: &User, _: Option<&ReducerEvent>) { +fn on_user_updated(_ctx: &EventContext, old: &User, new: &User) { if old.name != new.name { println!( "User {} renamed to {}.", @@ -264,7 +312,7 @@ fn on_user_updated(old: &User, new: &User, _: Option<&ReducerEvent>) { } ``` -## Print messages +### Print messages When we receive a new message, we'll print it to standard output, along with the name of the user who sent it. Keep in mind that we only want to do this for new messages, i.e. those inserted by a `send_message` reducer invocation. We have to handle the backlog we receive when our subscription is initialized separately, to ensure they're printed in the correct order. To that effect, our `on_message_inserted` callback will check if the ctx.event type is an `Event::Reducer`, and only print in that case. @@ -272,7 +320,7 @@ To find the `User` based on the message's `sender` identity, we'll use `ctx.db.u We'll print the user's name or identity in the same way as we did when notifying about `User` table events, but here we have to handle the case where we don't find a matching `User` row. This can happen when the module owner sends a message using the CLI's `spacetime call`. In this case, we'll print `unknown`. -We'll handle message-related events, such as receiving new messages or loading past messages. +Notice that our `print_message` function takes an `&impl RemoteDbContext` as an argument. This is a trait, defined in our `module_bindings` by `spacetime generate`, which is implemented by `DbConnection`, `EventContext`, `ErrorContext` and a few other similar types. (`RemoteDbContext` is actually a shorthand for `DbContext`, which applies to connections to *any* module, with its associated types locked to module-specific ones.) Later on, we're going to call `print_message` with a `ReducerEventContext`, so we need to be more generic than just accepting `EventContext`. To `client/src/main.rs`, add: @@ -284,40 +332,23 @@ fn on_message_inserted(ctx: &EventContext, message: &Message) { } } -fn print_message(ctx: &EventContext, message: &Message) { - let sender = ctx.db.user().identity().find(&message.sender.clone()) +fn print_message(ctx: &impl RemoteDbContext, message: &Message) { + let sender = ctx + .db() + .user() + .identity() + .find(&message.sender.clone()) .map(|u| user_name_or_identity(&u)) .unwrap_or_else(|| "unknown".to_string()); println!("{}: {}", sender, message.text); } ``` -### Print past messages in order - -Messages we receive live will come in order, but when we connect, we'll receive all the past messages at once. We can't just print these in the order we receive them; the logs would be all shuffled around, and would make no sense. Instead, when we receive the log of past messages, we'll sort them by their sent timestamps and print them in order. - - -We'll handle this in our function `print_messages_in_order`, which we registered as an `on_subscription_applied` callback. `print_messages_in_order` iterates over all the `Message`s we've received, sorts them, and then prints them. `Message::iter()` is defined on the trait `TableType`, and returns an iterator over all the messages in the client's cache. Rust iterators can't be sorted in-place, so we'll collect it to a `Vec`, then use the `sort_by_key` method to sort by timestamp. - -To `client/src/main.rs`, add: - -```rust -/// Our `on_subscription_applied` callback: -/// sort all past messages and print them in timestamp order. -fn on_sub_applied(ctx: &EventContext) { - let mut messages = ctx.db.message().iter().collect::>(); - messages.sort_by_key(|m| m.sent); - for message in messages { - print_message(ctx, &message); - } -} -``` - -## Handle reducer failures +### Handle reducer failures We can also register callbacks to run each time a reducer is invoked. We register these callbacks using the `on_reducer` method of the `Reducer` trait, which is automatically implemented for each reducer by `spacetime generate`. -Each reducer callback first takes an `&EventContext` which contains all of the information from the reducer call including the reducer arguments, the identity of the caller, and whether or not the reducer call suceeded. +Each reducer callback first takes a `&ReducerEventContext` which contains metadata about the reducer call, including the identity of the caller and whether or not the reducer call suceeded. These callbacks will be invoked in one of two cases: @@ -333,69 +364,74 @@ To `client/src/main.rs`, add: ```rust /// Our `on_set_name` callback: print a warning if the reducer failed. -fn on_name_set(ctx: &EventContext, name: &String) { - if let Event::Reducer(reducer) = &ctx.event { - if let Status::Failed(err) = reducer.status.clone() { - eprintln!("Failed to change name to {:?}: {}", name, err); - } +fn on_name_set(ctx: &ReducerEventContext, name: &String) { + if let Status::Failed(err) = &ctx.event.status { + eprintln!("Failed to change name to {:?}: {}", name, err); } } /// Our `on_send_message` callback: print a warning if the reducer failed. -fn on_message_sent(ctx: &EventContext, text: &String) { - if let Event::Reducer(reducer) = &ctx.event { - if let Status::Failed(err) = reducer.status.clone() { - eprintln!("Failed to send message {:?}: {}", text, err); - } +fn on_message_sent(ctx: &ReducerEventContext, text: &String) { + if let Status::Failed(err) = &ctx.event.status { + eprintln!("Failed to send message {:?}: {}", text, err); } } ``` -## Connect to the database +## Subscribe to queries + +SpacetimeDB is set up so that each client subscribes via SQL queries to some subset of the database, and is notified about changes only to that subset. For complex apps with large databases, judicious subscriptions can save each client significant network bandwidth, memory and computation. For example, in [BitCraft](https://bitcraftonline.com), each player's client subscribes only to the entities in the "chunk" of the world where that player currently resides, rather than the entire game world. Our app is much simpler than BitCraft, so we'll just subscribe to the whole database. + +When we specify our subscriptions, we can supply an `on_applied` callback. This will run when the subscription is applied and the matching rows become available in our client cache. We'll use this opportunity to print the message backlog in proper order. -Now that our callbacks are all set up, we can connect to the database. We'll store the URI of the SpacetimeDB instance and our module name in constants `SPACETIMEDB_URI` and `DB_NAME`. Replace `` with the name you chose when publishing your module during the module quickstart. +We'll also provide an `on_error` callback. This will run if the subscription fails, usually due to an invalid or malformed SQL queries. We can't handle this case, so we'll just print out the error and exit the process. To `client/src/main.rs`, add: ```rust -/// The URL of the SpacetimeDB instance hosting our chat module. -const SPACETIMEDB_URI: &str = "http://localhost:3000"; +/// Register subscriptions for all rows of both tables. +fn subscribe_to_tables(ctx: &DbConnection) { + ctx.subscription_builder() + .on_applied(on_sub_applied) + .on_error(on_sub_error) + .subscribe(["SELECT * FROM user", "SELECT * FROM message"]); +} +``` -/// The module name we chose when we published our module. -const DB_NAME: &str = ""; +### Print past messages in order -/// You should change this value to a unique name based on your application. -const CREDS_NAME: &str = "rust-sdk-quickstart"; +Messages we receive live will come in order, but when we connect, we'll receive all the past messages at once. We can't just print these in the order we receive them; the logs would be all shuffled around, and would make no sense. Instead, when we receive the log of past messages, we'll sort them by their sent timestamps and print them in order. -/// Load credentials from a file and connect to the database. -fn connect_to_db() -> DbConnection { - let credentials = File::new(CREDS_NAME); - let conn = DbConnection::builder() - .on_connect(on_connected) - .on_connect_error(on_connect_error) - .on_disconnect(on_disconnected) - .with_uri(SPACETIMEDB_URI) - .with_module_name(DB_NAME) - .with_credentials(credentials.load().unwrap()) - .build().expect("Failed to connect"); - conn.run_threaded(); - conn +We'll handle this in our function `print_messages_in_order`, which we registered as an `on_applied` callback. `print_messages_in_order` iterates over all the `Message`s we've received, sorts them, and then prints them. `ctx.db.message().iter()` is defined on the trait `Table`, and returns an iterator over all the messages in the client cache. Rust iterators can't be sorted in-place, so we'll collect it to a `Vec`, then use the `sort_by_key` method to sort by timestamp. + +To `client/src/main.rs`, add: + +```rust +/// Our `on_subscription_applied` callback: +/// sort all past messages and print them in timestamp order. +fn on_sub_applied(ctx: &SubscriptionEventContext) { + let mut messages = ctx.db.message().iter().collect::>(); + messages.sort_by_key(|m| m.sent); + for message in messages { + print_message(ctx, &message); + } + println!("Fully connected and all subscriptions applied."); + println!("Use /name to set your name, or type a message!"); } ``` -## Subscribe to queries +### Notify about failed subscriptions -SpacetimeDB is set up so that each client subscribes via SQL queries to some subset of the database, and is notified about changes only to that subset. For complex apps with large databases, judicious subscriptions can save each client significant network bandwidth, memory and computation compared. For example, in [BitCraft](https://bitcraftonline.com), each player's client subscribes only to the entities in the "chunk" of the world where that player currently resides, rather than the entire game world. Our app is much simpler than BitCraft, so we'll just subscribe to the whole database. +It's possible for SpacetimeDB to reject subscriptions. This happens most often because of a typo in the SQL queries, but can be due to use of SQL features that SpacetimeDB doesn't support. See [SQL Support: Subscriptions](/docs/sql#subscriptions) for more information about what subscription queries SpacetimeDB supports. -To `client/src/main.rs`, add: +In our case, we're pretty confident that our queries are valid, but if SpacetimeDB rejects them, we want to know about it. Our callback will print the error, then exit the process. ```rust -/// Register subscriptions for all rows of both tables. -fn subscribe_to_tables(conn: &DbConnection) { - conn.subscription_builder().subscribe([ - "SELECT * FROM user;", - "SELECT * FROM message;", - ]); +/// Or `on_error` callback: +/// print the error, then exit the process. +fn on_sub_error(_ctx: &ErrorContext, err: Error) { + eprintln!("Subscription failed: {}", err); + std::process::exit(1); } ``` @@ -403,21 +439,21 @@ fn subscribe_to_tables(conn: &DbConnection) { Our app should allow the user to interact by typing lines into their terminal. If the line starts with `/name `, we'll change the user's name. Any other line will send a message. -The functions `set_name` and `send_message` are generated from the server module via `spacetime generate`. We pass them a `String`, which gets sent to the server to execute the corresponding reducer. +For each reducer defined by our module, `ctx.reducers` has a method to request an invocation. In our case, we pass `set_name` and `send_message` a `String`, which gets sent to the server to execute the corresponding reducer. To `client/src/main.rs`, add: ```rust /// Read each line of standard input, and either set our name or send a message as appropriate. -fn user_input_loop(conn: &DbConnection) { +fn user_input_loop(ctx: &DbConnection) { for line in std::io::stdin().lines() { let Ok(line) = line else { panic!("Failed to read from stdin."); }; if let Some(name) = line.strip_prefix("/name ") { - conn.reducers.set_name(name.to_string()).unwrap(); + ctx.reducers.set_name(name.to_string()).unwrap(); } else { - conn.reducers.send_message(line).unwrap(); + ctx.reducers.send_message(line).unwrap(); } } } @@ -466,9 +502,9 @@ User connected. ## What's next? -You can find the full code for this client [in the Rust SDK's examples](https://github.com/clockworklabs/SpacetimeDB/tree/master/crates/sdk/examples/quickstart-chat). +You can find the full code for this client [in the Rust client SDK's examples](https://github.com/clockworklabs/SpacetimeDB/tree/master/crates/sdk/examples/quickstart-chat). -Check out the [Rust SDK Reference](/docs/sdks/rust) for a more comprehensive view of the SpacetimeDB Rust SDK. +Check out the [Rust client SDK Reference](/docs/sdks/rust) for a more comprehensive view of the SpacetimeDB Rust client SDK. Our basic terminal interface has some limitations. Incoming messages can appear while the user is typing, which is less than ideal. Additionally, the user's input gets mixed with the program's output, making messages the user sends appear twice. You might want to try improving the interface by using [Rustyline](https://crates.io/crates/rustyline), [Cursive](https://crates.io/crates/cursive), or even creating a full-fledged GUI. diff --git a/docs/sdks/typescript/index.md b/docs/sdks/typescript/index.md index 34d9edef..322443c9 100644 --- a/docs/sdks/typescript/index.md +++ b/docs/sdks/typescript/index.md @@ -2,9 +2,21 @@ The SpacetimeDB client SDK for TypeScript contains all the tools you need to build clients for SpacetimeDB modules using Typescript, either in the browser or with NodeJS. -> You need a database created before use the client, so make sure to follow the Rust or C# Module Quickstart guides if need one. - -## Install the SDK +| Name | Description | +|-------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------| +| [Project setup](#project-setup) | Configure a Rust crate to use the SpacetimeDB Rust client SDK. | +| [Generate module bindings](#generate-module-bindings) | Use the SpacetimeDB CLI to generate module-specific types and interfaces. | +| [`DbConnection` type](#type-dbconnection) | A connection to a remote database. | +| [`DbContext` interface](#interface-dbcontext) | Methods for interacting with the remote database. Implemented by [`DbConnection`](#type-dbconnection) and various event context types. | +| [`EventContext` type](#type-eventcontext) | [`DbContext`](#interface-dbcontext) available in [row callbacks](#callback-oninsert). | +| [`ReducerEventContext` type](#type-reducereventcontext) | [`DbContext`](#interface-dbcontext) available in [reducer callbacks](#observe-and-invoke-reducers). | +| [`SubscriptionEventContext` type](#type-subscriptioneventcontext) | [`DbContext`](#interface-dbcontext) available in [subscription-related callbacks](#subscribe-to-queries). | +| [`ErrorContext` type](#type-errorcontext) | [`DbContext`](#interface-dbcontext) available in error-related callbacks. | +| [Access the client cache](#access-the-client-cache) | Make local queries against subscribed rows, and register [row callbacks](#callback-oninsert) to run when subscribed rows change. | +| [Observe and invoke reducers](#observe-and-invoke-reducers) | Send requests to the database to run reducers, and register callbacks to run when notified of reducers. | +| [Identify a client](#identify-a-client) | Types for identifying users and client connections. | + +## Project setup First, create a new client project, and add the following to your `tsconfig.json` file: @@ -55,927 +67,818 @@ Each SpacetimeDB client depends on some bindings specific to your module. Create mkdir -p client/src/module_bindings spacetime generate --lang typescript \ --out-dir client/src/module_bindings \ - --project-path server -``` - -And now you will get the files for the `reducers` & `tables`: - -```bash -quickstart-chat -├── client -│ ├── node_modules -│ ├── public -│ └── src -| └── module_bindings -| ├── add_reducer.ts -| ├── person.ts -| └── say_hello_reducer.ts -└── server - └── src + --project-path PATH-TO-MODULE-DIRECTORY ``` Import the `module_bindings` in your client's _main_ file: ```typescript -import { SpacetimeDBClient, Identity } from '@clockworklabs/spacetimedb-sdk'; - -import Person from './module_bindings/person'; -import AddReducer from './module_bindings/add_reducer'; -import SayHelloReducer from './module_bindings/say_hello_reducer'; -console.log(Person, AddReducer, SayHelloReducer); +import * as moduleBindings from './module_bindings/index'; ``` -> There is a known issue where if you do not use every type in your file, it will not pull them into the published build. To fix this, we are using `console.log` to force them to get pulled in. +You may also need to import some definitions from the SDK library: -## API at a glance - -### Classes - -| Class | Description | -| ----------------------------------------------- | ---------------------------------------------------------------------------- | -| [`SpacetimeDBClient`](#class-spacetimedbclient) | The database client connection to a SpacetimeDB server. | -| [`Identity`](#class-identity) | The user's public identity. | -| [`Address`](#class-address) | An opaque identifier for differentiating connections by the same `Identity`. | -| [`{Table}`](#class-table) | `{Table}` is a placeholder for each of the generated tables. | -| [`{Reducer}`](#class-reducer) | `{Reducer}` is a placeholder for each of the generated reducers. | - -### Class `SpacetimeDBClient` +```typescript +import { + Identity, ConnectionId, Event, ReducerEvent +} from '@clockworklabs/spacetimedb-sdk'; +``` -The database client connection to a SpacetimeDB server. +## Type `DbConnection` -Defined in [spacetimedb-sdk.spacetimedb](https://github.com/clockworklabs/spacetimedb-typescript-sdk/blob/main/src/spacetimedb.ts): +```typescript +DbConnection +``` -| Constructors | Description | -| ----------------------------------------------------------------- | ------------------------------------------------------------------------ | -| [`SpacetimeDBClient.constructor`](#spacetimedbclient-constructor) | Creates a new `SpacetimeDBClient` database client. | -| Properties | -| [`SpacetimeDBClient.identity`](#spacetimedbclient-identity) | The user's public identity. | -| [`SpacetimeDBClient.live`](#spacetimedbclient-live) | Whether the client is connected. | -| [`SpacetimeDBClient.token`](#spacetimedbclient-token) | The user's private authentication token. | -| Methods | | -| [`SpacetimeDBClient.connect`](#spacetimedbclient-connect) | Connect to a SpacetimeDB module. | -| [`SpacetimeDBClient.disconnect`](#spacetimedbclient-disconnect) | Close the current connection. | -| [`SpacetimeDBClient.subscribe`](#spacetimedbclient-subscribe) | Subscribe to a set of queries. | -| Events | | -| [`SpacetimeDBClient.onConnect`](#spacetimedbclient-onconnect) | Register a callback to be invoked upon authentication with the database. | -| [`SpacetimeDBClient.onError`](#spacetimedbclient-onerror) | Register a callback to be invoked upon a error. | +A connection to a remote database is represented by the `DbConnection` type. This type is generated per-module, and contains information about the types, tables and reducers defined by your module. -## Constructors +| Name | Description | +|-----------------------------------------------------------|--------------------------------------------------------------------------------------------------| +| [Connect to a module](#connect-to-a-module) | Construct a `DbConnection`. | +| [Access tables and reducers](#access-tables-and-reducers) | Access subscribed rows in the client cache, request reducer invocations, and register callbacks. | -### `SpacetimeDBClient` constructor -Creates a new `SpacetimeDBClient` database client and set the initial parameters. +### Connect to a module -```ts -new SpacetimeDBClient(host: string, name_or_address: string, auth_token?: string, protocol?: "binary" | "json") +```typescript +class DbConnection { + public static builder(): DbConnectionBuilder +} ``` -#### Parameters +Construct a `DbConnection` by calling `DbConnection.builder()` and chaining configuration methods, then calling `.build()`. You must at least specify `withUri`, to supply the URI of the SpacetimeDB to which you published your module, and `withModuleName`, to supply the human-readable SpacetimeDB domain name or the raw `Identity` which identifies the module. -| Name | Type | Description | -| :---------------- | :--------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------ | -| `host` | `string` | The host of the SpacetimeDB server. | -| `name_or_address` | `string` | The name or address of the SpacetimeDB module. | -| `auth_token?` | `string` | The credentials to use to connect to authenticate with SpacetimeDB. | -| `protocol?` | `"binary"` \| `"json"` | Define how encode the messages: `"binary"` \| `"json"`. Binary is more efficient and compact, but JSON provides human-readable debug information. | +| Name | Description | +|-------------------------------------------------------|--------------------------------------------------------------------------------------| +| [`withUri` method](#method-withuri) | Set the URI of the SpacetimeDB instance which hosts the remote database. | +| [`withModuleName` method](#method-withmodulename) | Set the name or `Identity` of the remote database. | +| [`onConnect` callback](#callback-onconnect) | Register a callback to run when the connection is successfully established. | +| [`onConnectError` callback](#callback-onconnecterror) | Register a callback to run if the connection is rejected or the host is unreachable. | +| [`onDisconnect` callback](#callback-ondisconnect) | Register a callback to run when the connection ends. | +| [`withToken` method](#method-withtoken) | Supply a token to authenticate with the remote database. | +| [`build` method](#method-build) | Finalize configuration and connect. | -#### Example +#### Method `withUri` -```ts -const host = 'ws://localhost:3000'; -const name_or_address = 'database_name'; -const auth_token = undefined; -const protocol = 'binary'; - -var spacetimeDBClient = new SpacetimeDBClient( - host, - name_or_address, - auth_token, - protocol -); +```typescript +class DbConnectionBuilder { + public withUri(uri: string): DbConnectionBuilder +} ``` -## Class methods +Configure the URI of the SpacetimeDB instance or cluster which hosts the remote module. -### `SpacetimeDBClient.registerReducers` +#### Method `withModuleName` -Registers reducer classes for use with a SpacetimeDBClient +```typescript +class DbConnectionBuilder { + public withModuleName(name_or_identity: string): DbConnectionBuilder +} -```ts -registerReducers(...reducerClasses: ReducerClass[]) ``` -#### Parameters - -| Name | Type | Description | -| :--------------- | :------------- | :---------------------------- | -| `reducerClasses` | `ReducerClass` | A list of classes to register | +Configure the SpacetimeDB domain name or hex string encoded `Identity` of the remote module which identifies it within the SpacetimeDB instance or cluster. -#### Example +#### Callback `onConnect` -```ts -import SayHelloReducer from './types/say_hello_reducer'; -import AddReducer from './types/add_reducer'; - -SpacetimeDBClient.registerReducers(SayHelloReducer, AddReducer); +```typescript +class DbConnectionBuilder { + public onConnect( + callback: (ctx: DbConnection, identity: Identity, token: string) => void + ): DbConnectionBuilder +} ``` ---- +Chain a call to `.onConnect(callback)` to your builder to register a callback to run when your new `DbConnection` successfully initiates its connection to the remote module. The callback accepts three arguments: a reference to the `DbConnection`, the `Identity` by which SpacetimeDB identifies this connection, and a private access token which can be saved and later passed to [`withToken`](#method-withtoken) to authenticate the same user in future connections. -### `SpacetimeDBClient.registerTables` +#### Callback `onConnectError` -Registers table classes for use with a SpacetimeDBClient - -```ts -registerTables(...reducerClasses: TableClass[]) +```typescript +class DbConnectionBuilder { + public onConnectError( + callback: (ctx: ErrorContext, error: Error) => void + ): DbConnectionBuilder +} ``` -#### Parameters +Chain a call to `.onConnectError(callback)` to your builder to register a callback to run when your connection fails. -| Name | Type | Description | -| :------------- | :----------- | :---------------------------- | -| `tableClasses` | `TableClass` | A list of classes to register | +#### Callback `onDisconnect` -#### Example - -```ts -import User from './types/user'; -import Player from './types/player'; - -SpacetimeDBClient.registerTables(User, Player); +```typescript +class DbConnectionBuilder { + public onDisconnect( + callback: (ctx: ErrorContext, error: Error | null) => void + ): DbConnectionBuilder +} ``` ---- - -## Properties - -### `SpacetimeDBClient` identity +Chain a call to `.onDisconnect(callback)` to your builder to register a callback to run when your `DbConnection` disconnects from the remote module, either as a result of a call to [`disconnect`](#method-disconnect) or due to an error. -The user's public [Identity](#class-identity). +#### Method `withToken` -``` -identity: Identity | undefined +```typescript +class DbConnectionBuilder { + public withToken(token?: string): DbConnectionBuilder +} ``` ---- +Chain a call to `.withToken(token)` to your builder to provide an OpenID Connect compliant JSON Web Token to authenticate with, or to explicitly select an anonymous connection. If this method is not called or `null` is passed, SpacetimeDB will generate a new `Identity` and sign a new private access token for the connection. -### `SpacetimeDBClient` live -Whether the client is connected. +#### Method `build` -```ts -live: boolean; +```typescript +class DbConnectionBuilder { + public build(): DbConnection +} ``` ---- +After configuring the connection and registering callbacks, attempt to open the connection. -### `SpacetimeDBClient` token +### Access tables and reducers -The user's private authentication token. +#### Field `db` -``` -token: string | undefined +```typescript +class DbConnection { + public db: RemoteTables +} ``` -#### Parameters +The `db` field of the `DbConnection` provides access to the subscribed view of the remote database's tables. See [Access the client cache](#access-the-client-cache). -| Name | Type | Description | -| :------------ | :----------- | :------------------------------ | -| `reducerName` | `string` | The name of the reducer to call | -| `serializer` | `Serializer` | - | +#### Field `reducers` ---- +```typescript +class DbConnection { + public reducers: RemoteReducers +} +``` -### `SpacetimeDBClient` connect +The `reducers` field of the `DbConnection` provides access to reducers exposed by the remote module. See [Observe and invoke reducers](#observe-and-invoke-reducers). -Connect to The SpacetimeDB Websocket For Your Module. By default, this will use a secure websocket connection. The parameters are optional, and if not provided, will use the values provided on construction of the client. +## Interface `DbContext` -```ts -connect(host: string?, name_or_address: string?, auth_token: string?): Promise +```typescript +interface DbContext< + DbView, + Reducers, +> ``` -#### Parameters +[`DbConnection`](#type-dbconnection), [`EventContext`](#type-eventcontext), [`ReducerEventContext`](#type-reducereventcontext), [`SubscriptionEventContext`](#type-subscriptioneventcontext) and [`ErrorContext`](#type-errorcontext) all implement `DbContext`. `DbContext` has fields and methods for inspecting and configuring your connection to the remote database. -| Name | Type | Description | -| :----------------- | :------- | :------------------------------------------------------------------------------------------------------------------------------------------ | -| `host?` | `string` | The hostname of the SpacetimeDB server. Defaults to the value passed to the [constructor](#spacetimedbclient-constructor). | -| `name_or_address?` | `string` | The name or address of the SpacetimeDB module. Defaults to the value passed to the [constructor](#spacetimedbclient-constructor). | -| `auth_token?` | `string` | The credentials to use to authenticate with SpacetimeDB. Defaults to the value passed to the [constructor](#spacetimedbclient-constructor). | +The `DbContext` interface is implemented by connections and contexts to *every* module. This means that its [`DbView`](#field-db) and [`Reducers`](#field-reducers) are generic types. -#### Returns +| Name | Description | +|-------------------------------------------------------|--------------------------------------------------------------------------| +| [`db` field](#field-db) | Access subscribed rows of tables and register row callbacks. | +| [`reducers` field](#field-reducers) | Request reducer invocations and register reducer callbacks. | +| [`disconnect` method](#method-disconnect) | End the connection. | +| [Subscribe to queries](#subscribe-to-queries) | Register SQL queries to receive updates about matching rows. | +| [Read connection metadata](#read-connection-metadata) | Access the connection's `Identity` and `ConnectionId` | -`Promise`<`void`\> +#### Field `db` -#### Example - -```ts -const host = 'ws://localhost:3000'; -const name_or_address = 'database_name'; -const auth_token = undefined; - -var spacetimeDBClient = new SpacetimeDBClient( - host, - name_or_address, - auth_token -); -// Connect with the initial parameters -spacetimeDBClient.connect(); -//Set the `auth_token` -spacetimeDBClient.connect(undefined, undefined, NEW_TOKEN); +```typescript +interface DbContext { + db: DbView +} ``` ---- - -### `SpacetimeDBClient` disconnect +The `db` field of a `DbContext` provides access to the subscribed view of the remote database's tables. See [Access the client cache](#access-the-client-cache). -Close the current connection. +#### Field `reducers` -```ts -disconnect(): void +```typescript +interface DbContext { + reducers: Reducers +} ``` -#### Example +The `reducers` field of a `DbContext` provides access to reducers exposed by the remote module. See [Observe and invoke reducers](#observe-and-invoke-reducers). -```ts -var spacetimeDBClient = new SpacetimeDBClient( - 'ws://localhost:3000', - 'database_name' -); +### Method `disconnect` -spacetimeDBClient.disconnect(); +```typescript +interface DbContext { + disconnect(): void +} ``` ---- +Gracefully close the `DbConnection`. Throws an error if the connection is already disconnected. -### `SpacetimeDBClient` subscribe +### Subscribe to queries -Subscribe to a set of queries, to be notified when rows which match those queries are altered. +| Name | Description | +|---------------------------------------------------------|-------------------------------------------------------------| +| [`SubscriptionBuilder` type](#type-subscriptionbuilder) | Builder-pattern constructor to register subscribed queries. | +| [`SubscriptionHandle` type](#type-subscriptionhandle) | Manage an active subscripion. | -> A new call to `subscribe` will remove all previous subscriptions and replace them with the new `queries`. -> If any rows matched the previous subscribed queries but do not match the new queries, -> those rows will be removed from the client cache, and [`{Table}.on_delete`](#table-ondelete) callbacks will be invoked for them. +#### Type `SubscriptionBuilder` -```ts -subscribe(queryOrQueries: string | string[]): void +```typescript +SubscriptionBuilder ``` -#### Parameters - -| Name | Type | Description | -| :--------------- | :--------------------- | :------------------------------- | -| `queryOrQueries` | `string` \| `string`[] | A `SQL` query or list of queries | +| Name | Description | +|--------------------------------------------------------------------------------|-----------------------------------------------------------------| +| [`ctx.subscriptionBuilder()` constructor](#constructor-ctxsubscriptionbuilder) | Begin configuring a new subscription. | +| [`onApplied` callback](#callback-onapplied) | Register a callback to run when matching rows become available. | +| [`onError` callback](#callback-onerror) | Register a callback to run if the subscription fails. | +| [`subscribe` method](#method-subscribe) | Finish configuration and subscribe to one or more SQL queries. | +| [`subscribeToAllTables` method](#method-subscribetoalltables) | Convenience method to subscribe to the entire database. | -#### Example +##### Constructor `ctx.subscriptionBuilder()` -```ts -spacetimeDBClient.subscribe(['SELECT * FROM User', 'SELECT * FROM Message']); +```typescript +interface DbContext { + subscriptionBuilder(): SubscriptionBuilder +} ``` -## Events - -### `SpacetimeDBClient` onConnect +Subscribe to queries by calling `ctx.subscription_builder()` and chaining configuration methods, then calling `.subscribe(queries)`. -Register a callback to be invoked upon authentication with the database. +##### Callback `onApplied` -```ts -onConnect(callback: (token: string, identity: Identity) => void): void +```typescript +class SubscriptionBuilder { + public onApplied( + callback: (ctx: SubscriptionEventContext) => void + ): SubscriptionBuilder +} ``` -The callback will be invoked with the public user [Identity](#class-identity), private authentication token and connection [`Address`](#class-address) provided by the database. If credentials were supplied to [connect](#spacetimedbclient-connect), those passed to the callback will be equivalent to the ones used to connect. If the initial connection was anonymous, a new set of credentials will be generated by the database to identify this user. - -The credentials passed to the callback can be saved and used to authenticate the same user in future connections. - -#### Parameters +Register a callback to run when the subscription is applied and the matching rows are inserted into the client cache. -| Name | Type | -| :--------- | :--------------------------------------------------------------------------------------------------------------- | -| `callback` | (`token`: `string`, `identity`: [`Identity`](#class-identity), `address`: [`Address`](#class-address)) => `void` | +##### Callback `onError` -#### Example - -```ts -spacetimeDBClient.onConnect((token, identity, address) => { - console.log('Connected to SpacetimeDB'); - console.log('Token', token); - console.log('Identity', identity); - console.log('Address', address); -}); +```typescript +class SubscriptionBuilder { + public onError( + callback: (ctx: ErrorContext, error: Error) => void + ): SubscriptionBuilder +} ``` ---- +Register a callback to run if the subscription is rejected or unexpectedly terminated by the server. This is most frequently caused by passing an invalid query to [`subscribe`](#method-subscribe). -### `SpacetimeDBClient` onError -Register a callback to be invoked upon an error. +##### Method `subscribe` -```ts -onError(callback: (...args: any[]) => void): void +```typescript +class SubscriptionBuilder { + subscribe(queries: string | string[]): SubscriptionHandle +} ``` -#### Parameters +Subscribe to a set of queries. -| Name | Type | -| :--------- | :----------------------------- | -| `callback` | (...`args`: `any`[]) => `void` | +See [the SpacetimeDB SQL Reference](/docs/sql#subscriptions) for information on the queries SpacetimeDB supports as subscriptions. -#### Example +##### Method `subscribeToAllTables` -```ts -spacetimeDBClient.onError((...args: any[]) => { - console.error('ERROR', args); -}); +```typescript +class SubscriptionBuilder { + subscribeToAllTables(): void +} ``` -### Class `Identity` - -A unique public identifier for a user of a database. - -Defined in [spacetimedb-sdk.identity](https://github.com/clockworklabs/spacetimedb-typescript-sdk/blob/main/src/identity.ts): +Subscribe to all rows from all public tables. This method is provided as a convenience for simple clients. The subscription initiated by `subscribeToAllTables` cannot be canceled after it is initiated. You should [`subscribe` to specific queries](#method-subscribe) if you need fine-grained control over the lifecycle of your subscriptions. -| Constructors | Description | -| ----------------------------------------------- | -------------------------------------------- | -| [`Identity.constructor`](#identity-constructor) | Creates a new `Identity`. | -| Methods | | -| [`Identity.isEqual`](#identity-isequal) | Compare two identities for equality. | -| [`Identity.toHexString`](#identity-tohexstring) | Print the identity as a hexadecimal string. | -| Static methods | | -| [`Identity.fromString`](#identity-fromstring) | Parse an Identity from a hexadecimal string. | +#### Type `SubscriptionHandle` -## Constructors - -### `Identity` constructor - -```ts -new Identity(data: Uint8Array) +```typescript +SubscriptionHandle ``` -#### Parameters +A `SubscriptionHandle` represents a subscribed query or a group of subscribed queries. -| Name | Type | -| :----- | :----------- | -| `data` | `Uint8Array` | +The `SubscriptionHandle` does not contain or provide access to the subscribed rows. Subscribed rows of all subscriptions by a connection are contained within that connection's [`ctx.db`](#field-db). See [Access the client cache](#access-the-client-cache). -## Methods +| Name | Description | +|-----------------------------------------------------|------------------------------------------------------------------------------------------------------------------| +| [`isEnded` method](#method-isended) | Determine whether the subscription has ended. | +| [`isActive` method](#method-isactive) | Determine whether the subscription is active and its matching rows are present in the client cache. | +| [`unsubscribe` method](#method-unsubscribe) | Discard a subscription. | +| [`unsubscribeThen` method](#method-unsubscribethen) | Discard a subscription, and register a callback to run when its matching rows are removed from the client cache. | -### `Identity` isEqual +##### Method `isEnded` -Compare two identities for equality. - -```ts -isEqual(other: Identity): boolean +```typescript +class SubscriptionHandle { + public isEnded(): bool +} ``` -#### Parameters +Returns true if this subscription has been terminated due to an unsubscribe call or an error. -| Name | Type | -| :------ | :---------------------------- | -| `other` | [`Identity`](#class-identity) | +##### Method `isActive` -#### Returns - -`boolean` - ---- - -### `Identity` toHexString - -Print an `Identity` as a hexadecimal string. - -```ts -toHexString(): string +```typescript +class SubscriptionHandle { + public isActive(): bool +} ``` -#### Returns - -`string` - ---- +Returns true if this subscription has been applied and has not yet been unsubscribed. -### `Identity` fromString +##### Method `unsubscribe` -Static method; parse an Identity from a hexadecimal string. - -```ts -Identity.fromString(str: string): Identity +```typescript +class SubscriptionHandle { + public unsubscribe(): void +} ``` -#### Parameters +Terminate this subscription, causing matching rows to be removed from the client cache. Any rows removed from the client cache this way will have [`onDelete` callbacks](#callback-ondelete) run for them. -| Name | Type | -| :---- | :------- | -| `str` | `string` | +Unsubscribing is an asynchronous operation. Matching rows are not removed from the client cache immediately. Use [`unsubscribeThen`](#method-unsubscribethen) to run a callback once the unsubscribe operation is completed. -#### Returns +Throws an error if the subscription has already ended, either due to a previous call to `unsubscribe` or [`unsubscribeThen`](#method-unsubscribethen), or due to an error. -[`Identity`](#class-identity) +##### Method `unsubscribeThen` -### Class `Address` - -An opaque identifier for a client connection to a database, intended to differentiate between connections from the same [`Identity`](#class-identity). +```typescript +class SubscriptionHandle { + public unsubscribeThen( + on_end: (ctx: SubscriptionEventContext) => void + ): void +} +``` -Defined in [spacetimedb-sdk.address](https://github.com/clockworklabs/spacetimedb-typescript-sdk/blob/main/src/address.ts): +Terminate this subscription, and run the `onEnd` callback when the subscription is ended and its matching rows are removed from the client cache. Any rows removed from the client cache this way will have [`onDelete` callbacks](#callback-ondelete) run for them. -| Constructors | Description | -| --------------------------------------------- | ------------------------------------------- | -| [`Address.constructor`](#address-constructor) | Creates a new `Address`. | -| Methods | | -| [`Address.isEqual`](#address-isequal) | Compare two identities for equality. | -| [`Address.toHexString`](#address-tohexstring) | Print the address as a hexadecimal string. | -| Static methods | | -| [`Address.fromString`](#address-fromstring) | Parse an Address from a hexadecimal string. | +Returns an error if the subscription has already ended, either due to a previous call to [`unsubscribe`](#method-unsubscribe) or `unsubscribeThen`, or due to an error. -## Constructors +### Read connection metadata -### `Address` constructor +#### Field `isActive` -```ts -new Address(data: Uint8Array) +```typescript +interface DbContext { + isActive: bool +} ``` -#### Parameters +`true` if the connection has not yet disconnected. Note that a connection `isActive` when it is constructed, before its [`onConnect` callback](#callback-onconnect) is invoked. -| Name | Type | -| :----- | :----------- | -| `data` | `Uint8Array` | +## Type `EventContext` -## Methods - -### `Address` isEqual - -Compare two addresses for equality. - -```ts -isEqual(other: Address): boolean +```typescript +EventContext ``` -#### Parameters +An `EventContext` is a [`DbContext`](#interface-dbcontext) augmented with a field [`event: Event`](#type-event). `EventContext`s are passed as the first argument to row callbacks [`onInsert`](#callback-oninsert), [`onDelete`](#callback-ondelete) and [`onUpdate`](#callback-onupdate). -| Name | Type | -| :------ | :-------------------------- | -| `other` | [`Address`](#class-address) | +| Name | Description | +|-------------------------------------|---------------------------------------------------------------| +| [`event` field](#field-event) | Enum describing the cause of the current row callback. | +| [`db` field](#field-db) | Provides access to the client cache. | +| [`reducers` field](#field-reducers) | Allows requesting reducers run on the remote database. | +| [`Event` type](#type-event) | Possible events which can cause a row callback to be invoked. | -#### Returns +### Field `event` -`boolean` +```typescript +class EventContext { + public event: Event +} +/* other fields */ ---- +``` -### `Address` toHexString +The [`Event`](#type-event) contained in the `EventContext` describes what happened to cause the current row callback to be invoked. -Print an `Address` as a hexadecimal string. +### Field `db` -```ts -toHexString(): string +```typescript +class EventContext { + public db: RemoteTables +} ``` -#### Returns +The `db` field of the context provides access to the subscribed view of the remote database's tables. See [Access the client cache](#access-the-client-cache). -`string` +### Field `reducers` ---- +```typescript +class EventContext { + public reducers: RemoteReducers +} +``` -### `Address` fromString +The `reducers` field of the context provides access to reducers exposed by the remote module. See [Observe and invoke reducers](#observe-and-invoke-reducers). -Static method; parse an Address from a hexadecimal string. +### Type `Event` -```ts -Address.fromString(str: string): Address +```rust +type Event = + | { tag: 'Reducer'; value: ReducerEvent } + | { tag: 'SubscribeApplied' } + | { tag: 'UnsubscribeApplied' } + | { tag: 'Error'; value: Error } + | { tag: 'UnknownTransaction' }; ``` -#### Parameters +| Name | Description | +|-------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------| +| [`Reducer` variant](#variant-reducer) | A reducer ran in the remote database. | +| [`SubscribeApplied` variant](#variant-subscribeapplied) | A new subscription was applied to the client cache. | +| [`UnsubscribeApplied` variant](#variant-unsubscribeapplied) | A previous subscription was removed from the client cache after a call to [`unsubscribe`](#method-unsubscribe). | +| [`Error` variant](#variant-error) | A previous subscription was removed from the client cache due to an error. | +| [`UnknownTransaction` variant](#variant-unknowntransaction) | A transaction ran in the remote database, but was not attributed to a known reducer. | +| [`ReducerEvent` type](#type-reducerevent) | Metadata about a reducer run. Contained in [`Event::Reducer`](#variant-reducer) and [`ReducerEventContext`](#type-reducereventcontext). | +| [`UpdateStatus` type](#type-updatestatus) | Completion status of a reducer run. | +| [`Reducer` type](#type-reducer) | Module-specific generated enum with a variant for each reducer defined by the module. | -| Name | Type | -| :---- | :------- | -| `str` | `string` | +#### Variant `Reducer` -#### Returns +```typescript +{ tag: 'Reducer'; value: ReducerEvent } +``` -[`Address`](#class-address) +Event when we are notified that a reducer ran in the remote module. The [`ReducerEvent`](#type-reducerevent) contains metadata about the reducer run, including its arguments and termination status(#type-updatestatus). -### Class `{Table}` +This event is passed to row callbacks resulting from modifications by the reducer. -For each table defined by a module, `spacetime generate` generates a `class` in the `module_bindings` folder whose name is that table's name converted to `PascalCase`. +#### Variant `SubscribeApplied` -The generated class has a field for each of the table's columns, whose names are the column names converted to `snake_case`. +```typescript +{ tag: 'SubscribeApplied' } +``` -| Properties | Description | -| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -| [`Table.name`](#table-name) | The name of the class. | -| [`Table.tableName`](#table-tablename) | The name of the table in the database. | -| Methods | | -| [`Table.all`](#table-all) | Return all the subscribed rows in the table. | -| [`Table.filterBy{COLUMN}`](#table-filterbycolumn) | Autogenerated; return subscribed rows with a given value in a particular column. `{COLUMN}` is a placeholder for a column name. | -| [`Table.findBy{COLUMN}`](#table-findbycolumn) | Autogenerated; return a subscribed row with a given value in a particular unique column. `{COLUMN}` is a placeholder for a column name. | -| Events | | -| [`Table.onInsert`](#table-oninsert) | Register an `onInsert` callback for when a subscribed row is newly inserted into the database. | -| [`Table.removeOnInsert`](#table-removeoninsert) | Unregister a previously-registered [`onInsert`](#table-oninsert) callback. | -| [`Table.onUpdate`](#table-onupdate) | Register an `onUpdate` callback for when an existing row is modified. | -| [`Table.removeOnUpdate`](#table-removeonupdate) | Unregister a previously-registered [`onUpdate`](#table-onupdate) callback. | -| [`Table.onDelete`](#table-ondelete) | Register an `onDelete` callback for when a subscribed row is removed from the database. | -| [`Table.removeOnDelete`](#table-removeondelete) | Unregister a previously-registered [`onDelete`](#table-removeondelete) callback. | +Event when our subscription is applied and its rows are inserted into the client cache. -## Properties +This event is passed to [row `onInsert` callbacks](#callback-oninsert) resulting from the new subscription. -### {Table} name +#### Variant `UnsubscribeApplied` -• **name**: `string` +```typescript +{ tag: 'UnsubscribeApplied' } +``` -The name of the `Class`. +Event when our subscription is removed after a call to [`SubscriptionHandle.unsubscribe`](#method-unsubscribe) or [`SubscriptionHandle.unsubscribeThen`](#method-unsubscribethen) and its matching rows are deleted from the client cache. ---- +This event is passed to [row `onDelete` callbacks](#callback-ondelete) resulting from the subscription ending. -### {Table} tableName +#### Variant `Error` -The name of the table in the database. +```typescript +{ tag: 'Error'; value: Error } -▪ `Static` **tableName**: `string` = `"Person"` +``` -## Methods +Event when a subscription ends unexpectedly due to an error. -### {Table} all +This event is passed to [row `onDelete` callbacks](#callback-ondelete) resulting from the subscription ending. -Return all the subscribed rows in the table. +#### Variant `UnknownTransaction` -```ts -{Table}.all(): {Table}[] +```typescript +{ tag: 'UnknownTransaction' } ``` -#### Returns +Event when we are notified of a transaction in the remote module which we cannot associate with a known reducer. This may be an ad-hoc SQL query or a reducer for which we do not have bindings. -`{Table}[]` +This event is passed to [row callbacks](#callback-oninsert) resulting from modifications by the transaction. -#### Example +### Type `ReducerEvent` -```ts -var spacetimeDBClient = new SpacetimeDBClient( - 'ws://localhost:3000', - 'database_name' -); +A `ReducerEvent` contains metadata about a reducer run. -spacetimeDBClient.onConnect((token, identity, address) => { - spacetimeDBClient.subscribe(['SELECT * FROM Person']); +```typescript +type ReducerEvent = { + /** + * The time when the reducer started running. + */ + timestamp: Timestamp; + + /** + * Whether the reducer committed, was aborted due to insufficient energy, or failed with an error message. + */ + status: UpdateStatus; + + /** + * The identity of the caller. + * TODO: Revise these to reflect the forthcoming Identity proposal. + */ + callerIdentity: Identity; + + /** + * The connection ID of the caller. + * + * May be `null`, e.g. for scheduled reducers. + */ + callerConnectionId?: ConnectionId; + + /** + * The amount of energy consumed by the reducer run, in eV. + * (Not literal eV, but our SpacetimeDB energy unit eV.) + * May be present or undefined at the implementor's discretion; + * future work may determine an interface for module developers + * to request this value be published or hidden. + */ + energyConsumed?: bigint; + + /** + * The `Reducer` enum defined by the `moduleBindings`, which encodes which reducer ran and its arguments. + */ + reducer: Reducer; +}; +``` + +### Type `UpdateStatus` - setTimeout(() => { - console.log(Person.all()); // Prints all the `Person` rows in the database. - }, 5000); -}); +```typescript +type UpdateStatus = + | { tag: 'Committed'; value: __DatabaseUpdate } + | { tag: 'Failed'; value: string } + | { tag: 'OutOfEnergy' }; ``` ---- - -### {Table} count +| Name | Description | +|-----------------------------------------------|-----------------------------------------------------| +| [`Committed` variant](#variant-committed) | The reducer ran successfully. | +| [`Failed` variant](#variant-failed) | The reducer errored. | +| [`OutOfEnergy` variant](#variant-outofenergy) | The reducer was aborted due to insufficient energy. | -Return the number of subscribed rows in the table, or 0 if there is no active connection. +#### Variant `Committed` -```ts -{Table}.count(): number +```typescript +{ tag: 'Committed' } ``` -#### Returns +The reducer returned successfully and its changes were committed into the database state. An [`Event` with `tag: 'Reducer'`](#variant-reducer) passed to a row callback must have this status in its [`ReducerEvent`](#type-reducerevent). -`number` +#### Variant `Failed` -#### Example +```typescript +{ tag: 'Failed'; value: string } +``` -```ts -var spacetimeDBClient = new SpacetimeDBClient( - 'ws://localhost:3000', - 'database_name' -); +The reducer returned an error, panicked, or threw an exception. The `value` is the stringified error message. Formatting of the error message is unstable and subject to change, so clients should use it only as a human-readable diagnostic, and in particular should not attempt to parse the message. -spacetimeDBClient.onConnect((token, identity, address) => { - spacetimeDBClient.subscribe(['SELECT * FROM Person']); +#### Variant `OutOfEnergy` - setTimeout(() => { - console.log(Person.count()); - }, 5000); -}); +```typescript +{ tag: 'OutOfEnergy' } ``` ---- - -### {Table} filterBy{COLUMN} - -For each column of a table, `spacetime generate` generates a static method on the `Class` to filter subscribed rows where that column matches a requested value. +The reducer was aborted due to insufficient energy balance of the module owner. -These methods are named `filterBy{COLUMN}`, where `{COLUMN}` is the column name converted to `camelCase`. +### Type `Reducer` -```ts -{Table}.filterBy{COLUMN}(value): Iterable<{Table}> +```rust +type Reducer = + | { name: 'ReducerA'; args: ReducerA } + | { name: 'ReducerB'; args: ReducerB } ``` -#### Parameters +The module bindings contains a type `Reducer` with a variant for each reducer defined by the module. Each variant has a field `args` containing the arguments to the reducer. -| Name | Type | -| :------ | :-------------------------- | -| `value` | The type of the `{COLUMN}`. | +## Type `ReducerEventContext` -#### Returns +A `ReducerEventContext` is a [`DbContext`](#interface-dbcontext) augmented with a field [`event: ReducerEvent`](#type-reducerevent). `ReducerEventContext`s are passed as the first argument to [reducer callbacks](#observe-and-invoke-reducers). -`Iterable<{Table}>` +| Name | Description | +|-------------------------------------|-------------------------------------------------------------------| +| [`event` field](#field-event) | [`ReducerEvent`](#type-reducerevent) containing reducer metadata. | +| [`db` field](#field-db) | Provides access to the client cache. | +| [`reducers` field](#field-reducers) | Allows requesting reducers run on the remote database. | -#### Example +### Field `event` -```ts -var spacetimeDBClient = new SpacetimeDBClient( - 'ws://localhost:3000', - 'database_name' -); - -spacetimeDBClient.onConnect((token, identity, address) => { - spacetimeDBClient.subscribe(['SELECT * FROM Person']); - - setTimeout(() => { - console.log(...Person.filterByName('John')); // prints all the `Person` rows named John. - }, 5000); -}); +```typescript +class ReducerEventContext { + public event: ReducerEvent +} ``` ---- - -### {Table} findBy{COLUMN} - -For each unique column of a table, `spacetime generate` generates a static method on the `Class` to find the subscribed row where that column matches a requested value. +The [`ReducerEvent`](#type-reducerevent) contained in the `ReducerEventContext` has metadata about the reducer which ran. -These methods are named `findBy{COLUMN}`, where `{COLUMN}` is the column name converted to `camelCase`. +### Field `db` -```ts -{Table}.findBy{COLUMN}(value): {Table} | undefined +```typescript +class ReducerEventContext { + public db: RemoteTables +} ``` -#### Parameters +The `db` field of the context provides access to the subscribed view of the remote database's tables. See [Access the client cache](#access-the-client-cache). -| Name | Type | -| :------ | :-------------------------- | -| `value` | The type of the `{COLUMN}`. | +### Field `reducers` -#### Returns +```typescript +class ReducerEventContext { + public reducers: RemoteReducers +} +``` -`{Table} | undefined` +The `reducers` field of the context provides access to reducers exposed by the remote module. See [Observe and invoke reducers](#observe-and-invoke-reducers). -#### Example +## Type `SubscriptionEventContext` -```ts -var spacetimeDBClient = new SpacetimeDBClient( - 'ws://localhost:3000', - 'database_name' -); +A `SubscriptionEventContext` is a [`DbContext`](#interface-dbcontext). Unlike the other context types, `SubscriptionEventContext` doesn't have an `event` field. `SubscriptionEventContext`s are passed to subscription [`onApplied`](#callback-onapplied) and [`unsubscribeThen`](#method-unsubscribethen) callbacks. -spacetimeDBClient.onConnect((token, identity, address) => { - spacetimeDBClient.subscribe(['SELECT * FROM Person']); +| Name | Description | +|-------------------------------------|------------------------------------------------------------| +| [`db` field](#field-db) | Provides access to the client cache. | +| [`reducers` field](#field-reducers) | Allows requesting reducers run on the remote database. | - setTimeout(() => { - console.log(Person.findById(0)); // prints a `Person` row with id 0. - }, 5000); -}); -``` +### Field `db` ---- +```typescript +class SubscriptionEventContext { + public db: RemoteTables +} +``` -### {Table} fromValue +The `db` field of the context provides access to the subscribed view of the remote database's tables. See [Access the client cache](#access-the-client-cache). -Deserialize an `AlgebraicType` into this `{Table}`. +### Field `reducers` -```ts - {Table}.fromValue(value: AlgebraicValue): {Table} +```typescript +class SubscriptionEventContext { + public reducers: RemoteReducers +} ``` -#### Parameters - -| Name | Type | -| :------ | :--------------- | -| `value` | `AlgebraicValue` | +The `reducers` field of the context provides access to reducers exposed by the remote module. See [Observe and invoke reducers](#observe-and-invoke-reducers). -#### Returns +## Type `ErrorContext` -`{Table}` +An `ErrorContext` is a [`DbContext`](#interface-dbcontext) augmented with a field `event: Error`. `ErrorContext`s are to connections' [`onDisconnect`](#callback-ondisconnect) and [`onConnectError`](#callback-onconnecterror) callbacks, and to subscriptions' [`onError`](#callback-onerror) callbacks. ---- +| Name | Description | +|-------------------------------------|--------------------------------------------------------| +| [`event` field](#field-event) | The error which caused the current error callback. | +| [`db` field](#field-db) | Provides access to the client cache. | +| [`reducers` field](#field-reducers) | Allows requesting reducers run on the remote database. | -### {Table} getAlgebraicType -Serialize `this` into an `AlgebraicType`. +### Field `event` -#### Example - -```ts -{Table}.getAlgebraicType(): AlgebraicType +```typescript +class ErrorContext { + public event: Error +} ``` -#### Returns - -`AlgebraicType` - ---- - -### {Table} onInsert +### Field `db` -Register an `onInsert` callback for when a subscribed row is newly inserted into the database. - -```ts -{Table}.onInsert(callback: (value: {Table}, reducerEvent: ReducerEvent | undefined) => void): void +```typescript +class ErrorContext { + public db: RemoteTables +} ``` -#### Parameters +The `db` field of the context provides access to the subscribed view of the remote database's tables. See [Access the client cache](#access-the-client-cache). -| Name | Type | Description | -| :--------- | :---------------------------------------------------------------------------- | :----------------------------------------------------- | -| `callback` | (`value`: `{Table}`, `reducerEvent`: `undefined` \| `ReducerEvent`) => `void` | Callback to run whenever a subscribed row is inserted. | +### Field `reducers` -#### Example - -```ts -var spacetimeDBClient = new SpacetimeDBClient( - 'ws://localhost:3000', - 'database_name' -); -spacetimeDBClient.onConnect((token, identity, address) => { - spacetimeDBClient.subscribe(['SELECT * FROM Person']); -}); - -Person.onInsert((person, reducerEvent) => { - if (reducerEvent) { - console.log('New person inserted by reducer', reducerEvent, person); - } else { - console.log('New person received during subscription update', person); - } -}); +```typescript +class ErrorContext { + public reducers: RemoteReducers +} ``` ---- - -### {Table} removeOnInsert +The `reducers` field of the context provides access to reducers exposed by the remote module. See [Observe and invoke reducers](#observe-and-invoke-reducers). -Unregister a previously-registered [`onInsert`](#table-oninsert) callback. +## Access the client cache -```ts -{Table}.removeOnInsert(callback: (value: Person, reducerEvent: ReducerEvent | undefined) => void): void -``` - -#### Parameters +All [`DbContext`](#interface-dbcontext) implementors, including [`DbConnection`](#type-dbconnection) and [`EventContext`](#type-eventcontext), have fields `.db`, which in turn has methods for accessing tables in the client cache. -| Name | Type | -| :--------- | :---------------------------------------------------------------------------- | -| `callback` | (`value`: `{Table}`, `reducerEvent`: `undefined` \| `ReducerEvent`) => `void` | +Each table defined by a module has an accessor method, whose name is the table name converted to `camelCase`, on this `.db` field. The table accessor methods return table handles. Table handles have methods for [accessing rows](#accessing-rows) and [registering `onInsert`](#callback-oninsert) and [`onDelete` callbacks](#callback-ondelete). Handles for tables which have a declared primary key field also expose [`onUpdate` callbacks](#callback-onupdate). Table handles also offer the ability to find subscribed rows by unique index. ---- +| Name | Description | +|--------------------------------------------------------|---------------------------------------------------------------------------------| +| [Accessing rows](#accessing-rows) | Iterate over or count subscribed rows. | +| [`onInsert` callback](#callback-oninsert) | Register a function to run when a row is added to the client cache. | +| [`onDelete` callback](#callback-ondelete) | Register a function to run when a row is removed from the client cache. | +| [`onUpdate` callback](#callback-onupdate) | Register a functioNto run when a subscribed row is replaced with a new version. | +| [Unique index access](#unique-constraint-index-access) | Seek a subscribed row by the value in its unique or primary key column. | +| [BTree index access](#btree-index-access) | Not supported. | -### {Table} onUpdate +### Accessing rows -Register an `onUpdate` callback to run when an existing row is modified by primary key. +#### Method `count` -```ts -{Table}.onUpdate(callback: (oldValue: {Table}, newValue: {Table}, reducerEvent: ReducerEvent | undefined) => void): void +```typescript +class TableHandle { + public count(): number +} ``` -`onUpdate` callbacks are only meaningful for tables with a column declared as a primary key. Tables without primary keys will never fire `onUpdate` callbacks. - -#### Parameters - -| Name | Type | Description | -| :--------- | :------------------------------------------------------------------------------------------------------ | :---------------------------------------------------- | -| `callback` | (`oldValue`: `{Table}`, `newValue`: `{Table}`, `reducerEvent`: `undefined` \| `ReducerEvent`) => `void` | Callback to run whenever a subscribed row is updated. | +Returns the number of rows of this table resident in the client cache, i.e. the total number which match any subscribed query. -#### Example +#### Method `iter` -```ts -var spacetimeDBClient = new SpacetimeDBClient( - 'ws://localhost:3000', - 'database_name' -); -spacetimeDBClient.onConnect((token, identity, address) => { - spacetimeDBClient.subscribe(['SELECT * FROM Person']); -}); - -Person.onUpdate((oldPerson, newPerson, reducerEvent) => { - console.log('Person updated by reducer', reducerEvent, oldPerson, newPerson); -}); +```typescript +class TableHandle { + public iter(): Iterable +} ``` ---- +An iterator over all the subscribed rows in the client cache, i.e. those which match any subscribed query. -### {Table} removeOnUpdate +The `Row` type will be an autogenerated type which matches the row type defined by the module. -Unregister a previously-registered [`onUpdate`](#table-onupdate) callback. +### Callback `onInsert` -```ts -{Table}.removeOnUpdate(callback: (oldValue: {Table}, newValue: {Table}, reducerEvent: ReducerEvent | undefined) => void): void +```typescript +class TableHandle { + public onInsert( + callback: (ctx: EventContext, row: Row) => void + ): void; + + public removeOnInsert( + callback: (ctx: EventContext, row: Row) => void + ): void; +} ``` -#### Parameters - -| Name | Type | -| :--------- | :------------------------------------------------------------------------------------------------------ | -| `callback` | (`oldValue`: `{Table}`, `newValue`: `{Table}`, `reducerEvent`: `undefined` \| `ReducerEvent`) => `void` | +The `onInsert` callback runs whenever a new row is inserted into the client cache, either when applying a subscription or being notified of a transaction. The passed [`EventContext`](#type-eventcontext) contains an [`Event`](#type-event) which can identify the change which caused the insertion, and also allows the callback to interact with the connection, inspect the client cache and invoke reducers. ---- +The `Row` type will be an autogenerated type which matches the row type defined by the module. -### {Table} onDelete +`removeOnInsert` may be used to un-register a previously-registered `onInsert` callback. -Register an `onDelete` callback for when a subscribed row is removed from the database. +### Callback `onDelete` -```ts -{Table}.onDelete(callback: (value: {Table}, reducerEvent: ReducerEvent | undefined) => void): void +```typescript +class TableHandle { + public onDelete( + callback: (ctx: EventContext, row: Row) => void + ): void; + + public removeOnDelete( + callback: (ctx: EventContext, row: Row) => void + ): void; +} ``` -#### Parameters +The `onDelete` callback runs whenever a previously-resident row is deleted from the client cache. -| Name | Type | Description | -| :--------- | :---------------------------------------------------------------------------- | :---------------------------------------------------- | -| `callback` | (`value`: `{Table}`, `reducerEvent`: `undefined` \| `ReducerEvent`) => `void` | Callback to run whenever a subscribed row is removed. | +The `Row` type will be an autogenerated type which matches the row type defined by the module. -#### Example +`removeOnDelete` may be used to un-register a previously-registered `onDelete` callback. -```ts -var spacetimeDBClient = new SpacetimeDBClient( - 'ws://localhost:3000', - 'database_name' -); -spacetimeDBClient.onConnect((token, identity, address) => { - spacetimeDBClient.subscribe(['SELECT * FROM Person']); -}); +### Callback `onUpdate` -Person.onDelete((person, reducerEvent) => { - if (reducerEvent) { - console.log('Person deleted by reducer', reducerEvent, person); - } else { - console.log( - 'Person no longer subscribed during subscription update', - person - ); - } -}); +```typescript +class TableHandle { + public onUpdate( + callback: (ctx: EventContext, old: Row, new: Row) => void + ): void; + + public removeOnUpdate( + callback: (ctx: EventContext, old: Row, new: Row) => void + ): void; +} ``` ---- - -### {Table} removeOnDelete +The `onUpdate` callback runs whenever an already-resident row in the client cache is updated, i.e. replaced with a new row that has the same primary key. -Unregister a previously-registered [`onDelete`](#table-ondelete) callback. +Only tables with a declared primary key expose `onUpdate` callbacks. Handles for tables without a declared primary key will not have `onUpdate` or `removeOnUpdate` methods. -```ts -{Table}.removeOnDelete(callback: (value: {Table}, reducerEvent: ReducerEvent | undefined) => void): void -``` +The `Row` type will be an autogenerated type which matches the row type defined by the module. -#### Parameters +`removeOnUpdate` may be used to un-register a previously-registered `onUpdate` callback. -| Name | Type | -| :--------- | :---------------------------------------------------------------------------- | -| `callback` | (`value`: `{Table}`, `reducerEvent`: `undefined` \| `ReducerEvent`) => `void` | +### Unique constraint index access -### Class `{Reducer}` +For each unique constraint on a table, its table handle has a field whose name is the unique column name. This field is a unique index handle. The unique index handle has a method `.find(desiredValue: Col) -> Row | undefined`, where `Col` is the type of the column, and `Row` the type of rows. If a row with `desiredValue` in the unique column is resident in the client cache, `.find` returns it. -`spacetime generate` defines an `{Reducer}` class in the `module_bindings` folder for each reducer defined by a module. +### BTree index access -The class's name will be the reducer's name converted to `PascalCase`. +The SpacetimeDB TypeScript client SDK does not support non-unique BTree indexes. -| Static methods | Description | -| ------------------------------- | ------------------------------------------------------------ | -| [`Reducer.call`](#reducer-call) | Executes the reducer. | -| Events | | -| [`Reducer.on`](#reducer-on) | Register a callback to run each time the reducer is invoked. | +## Observe and invoke reducers -## Static methods +All [`DbContext`](#interface-dbcontext) implementors, including [`DbConnection`](#type-dbconnection) and [`EventContext`](#type-eventcontext), have fields `.reducers`, which in turn has methods for invoking reducers defined by the module and registering callbacks on it. -### {Reducer} call +Each reducer defined by the module has three methods on the `.reducers`: -Executes the reducer. +- An invoke method, whose name is the reducer's name converted to camel case, like `setName`. This requests that the module run the reducer. +- A callback registation method, whose name is prefixed with `on`, like `onSetName`. This registers a callback to run whenever we are notified that the reducer ran, including successfully committed runs and runs we requested which failed. This method returns a callback id, which can be passed to the callback remove method. +- A callback remove method, whose name is prefixed with `removeOn`, like `removeOnSetName`. This cancels a callback previously registered via the callback registration method. -```ts -{Reducer}.call(): void -``` +## Identify a client -#### Example +### Type `Identity` -```ts -SayHelloReducer.call(); +```rust +Identity ``` -## Events - -### {Reducer} on +A unique public identifier for a client connected to a database. -Register a callback to run each time the reducer is invoked. +### Type `ConnectionId` -```ts -{Reducer}.on(callback: (reducerEvent: ReducerEvent, ...reducerArgs: any[]) => void): void +```rust +ConnectionId ``` -Clients will only be notified of reducer runs if either of two criteria is met: - -- The reducer inserted, deleted or updated at least one row to which the client is subscribed. -- The reducer invocation was requested by this client, and the run failed. - -#### Parameters - -| Name | Type | -| :--------- | :------------------------------------------------------------- | -| `callback` | `(reducerEvent: ReducerEvent, ...reducerArgs: any[]) => void)` | - -#### Example - -```ts -SayHelloReducer.on((reducerEvent, ...reducerArgs) => { - console.log('SayHelloReducer called', reducerEvent, reducerArgs); -}); -``` +An opaque identifier for a client connection to a database, intended to differentiate between connections from the same [`Identity`](#type-identity). diff --git a/docs/sdks/typescript/quickstart.md b/docs/sdks/typescript/quickstart.md index 660b0bc2..d6f73f33 100644 --- a/docs/sdks/typescript/quickstart.md +++ b/docs/sdks/typescript/quickstart.md @@ -1,8 +1,13 @@ -# Typescript Client SDK Quickstart +# TypeScript Client SDK Quickstart -In this guide we'll show you how to get up and running with a simple SpacetimeDB app with a client written in Typescript. +In this guide, you'll learn how to use TypeScript to create a SpacetimeDB client application. -We'll implement a basic single page web app for the module created in our Rust or C# Module Quickstart guides. **Make sure you follow one of these guides before you start on this one.** +Please note that TypeScript is supported as a client language only. **Before you get started on this guide**, you should complete one of the quickstart guides for creating a SpacetimeDB server module listed below. + +- [Rust](/docs/modules/rust/quickstart) +- [C#](/docs/modules/c-sharp/quickstart) + +By the end of this introduciton, you will have created a basic single page web app which connects to the `quickstart-chat` module created in the above module quickstart guides. ## Project structure @@ -12,61 +17,64 @@ Enter the directory `quickstart-chat` you created in the [Rust Module Quickstart cd quickstart-chat ``` -Within it, create a `client` react app: +Within it, create a `client` React app: ```bash -npx create-react-app client --template typescript +pnpm create vite@latest client -- --template react-ts +cd client +pnpm install ``` We also need to install the `spacetime-client-sdk` package: ```bash -cd client -npm install @clockworklabs/spacetimedb-sdk +pnpm install @clockworklabs/spacetimedb-sdk@1.0.0-rc1.0 ``` +> If you are using another package manager like `yarn` or `npm`, the same steps should work with the appropriate commands for those tools. + +You can now `pnpm run dev` to see the Vite template app running at `http://localhost:5173`. + ## Basic layout -We are going to start by creating a basic layout for our app. The page contains four sections: +The app we're going to create is a basic chat application. We are going to start by creating a layout for our app. The webpage page will contain four sections: 1. A profile section, where we can set our name. 2. A message section, where we can see all the messages. 3. A system section, where we can see system messages. 4. A new message section, where we can send a new message. -The `onSubmitNewName` and `onMessageSubmit` callbacks will be called when the user clicks the submit button in the profile and new message sections, respectively. We'll hook these up later. - Replace the entire contents of `client/src/App.tsx` with the following: -```typescript -import React, { useEffect, useState } from "react"; -import logo from "./logo.svg"; -import "./App.css"; +```tsx +import React, { useEffect, useState } from 'react'; +import './App.css'; -export type MessageType = { - name: string; - message: string; +export type PrettyMessage = { + senderName: string; + text: string; }; function App() { - const [newName, setNewName] = useState(""); + const [newName, setNewName] = useState(''); const [settingName, setSettingName] = useState(false); - const [name, setName] = useState(""); - const [systemMessage, setSystemMessage] = useState(""); - const [messages, setMessages] = useState([]); + const [systemMessage, setSystemMessage] = useState(''); + const [newMessage, setNewMessage] = useState(''); - const [newMessage, setNewMessage] = useState(""); + const prettyMessages: PrettyMessage[] = []; + + const name = ''; const onSubmitNewName = (e: React.FormEvent) => { e.preventDefault(); setSettingName(false); - // Fill in app logic here + // TODO: Call `setName` reducer }; const onMessageSubmit = (e: React.FormEvent) => { e.preventDefault(); - // Fill in app logic here - setNewMessage(""); + setNewMessage(''); + // TODO: Call `sendMessage` reducer }; return ( @@ -89,9 +97,8 @@ function App() {
setNewName(e.target.value)} + onChange={e => setNewName(e.target.value)} /> @@ -99,19 +106,19 @@ function App() {

Messages

- {messages.length < 1 &&

No messages

} + {prettyMessages.length < 1 &&

No messages

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

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

-

{message.message}

+

{message.text}

))}
-
+

System

{systemMessage}

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

New Message

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

Connecting...

+
); } -}); ``` -### SetNameReducer.on callback - Handle errors and update profile name - -We can also register callbacks to run each time a reducer is invoked. We register these callbacks using the `OnReducer` method which is automatically implemented for each reducer by `spacetime generate`. - -Each reducer callback takes a number of parameters: - -1. `ReducerEvent` that contains information about the reducer that triggered this event. It contains several fields. The ones we care about are: +Finally, let's also compute the name of the user from the `Identity` in our `name` variable. Replace `const name = '';` with the following: - - `callerIdentity`: The `Identity` of the client that called the reducer. - - `status`: The `Status` of the reducer run, one of `"Committed"`, `"Failed"` or `"OutOfEnergy"`. - - `message`: The error message, if any, that the reducer returned. - -2. The rest of the parameters are arguments passed to the reducer. - -These callbacks will be invoked in one of two cases: - -1. If the reducer was successful and altered any of our subscribed rows. -2. If we requested an invocation which failed. +```tsx + const name = + users.get(identity?.toHexString())?.name || + identity?.toHexString().substring(0, 8) || + 'unknown'; +``` -Note that a status of `Failed` or `OutOfEnergy` implies that the caller identity is our own identity. +### Calling Reducers -We already handle other users' `set_name` calls using our `User.onUpdate` callback, but we need some additional behavior for setting our own name. If our name was rejected, we'll update the `system` message. If our name was accepted, we'll update our name in the app. +Let's hook up our callbacks so we can send some messages and see them displayed in the app after being synchronized by SpacetimeDB. We need to update the `onSubmitNewName` and `onSubmitMessage` callbacks to send the appropriate reducer to the module. -We'll test both that our identity matches the sender and that the status is `Failed`, even though the latter implies the former, for demonstration purposes. +Modify the `onSubmitNewName` callback by adding a call to the `setName` reducer: -If the reducer status comes back as `committed`, we'll update the name in our app. +```tsx + const onSubmitNewName = (e: React.FormEvent) => { + e.preventDefault(); + setSettingName(false); + conn.reducers.setName(newName); + }; +``` -To the body of `App`, add: +Next modify the `onSubmitMessage` callback by adding a call to the `sendMessage` reducer: -```typescript -SetNameReducer.on((reducerEvent, newName) => { - if ( - local_identity.current && - reducerEvent.callerIdentity.isEqual(local_identity.current) - ) { - if (reducerEvent.status === 'failed') { - appendToSystemMessage(`Error setting name: ${reducerEvent.message} `); - } else if (reducerEvent.status === 'committed') { - setName(newName); - } - } -}); +```tsx + const onMessageSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setNewMessage(""); + conn.reducers.sendMessage(newMessage); + }; ``` -### SendMessageReducer.on callback - Handle errors - -We handle warnings on rejected messages the same way as rejected names, though the types and the error message are different. We don't need to do anything for successful SendMessage reducer runs; our Message.onInsert callback already displays them. +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. -To the body of `App`, add: +Let's try out our app to see the result of these changes. -```typescript -SendMessageReducer.on((reducerEvent, newMessage) => { - if ( - local_identity.current && - reducerEvent.callerIdentity.isEqual(local_identity.current) - ) { - if (reducerEvent.status === 'failed') { - appendToSystemMessage(`Error sending message: ${reducerEvent.message} `); - } - } -}); +```sh +cd client +pnpm run dev ``` -## Update the UI button callbacks +> Don't forget! You may need to publish your server module if you haven't yet. -We need to update the `onSubmitNewName` and `onMessageSubmit` callbacks to send the appropriate reducer to the module. +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. -`spacetime generate` defined two functions for us, `SetNameReducer.call` and `SendMessageReducer.call`, which send a message to the database to invoke the corresponding reducer. The first argument, the `ReducerContext`, is supplied by the server, but we pass all other arguments ourselves. In our case, that means that both `SetNameReducer.call` and `SendMessageReducer.call` take one argument, a `String`. +Try opening a few incognito windows to see what it's like with multiple users! -Add the following to the `onSubmitNewName` callback: +### Notify about new users -```typescript -SetNameReducer.call(newName); -``` - -Add the following to the `onMessageSubmit` callback: - -```typescript -SendMessageReducer.call(newMessage); -``` +We can also register `onInsert` and `onDelete` callbacks for the purpose of handling events, not just state. For example, we might want to show a notification any time a new user connects to the module. -## Connecting to the module +Note that these callbacks can fire in two contexts: -We need to connect to the module when the app loads. We'll do this by adding a `useEffect` hook to the `App` function. This hook should only run once, when the component is mounted, but we are going to use an `initialized` boolean to ensure that it only runs once. +- After a reducer runs, when the client's cache is updated about changes to subscribed rows. +- After calling `subscribe`, when the client's cache is initialized with all existing matching rows. -```typescript -useEffect(() => { - if (!initialized.current) { - client.current.connect(); - initialized.current = true; - } -}, []); +Our `user` table includes all users not just online users, so we want to take care to only show a notification when new users join. Let's add a `useEffect` which subscribes a callback when a `user` is inserted into the table and a callback when a `user` is updated. Add the following to your `App` component just below the other `useEffect`. + +```tsx + useEffect(() => { + if (!conn) return; + conn.db.user.onInsert((_ctx, user) => { + if (user.online) { + const name = user.name || user.identity.toHexString().substring(0, 8); + setSystemMessage(prev => prev + `\n${name} has connected.`); + } + }); + conn.db.user.onUpdate((_ctx, oldUser, newUser) => { + const name = + newUser.name || newUser.identity.toHexString().substring(0, 8); + if (oldUser.online === false && newUser.online === true) { + setSystemMessage(prev => prev + `\n${name} has connected.`); + } else if (oldUser.online === true && newUser.online === false) { + setSystemMessage(prev => prev + `\n${name} has disconnected.`); + } + }); + }, [conn]); ``` -## What's next? - -When you run `npm start` you should see a chat app that can send and receive messages. If you open it in multiple private browser windows, you should see that messages are synchronized between them. - -Congratulations! You've built a simple chat app with SpacetimeDB. You can find the full source code for this app [here](https://github.com/clockworklabs/spacetimedb-typescript-sdk/tree/main/examples/quickstart) +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". -For a more advanced example of the SpacetimeDB TypeScript SDK, take a look at the [Spacetime MUD (multi-user dungeon)](https://github.com/clockworklabs/spacetime-mud/tree/main/react-client). +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. -## Troubleshooting +## Conclusion -If you encounter the following error: +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). -``` -TS2802: Type 'IterableIterator' can only be iterated through when using the '--downlevelIteration' flag or with a '--target' of 'es2015' or higher. -``` +At this point you've learned how to create a basic TypeScript client for your SpacetimeDB `quickstart-chat` module. You've learned how to connect to SpacetimeDB and call reducers to update data. You've learned how to subscribe to table data, and hook it up so that it updates reactively in a React application. -You can fix it by changing your compiler target. Add the following to your `tsconfig.json` file: +## What's next? -```json -{ - "compilerOptions": { - "target": "es2015" - } -} -``` +We covered a lot here, but we haven't covered everything. Take a look at our [reference documentation](/docs/sdks/typescript) to find out how you can use SpacetimeDB in more advanced ways, including managing reducer errors and subscribing to reducer events. \ No newline at end of file diff --git a/docs/sql/index.md b/docs/sql/index.md index 66097209..807af409 100644 --- a/docs/sql/index.md +++ b/docs/sql/index.md @@ -1,407 +1,648 @@ # SQL Support -SpacetimeDB supports a subset of SQL as a query language. Developers can evaluate SQL queries against a Spacetime database via the `spacetime sql` command-line tool and the [`/database/sql/:name_or_address POST` HTTP endpoint](/docs/http/database#databasesqlname_or_address-post). Client developers also write SQL queries when subscribing to events in the [WebSocket API](/docs/ws#subscribe) or via an SDK `subscribe` function. +SpacetimeDB supports two subsets of SQL: +One for queries issued through the [cli] or [http] api. +Another for subscriptions issued via the [sdk] or WebSocket api. -SpacetimeDB aims to support much of the [SQL 2016 standard](https://www.iso.org/standard/63555.html), and in particular aims to be compatible with [PostgreSQL](https://www.postgresql.org/). +## Subscriptions -SpacetimeDB 0.6 implements a relatively small subset of SQL. Future SpacetimeDB versions will implement additional SQL features. +```ebnf +SELECT projection FROM relation [ WHERE predicate ] +``` -## Types +The subscription language is strictly a query language. +Its sole purpose is to replicate a subset of the rows in the database, +and to **automatically** update them in realtime as the database changes. -| Type | Description | -| --------------------------------------------- | -------------------------------------- | -| [Nullable types](#nullable-types) | Types which may not hold a value. | -| [Logic types](#logic-types) | Booleans, i.e. `true` and `false`. | -| [Integer types](#integer-types) | Numbers without fractional components. | -| [Floating-point types](#floating-point-types) | Numbers with fractional components. | -| [Text types](#text-types) | UTF-8 encoded text. | +There is no context for manually updating this view. +Hence data manipulation commands like `INSERT` and `DELETE` are not supported. -### Definition statements +> NOTE: Because subscriptions are evaluated in realtime, +> performance is critical, and as a result, +> additional restrictions are applied over ad hoc queries. +> These restrictions are highlighted below. -| Statement | Description | -| ----------------------------- | ------------------------------------ | -| [CREATE TABLE](#create-table) | Create a new table. | -| [DROP TABLE](#drop-table) | Remove a table, discarding all rows. | +### SELECT -### Query statements +```ebnf +SELECT ( '*' | table '.' '*' ) +``` -| Statement | Description | -| ----------------- | -------------------------------------------------------------------------------------------- | -| [FROM](#from) | A source of data, like a table or a value. | -| [JOIN](#join) | Combine several data sources. | -| [SELECT](#select) | Select specific rows and columns from a data source, and optionally compute a derived value. | -| [DELETE](#delete) | Delete specific rows from a table. | -| [INSERT](#insert) | Insert rows into a table. | -| [UPDATE](#update) | Update specific rows in a table. | +The `SELECT` clause determines the table that is being subscribed to. +Since the subscription api is purely a replication api, +a query may only return rows from a single table, +and it must return the entire row. +Individual column projections are not allowed. -## Data types +A `*` projection is allowed when the table is unambiguous, +otherwise it must be qualified with the appropriate table name. + +#### Examples -SpacetimeDB is built on the Spacetime Algebraic Type System, or SATS. SATS is a richer, more expressive type system than the one included in the SQL language. +```sql +-- Subscribe to all rows of a table +SELECT * FROM Inventory -Because SATS is a richer type system than SQL, some SATS types cannot cleanly correspond to SQL types. In particular, the SpacetimeDB SQL interface is unable to construct or compare instances of product and sum types. As such, SpacetimeDB SQL must largely restrict themselves to interacting with columns of builtin types. +-- Qualify the `*` projection with the table +SELECT item.* from Inventory item -Most SATS builtin types map cleanly to SQL types. +-- Subscribe to all customers who have orders totaling more than $1000 +SELECT customer.* +FROM Customers customer JOIN Orders o ON customer.id = o.customer_id +WHERE o.amount > 1000 -### Nullable types +-- 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 +``` -SpacetimeDB types, by default, do not permit `NULL` as a value. Nullable types are encoded in SATS using a sum type which corresponds to [Rust's `Option`](https://doc.rust-lang.org/stable/std/option/enum.Option.html). In SQL, such types can be written by adding the constraint `NULL`, like `INT NULL`. +### FROM -### Logic types +```ebnf +FROM table [ [AS] alias ] [ [INNER] JOIN table [ [AS] alias ] ON column '=' column ] +``` -| SQL | SATS | Example | -| --------- | ------ | --------------- | -| `BOOLEAN` | `Bool` | `true`, `false` | +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. -### Numeric types +Subscriptions do not support joins of more than two tables. -#### Integer types +For any column referenced in `ON` clause of a `JOIN`, +it must be qualified with the appropriate table name or alias. -An integer is a number without a fractional component. +In order for a `JOIN` to be evaluated efficiently, +subscriptions require an index to be defined on both join columns. -Adding the `UNSIGNED` constraint to an integer type allows only positive values. This allows representing a larger positive range without increasing the width of the integer. +#### Example -| SQL | SATS | Example | Min | Max | -| ------------------- | ----- | ------- | ------ | ----- | -| `TINYINT` | `I8` | 1 | -(2⁷) | 2⁷-1 | -| `TINYINT UNSIGNED` | `U8` | 1 | 0 | 2⁸-1 | -| `SMALLINT` | `I16` | 1 | -(2¹⁵) | 2¹⁵-1 | -| `SMALLINT UNSIGNED` | `U16` | 1 | 0 | 2¹⁶-1 | -| `INT`, `INTEGER` | `I32` | 1 | -(2³¹) | 2³¹-1 | -| `INT UNSIGNED` | `U32` | 1 | 0 | 2³²-1 | -| `BIGINT` | `I64` | 1 | -(2⁶³) | 2⁶³-1 | -| `BIGINT UNSIGNED` | `U64` | 1 | 0 | 2⁶⁴-1 | +```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 +``` -#### Floating-point types +### WHERE + +```ebnf +predicate + = expr + | predicate AND predicate + | predicate OR predicate + ; + +expr + = literal + | column + | expr op expr + ; + +op + = '=' + | '<' + | '>' + | '<' '=' + | '>' '=' + | '!' '=' + | '<' '>' + ; + +literal + = INTEGER + | STRING + | HEX + | TRUE + | FALSE + ; +``` -SpacetimeDB supports single- and double-precision [binary IEEE-754 floats](https://en.wikipedia.org/wiki/IEEE_754). +While the `SELECT` clause determines the table, +the `WHERE` clause determines the rows in the subscription. -| SQL | SATS | Example | Min | Max | -| ----------------- | ----- | ------- | ------------------------ | ----------------------- | -| `REAL` | `F32` | 1.0 | -3.40282347E+38 | 3.40282347E+38 | -| `DOUBLE`, `FLOAT` | `F64` | 1.0 | -1.7976931348623157E+308 | 1.7976931348623157E+308 | +Arithmetic expressions are not supported. -### Text types +#### Examples -SpacetimeDB supports a single string type, `String`. SpacetimeDB strings are UTF-8 encoded. +```sql +-- Find products that sell for more than $X +SELECT * FROM Inventory WHERE price > {X} -| SQL | SATS | Example | Notes | -| ----------------------------------------------- | -------- | ------- | -------------------- | -| `CHAR`, `VARCHAR`, `NVARCHAR`, `TEXT`, `STRING` | `String` | 'hello' | Always UTF-8 encoded | +-- 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} +``` -> SpacetimeDB SQL currently does not support length contraints like `CHAR(10)`. +## Query and DML (Data Manipulation Language) -## Syntax +### Statements -### Comments +- [SELECT](#select-1) +- [INSERT](#insert) +- [DELETE](#delete) +- [UPDATE](#update) +- [SET](#set) +- [SHOW](#show) -SQL line comments begin with `--`. +### SELECT -```sql --- This is a comment +```ebnf +SELECT projection FROM relation [ WHERE predicate ] [LIMIT NUM] ``` -### Expressions +The query languge is a strict superset of the subscription language. +The main differences are seen in column projections and [joins](#from-clause). -We can express different, composable, values that are universally called `expressions`. +The subscription api only supports `*` projections, +but the query api supports both individual column projections, +as well as aggregations in the form of `COUNT`. -An expression is one of the following: +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. -#### Literals +#### SELECT Clause -| Example | Description | -| --------- | ----------- | -| `1` | An integer. | -| `1.0` | A float. | -| `'hello'` | A string. | -| `true` | A boolean. | +```ebnf +projection + = '*' + | table '.' '*' + | projExpr { ',' projExpr } + | aggExpr + ; -#### Binary operators +projExpr + = column [ [ AS ] alias ] + ; -| Example | Description | -| ------- | ------------------- | -| `1 > 2` | Integer comparison. | -| `1 + 2` | Integer addition. | +aggExpr + = COUNT '(' '*' ')' [AS] alias + ; +``` -#### Logical expressions +The `SELECT` clause determines the columns that are returned. -Any expression which returns a boolean, i.e. `true` or `false`, is a logical expression. +##### Examples -| Example | Description | -| ---------------- | ------------------------------------------------------------ | -| `1 > 2` | Integer comparison. | -| `1 + 2 == 3` | Equality comparison between a constant and a computed value. | -| `true AND false` | Boolean and. | -| `true OR false` | Boolean or. | -| `NOT true` | Boolean inverse. | +```sql +-- Select the items in my inventory +SELECT * FROM Inventory; -#### Function calls +-- Select the names and prices of the items in my inventory +SELECT item_name, price FROM Inventory +``` -| Example | Description | -| --------------- | -------------------------------------------------- | -| `lower('JOHN')` | Apply the function `lower` to the string `'JOHN'`. | +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. -#### Table identifiers +##### Example -| Example | Description | -| ------------- | ------------------------- | -| `inventory` | Refers to a table. | -| `"inventory"` | Refers to the same table. | +```sql +-- Count the items in my inventory +SELECT COUNT(*) AS n FROM Inventory +``` -#### Column references +#### FROM Clause -| Example | Description | -| -------------------------- | ------------------------------------------------------- | -| `inventory_id` | Refers to a column. | -| `"inventory_id"` | Refers to the same column. | -| `"inventory.inventory_id"` | Refers to the same column, explicitly naming its table. | +```ebnf +FROM table [ [AS] alias ] { [INNER] JOIN table [ [AS] alias ] ON predicate } +``` -#### Wildcards +Unlike [subscriptions](#from), the query api supports joining more than two tables. -Special "star" expressions which select all the columns of a table. +##### Examples -| Example | Description | -| ------------- | ------------------------------------------------------- | -| `*` | Refers to all columns of a table identified by context. | -| `inventory.*` | Refers to all columns of the `inventory` table. | +```sql +-- Find all customers who ordered a particular product and when they ordered it +SELECT customer.first_name, customer.last_name, o.date +FROM Customers customer +JOIN Orders o ON customer.id = o.customer_id +JOIN Inventory product ON o.product_id = product.id +WHERE product.name = {product_name} +``` -#### Parenthesized expressions +#### WHERE Clause -Sub-expressions can be enclosed in parentheses for grouping and to override operator precedence. +See [Subscriptions](#where). -| Example | Description | -| ------------- | ----------------------- | -| `1 + (2 / 3)` | One plus a fraction. | -| `(1 + 2) / 3` | A sum divided by three. | +#### LIMIT clause -### `CREATE TABLE` +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. -A `CREATE TABLE` statement creates a new, initially empty table in the database. +##### Examples -The syntax of the `CREATE TABLE` statement is: +```sql +-- Fetch an example row from my inventory +SELECT * FROM Inventory LIMIT 1 +``` -> **CREATE TABLE** _table_name_ (_column_name_ _data_type_, ...); +### INSERT -![create-table](/images/syntax/create_table.svg) +```ebnf +INSERT INTO table [ '(' column { ',' column } ')' ] VALUES '(' literal { ',' literal } ')' +``` #### Examples -Create a table `inventory` with two columns, an integer `inventory_id` and a string `name`: - ```sql -CREATE TABLE inventory (inventory_id INTEGER, name TEXT); +-- 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'); ``` -Create a table `player` with two integer columns, an `entity_id` and an `inventory_id`: +### DELETE -```sql -CREATE TABLE player (entity_id INTEGER, inventory_id INTEGER); +```ebnf +DELETE FROM table [ WHERE predicate ] ``` -Create a table `location` with three columns, an integer `entity_id` and floats `x` and `z`: +Deletes all rows from a table. +If `WHERE` is specified, only the matching rows are deleted. -```sql -CREATE TABLE location (entity_id INTEGER, x REAL, z REAL); -``` +`DELETE` does not support joins. -### `DROP TABLE` +#### Examples + +```sql +-- Delete all rows +DELETE FROM Inventory; -A `DROP TABLE` statement removes a table from the database, deleting all its associated rows, indexes, constraints and sequences. +-- Delete all rows with a specific item_id +DELETE FROM Inventory WHERE item_id = 1; +``` -To empty a table of rows without destroying the table, use [`DELETE`](#delete). +### UPDATE -The syntax of the `DROP TABLE` statement is: +```ebnf +UPDATE table SET [ '(' assignment { ',' assignment } ')' ] [ WHERE predicate ] +``` -> **DROP TABLE** _table_name_; +Updates column values of existing rows in a table. +The columns are identified by the `assignment` defined as `column '=' literal`. +The column values are updated for all rows that match the `WHERE` condition. +The rows are updated after the `WHERE` condition is evaluated for all rows. -![drop-table](/images/syntax/drop_table.svg) +`UPDATE` does not support joins. -Examples: +#### Examples ```sql -DROP TABLE inventory; +-- Update the item_name for all rows with a specific item_id +UPDATE Inventory SET item_name = 'new name' WHERE item_id = 1; ``` -## Queries - -### `FROM` +### SET -A `FROM` clause derives a data source from a table name. +> WARNING: The `SET` statement is experimental. +> Compatibility with future versions of SpacetimeDB is not guaranteed. -The syntax of the `FROM` clause is: - -> **FROM** _table_name_ _join_clause_?; +```ebnf +SET var ( TO | '=' ) literal +``` -![from](/images/syntax/from.svg) +Updates the value of a system variable. -#### Examples +### SHOW -Select all rows from the `inventory` table: +> WARNING: The `SHOW` statement is experimental. +> Compatibility with future versions of SpacetimeDB is not guaranteed. -```sql -SELECT * FROM inventory; +```ebnf +SHOW var ``` -### `JOIN` +Returns the value of a system variable. + +## System Variables -A `JOIN` clause combines two data sources into a new data source. +> WARNING: System variables are experimental. +> Compatibility with future versions of SpacetimeDB is not guaranteed. -Currently, SpacetimeDB SQL supports only inner joins, which return rows from two data sources where the values of two columns match. +- `row_limit` -The syntax of the `JOIN` clause is: + ```sql + -- Reject queries that scan more than 10K rows + SET row_limit = 10000 + ``` -> **JOIN** _table_name_ **ON** _expr_ = _expr_; +## Data types -![join](/images/syntax/join.svg) +The set of data types that SpacetimeDB supports is defined by SATS, +the Spacetime Algebraic Type System. -### Examples +Spacetime SQL however does not support all of SATS, +specifically in the way of product and sum types. +The language itself does not provide a way to construct them, +nore does it provide any scalar operators for them. +Nevertheless rows containing them can be returned to clients. -Select all players rows who have a corresponding location: +## Literals -```sql -SELECT player.* FROM player - JOIN location - ON location.entity_id = player.entity_id; +```ebnf +literal = INTEGER | FLOAT | STRING | HEX | TRUE | FALSE ; ``` -Select all inventories which have a corresponding player, and where that player has a corresponding location: +The following describes how to construct literal values for SATS data types in Spacetime SQL. -```sql -SELECT inventory.* FROM inventory - JOIN player - ON inventory.inventory_id = player.inventory_id - JOIN location - ON player.entity_id = location.entity_id; -``` +### Booleans + +Booleans are represented using the canonical atoms `true` or `false`. -### `SELECT` +### Integers -A `SELECT` statement returns values of particular columns from a data source, optionally filtering the data source to include only rows which satisfy a `WHERE` predicate. +```ebnf +INTEGER + = [ '+' | '-' ] NUM + | [ '+' | '-' ] NUM 'E' [ '+' ] NUM + ; -The syntax of the `SELECT` command is: +NUM + = DIGIT { DIGIT } + ; -> **SELECT** _column_expr_ > **FROM** _from_expr_ -> {**WHERE** _expr_}? +DIGIT + = 0..9 + ; +``` -![sql-select](/images/syntax/select.svg) +SATS supports multple fixed width integer types. +The concrete type of a literal is inferred from the context. #### Examples -Select all columns of all rows from the `inventory` table: - ```sql -SELECT * FROM inventory; -SELECT inventory.* FROM inventory; +-- All products that sell for more than $1000 +SELECT * FROM Inventory WHERE price > 1000 +SELECT * FROM Inventory WHERE price > 1e3 +SELECT * FROM Inventory WHERE price > 1E3 ``` -Select only the `inventory_id` column of all rows from the `inventory` table: +### Floats -```sql -SELECT inventory_id FROM inventory; -SELECT inventory.inventory_id FROM inventory; +```ebnf +FLOAT + = [ '+' | '-' ] [ NUM ] '.' NUM + | [ '+' | '-' ] [ NUM ] '.' NUM 'E' [ '+' | '-' ] NUM + ; ``` -An optional `WHERE` clause can be added to filter the data source using a [logical expression](#logical-expressions). The `SELECT` will return only the rows from the data source for which the expression returns `true`. +SATS supports both 32 and 64 bit floating point types. +The concrete type of a literal is inferred from the context. #### Examples -Select all columns of all rows from the `inventory` table, with a filter that is always true: - ```sql -SELECT * FROM inventory WHERE 1 = 1; +-- All measurements where the temperature is greater than 105.3 +SELECT * FROM Measurements WHERE temperature > 105.3 +SELECT * FROM Measurements WHERE temperature > 1053e-1 +SELECT * FROM Measurements WHERE temperature > 1053E-1 ``` -Select all columns of all rows from the `inventory` table with the `inventory_id` 1: +### Strings -```sql -SELECT * FROM inventory WHERE inventory_id = 1; +```ebnf +STRING + = "'" { "''" | CHAR } "'" + ; ``` -Select only the `name` column of all rows from the `inventory` table with the `inventory_id` 1: +`CHAR` is defined as a `utf-8` encoded unicode character. -```sql -SELECT name FROM inventory WHERE inventory_id = 1; -``` - -Select all columns of all rows from the `inventory` table where the `inventory_id` is 2 or greater: +#### Examples ```sql -SELECT * FROM inventory WHERE inventory_id > 1; +SELECT * FROM Customers WHERE first_name = 'John' ``` -### `INSERT` +### Hex -An `INSERT INTO` statement inserts new rows into a table. +```ebnf +HEX + = 'X' "'" { HEXIT } "'" + | '0' 'x' { HEXIT } + ; -One can insert one or more rows specified by value expressions. +HEXIT + = DIGIT | a..f | A..F + ; +``` -The syntax of the `INSERT INTO` statement is: +Hex literals can represent [Identity], [ConnectionId], or binary types. +The type is ultimately inferred from the context. -> **INSERT INTO** _table_name_ (_column_name_, ...) **VALUES** (_expr_, ...), ...; +#### Examples -![sql-insert](/images/syntax/insert.svg) +```sql +SELECT * FROM Program WHERE hash_value = 0xABCD1234 +``` -#### Examples +## Identifiers -Insert a single row: +```ebnf +identifier + = LATIN { LATIN | DIGIT | '_' } + | '"' { '""' | CHAR } '"' + ; -```sql -INSERT INTO inventory (inventory_id, name) VALUES (1, 'health1'); +LATIN + = a..z | A..Z + ; ``` -Insert two rows: +Identifiers are tokens that identify database objects like tables or columns. +Spacetime SQL supports both quoted and unquoted identifiers. +Both types of identifiers are case sensitive. +Use quoted identifiers to avoid conflict with reserved SQL keywords, +or if your table or column contains non-alphanumeric characters. + +### Example ```sql -INSERT INTO inventory (inventory_id, name) VALUES (1, 'health1'), (2, 'health2'); +-- `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$" ``` -### UPDATE +## Best Practices for Performance and Scalability -An `UPDATE` statement changes the values of a set of specified columns in all rows of a table, optionally filtering the table to update only rows which satisfy a `WHERE` predicate. +When designing your schema or crafting your queries, +consider the following best practices to ensure optimal performance: -Columns not explicitly modified with the `SET` clause retain their previous values. +- **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. -If the `WHERE` clause is absent, the effect is to update all rows in the table. +- **Index Filtered Columns:** + Index columns frequently used in a `WHERE` clause. + Indexes reduce the number of rows scanned by the query engine. -The syntax of the `UPDATE` statement is +- **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`. -> **UPDATE** _table_name_ **SET** > _column_name_ = _expr_, ... -> {_WHERE expr_}?; + 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. -![sql-update](/images/syntax/update.svg) + 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. -#### Examples +- **Optimize Join Order:** + Place tables with the most selective filters first in your `FROM` clause. + This minimizes intermediate result sizes and improves query efficiency. -Set the `name` column of all rows from the `inventory` table with the `inventory_id` 1 to `'new name'`: +### Example +Take the following query that was used in a previous example: ```sql -UPDATE inventory - SET name = 'new name' - WHERE inventory_id = 1; +-- 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} ``` -### DELETE - -A `DELETE` statement deletes rows that satisfy the `WHERE` clause from the specified table. +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; + .. +} +``` +::: -If the `WHERE` clause is absent, the effect is to delete all rows in the table. In that case, the result is a valid empty table. +```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}; +``` -The syntax of the `DELETE` statement is +## Appendix -> **DELETE** _table_name_ -> {**WHERE** _expr_}?; +Common production rules that have been used throughout this document. -![sql-delete](/images/syntax/delete.svg) +```ebnf +table + = identifier + ; -#### Examples +alias + = identifier + ; -Delete all the rows from the `inventory` table with the `inventory_id` 1: +var + = identifier + ; -```sql -DELETE FROM inventory WHERE inventory_id = 1; +column + = identifier + | identifier '.' identifier + ; ``` -Delete all rows from the `inventory` table, leaving it empty: -```sql -DELETE FROM inventory; -``` +[sdk]: /docs/sdks/rust/index.md#subscribe-to-queries +[http]: /docs/http/database#databasesqlname_or_address-post +[cli]: /docs/cli-reference.md#spacetime-sql + +[Identity]: /docs/index.md#identity +[ConnectionId]: /docs/index.md#connectionid diff --git a/docs/subscriptions/index.md b/docs/subscriptions/index.md new file mode 100644 index 00000000..a896f6a6 --- /dev/null +++ b/docs/subscriptions/index.md @@ -0,0 +1,446 @@ +# The SpacetimeDB Subscription API + +The subscription API allows a client to replicate a subset of a database. +It does so by registering SQL queries, which we call subscriptions, through a database connection. +A client will only receive updates for rows that match the subscriptions it has registered. + +For more information on syntax and requirements see the [SQL docs](/docs/sql#subscriptions). + +This guide describes the two main interfaces that comprise the API - `SubscriptionBuilder` and `SubscriptionHandle`. +By using these interfaces, you can create efficient and responsive client applications that only receive the data they need. + +## SubscriptionBuilder + +:::server-rust +```rust +pub struct SubscriptionBuilder { /* private fields */ } + +impl SubscriptionBuilder { + /// Register a callback that runs when the subscription has been applied. + /// This callback receives a context containing the current state of the subscription. + pub fn on_applied(mut self, callback: impl FnOnce(&M::SubscriptionEventContext) + Send + 'static); + + /// Register a callback to run when the subscription fails. + /// + /// Note that this callback may run either when attempting to apply the subscription, + /// in which case [`Self::on_applied`] will never run, + /// or later during the subscription's lifetime if the module's interface changes, + /// in which case [`Self::on_applied`] may have already run. + pub fn on_error(mut self, callback: impl FnOnce(&M::ErrorContext, crate::Error) + Send + 'static); + + /// Subscribe to a subset of the database via a set of SQL queries. + /// Returns a handle which you can use to monitor or drop the subscription later. + pub fn subscribe(self, query_sql: Queries) -> M::SubscriptionHandle; + + /// Subscribe to all rows from all tables. + /// + /// This method is intended as a convenience + /// for applications where client-side memory use and network bandwidth are not concerns. + /// Applications where these resources are a constraint + /// should register more precise queries via [`Self::subscribe`] + /// in order to replicate only the subset of data which the client needs to function. + pub fn subscribe_to_all_tables(self); +} + +/// Types which specify a list of query strings. +pub trait IntoQueries { + fn into_queries(self) -> Box<[Box]>; +} +``` +::: +:::server-csharp +```cs +public sealed class SubscriptionBuilder +{ + /// + /// Register a callback to run when the subscription is applied. + /// + public SubscriptionBuilder OnApplied( + Action callback + ); + + /// + /// Register a callback to run when the subscription fails. + /// + /// Note that this callback may run either when attempting to apply the subscription, + /// in which case Self::on_applied will never run, + /// or later during the subscription's lifetime if the module's interface changes, + /// in which case Self::on_applied may have already run. + /// + public SubscriptionBuilder OnError( + Action callback + ); + + /// + /// Subscribe to the following SQL queries. + /// + /// This method returns immediately, with the data not yet added to the DbConnection. + /// The provided callbacks will be invoked once the data is returned from the remote server. + /// Data from all the provided queries will be returned at the same time. + /// + /// See the SpacetimeDB SQL docs for more information on SQL syntax: + /// https://spacetimedb.com/docs/sql + /// + public SubscriptionHandle Subscribe( + string[] querySqls + ); + + /// + /// Subscribe to all rows from all tables. + /// + /// This method is intended as a convenience + /// for applications where client-side memory use and network bandwidth are not concerns. + /// Applications where these resources are a constraint + /// should register more precise queries via Self.Subscribe + /// in order to replicate only the subset of data which the client needs to function. + /// + public void SubscribeToAllTables(); +} +``` +::: + +A `SubscriptionBuilder` provides an interface for registering subscription queries with a database. +It allows you to register callbacks that run when the subscription is successfully applied or when an error occurs. +Once applied, a client will start receiving row updates to its client cache. +A client can react to these updates by registering row callbacks for the appropriate table. + +### Example Usage + +:::server-rust +```rust +// Establish a database connection +let conn: DbConnection = connect_to_db(); + +// Register a subscription with the database +let subscription_handle = conn + .subscription_builder() + .on_applied(|ctx| { /* handle applied state */ }) + .on_error(|error_ctx, error| { /* handle error */ }) + .subscribe(["SELECT * FROM user", "SELECT * FROM message"]); +``` +::: +:::server-csharp +```cs +// Establish a database connection +var conn = ConnectToDB(); + +// Register a subscription with the database +var userSubscription = conn + .SubscriptionBuilder() + .OnApplied((ctx) => { /* handle applied state */ }) + .OnError((errorCtx, error) => { /* handle error */ }) + .Subscribe(new string[] { "SELECT * FROM user", "SELECT * FROM message" }); +``` +::: + +## SubscriptionHandle + +:::server-rust +```rust +pub trait SubscriptionHandle: InModule + Clone + Send + 'static +where + Self::Module: SpacetimeModule, +{ + /// Returns `true` if the subscription has been ended. + /// That is, if it has been unsubscribed or terminated due to an error. + fn is_ended(&self) -> bool; + + /// Returns `true` if the subscription is currently active. + fn is_active(&self) -> bool; + + /// Unsubscribe from the query controlled by this `SubscriptionHandle`, + /// then run `on_end` when its rows are removed from the client cache. + /// Returns an error if the subscription is already ended, + /// or if unsubscribe has already been called. + fn unsubscribe_then(self, on_end: OnEndedCallback) -> crate::Result<()>; + + /// Unsubscribe from the query controlled by this `SubscriptionHandle`. + /// Returns an error if the subscription is already ended, + /// or if unsubscribe has already been called. + fn unsubscribe(self) -> crate::Result<()>; +} +``` +::: +:::server-csharp +```cs + public class SubscriptionHandle : ISubscriptionHandle + where SubscriptionEventContext : ISubscriptionEventContext + where ErrorContext : IErrorContext + { + /// + /// Whether the subscription has ended. + /// + public bool IsEnded; + + /// + /// Whether the subscription is active. + /// + public bool IsActive; + + /// + /// Unsubscribe from the query controlled by this subscription handle. + /// + /// Calling this more than once will result in an exception. + /// + public void Unsubscribe(); + + /// + /// Unsubscribe from the query controlled by this subscription handle, + /// and call onEnded when its rows are removed from the client cache. + /// + public void UnsubscribeThen(Action? onEnded); + } +``` +::: + +When you register a subscription, you receive a `SubscriptionHandle`. +A `SubscriptionHandle` manages the lifecycle of each subscription you register. +In particular, it provides methods to check the status of the subscription and to unsubscribe if necessary. +Because each subscription has its own independently managed lifetime, +clients can dynamically subscribe to different subsets of the database as their application requires. + +### Example Usage + +:::server-rust +Consider a game client that displays shop items and discounts based on a player's level. +You subscribe to `shop_items` and `shop_discounts` when a player is at level 5: + +```rust +let conn: DbConnection = connect_to_db(); + +let shop_items_subscription = conn + .subscription_builder() + .on_applied(|ctx| { /* handle applied state */ }) + .on_error(|error_ctx, error| { /* handle error */ }) + .subscribe([ + "SELECT * FROM shop_items WHERE required_level <= 5", + "SELECT * FROM shop_discounts WHERE required_level <= 5", + ]); +``` + +Later, when the player reaches level 6 and new items become available, +you can subscribe to the new queries and unsubscribe from the old ones: + +```rust +let new_shop_items_subscription = conn + .subscription_builder() + .on_applied(|ctx| { /* handle applied state */ }) + .on_error(|error_ctx, error| { /* handle error */ }) + .subscribe([ + "SELECT * FROM shop_items WHERE required_level <= 6", + "SELECT * FROM shop_discounts WHERE required_level <= 6", + ]); + +if shop_items_subscription.is_active() { + shop_items_subscription + .unsubscribe() + .expect("Unsubscribing from shop_items failed"); +} +``` + +All other subscriptions continue to remain in effect. +::: +:::server-csharp +Consider a game client that displays shop items and discounts based on a player's level. +You subscribe to `shop_items` and `shop_discounts` when a player is at level 5: + +```cs +var conn = ConnectToDB(); + +var shopItemsSubscription = conn + .SubscriptionBuilder() + .OnApplied((ctx) => { /* handle applied state */ }) + .OnError((errorCtx, error) => { /* handle error */ }) + .Subscribe(new string[] { + "SELECT * FROM shop_items WHERE required_level <= 5", + "SELECT * FROM shop_discounts WHERE required_level <= 5", + }); +``` + +Later, when the player reaches level 6 and new items become available, +you can subscribe to the new queries and unsubscribe from the old ones: + +```cs +var newShopItemsSubscription = conn + .SubscriptionBuilder() + .OnApplied((ctx) => { /* handle applied state */ }) + .OnError((errorCtx, error) => { /* handle error */ }) + .Subscribe(new string[] { + "SELECT * FROM shop_items WHERE required_level <= 6", + "SELECT * FROM shop_discounts WHERE required_level <= 6", + }); + +if (shopItemsSubscription.IsActive) +{ + shopItemsSubscription.Unsubscribe(); +} +``` + +All other subscriptions continue to remain in effect. +::: + +## Best Practices for Optimizing Server Compute and Reducing Serialization Overhead + +### 1. Writing Efficient SQL Queries + +For writing efficient SQL queries, see our [SQL Best Practices Guide](/docs/sql#best-practices-for-performance-and-scalability). + +### 2. Group Subscriptions with the Same Lifetime Together + +Subscriptions with the same lifetime should be grouped together. + +For example, you may have certain data that is required for the lifetime of your application, +but you may have other data that is only sometimes required by your application. + +By managing these sets as two independent subscriptions, +your application can subscribe and unsubscribe from the latter, +without needlessly unsubscribing and resubscribing to the former. + +This will improve throughput by reducing the amount of data transferred from the database to your application. + +#### Example + +:::server-rust +```rust +let conn: DbConnection = connect_to_db(); + +// Never need to unsubscribe from global subscriptions +let global_subscriptions = conn + .subscription_builder() + .subscribe([ + // Global messages the client should always display + "SELECT * FROM announcements", + // A description of rewards for in-game achievements + "SELECT * FROM badges", + ]); + +// May unsubscribe to shop_items as player advances +let shop_subscription = conn + .subscription_builder() + .subscribe([ + "SELECT * FROM shop_items WHERE required_level <= 5", + ]); +``` +::: +:::server-csharp +```cs +var conn = ConnectToDB(); + +// Never need to unsubscribe from global subscriptions +var globalSubscriptions = conn + .SubscriptionBuilder() + .Subscribe(new string[] { + // Global messages the client should always display + "SELECT * FROM announcements", + // A description of rewards for in-game achievements + "SELECT * FROM badges", + }); + +// May unsubscribe to shop_items as player advances +var shopSubscription = conn + .SubscriptionBuilder() + .Subscribe(new string[] { + "SELECT * FROM shop_items WHERE required_level <= 5" + }); +``` +::: + +### 3. Subscribe Before Unsubscribing + +If you want to update or modify a subscription by dropping it and subscribing to a new set, +you should subscribe to the new set before unsubscribing from the old one. + +This is because SpacetimeDB subscriptions are zero-copy. +Subscribing to the same query more than once doesn't incur additional processing or serialization overhead. +Likewise, if a query is subscribed to more than once, +unsubscribing from it does not result in any server processing or data serializtion. + +#### Example + +:::server-rust +```rust +let conn: DbConnection = connect_to_db(); + +// Initial subscription: player at level 5. +let shop_subscription = conn + .subscription_builder() + .subscribe([ + // For displaying the price of shop items in the player's currency of choice + "SELECT * FROM exchange_rates", + "SELECT * FROM shop_items WHERE required_level <= 5", + ]); + +// New subscription: player now at level 6, which overlaps with the previous query. +let new_shop_subscription = conn + .subscription_builder() + .subscribe([ + // For displaying the price of shop items in the player's currency of choice + "SELECT * FROM exchange_rates", + "SELECT * FROM shop_items WHERE required_level <= 6", + ]); + +// Unsubscribe from the old subscription once the new one is active. +if shop_subscription.is_active() { + shop_subscription.unsubscribe(); +} +``` +::: +:::server-csharp +```cs +var conn = ConnectToDB(); + +// Initial subscription: player at level 5. +var shopSubscription = conn + .SubscriptionBuilder() + .Subscribe(new string[] { + // For displaying the price of shop items in the player's currency of choice + "SELECT * FROM exchange_rates", + "SELECT * FROM shop_items WHERE required_level <= 5" + }); + +// New subscription: player now at level 6, which overlaps with the previous query. +var newShopSubscription = conn + .SubscriptionBuilder() + .Subscribe(new string[] { + // For displaying the price of shop items in the player's currency of choice + "SELECT * FROM exchange_rates", + "SELECT * FROM shop_items WHERE required_level <= 6" + }); + +// Unsubscribe from the old subscription once the new one is in place. +if (shopSubscription.IsActive) +{ + shopSubscription.Unsubscribe(); +} +``` +::: + +### 4. Avoid Overlapping Queries + +This refers to distinct queries that return intersecting data sets, +which can result in the server processing and serializing the same row multiple times. +While SpacetimeDB can manage this redundancy, it may lead to unnecessary inefficiencies. + +Consider the following two queries: +```sql +SELECT * FROM User +SELECT * FROM User WHERE id = 5 +``` + +If `User.id` is a unique or primary key column, +the cost of subscribing to both queries is minimal. +This is because the server will use an index when processing the 2nd query, +and it will only serialize a single row for the 2nd query. + +In contrast, consider these two queries: +```sql +SELECT * FROM User +SELECT * FROM User WHERE id != 5 +``` + +The server must now process each row of the `User` table twice, +since the 2nd query cannot be processed using an index. +It must also serialize all but one row of the `User` table twice, +due to the significant overlap between the two queries. + +By following these best practices, you can optimize your data replication strategy and ensure your application remains efficient and responsive. diff --git a/docs/unity/part-2.md b/docs/unity/part-2.md index 9e9936c9..54c1983a 100644 --- a/docs/unity/part-2.md +++ b/docs/unity/part-2.md @@ -109,7 +109,7 @@ public partial struct Config Let's break down this code. This defines a normal C# `struct` with two fields: `id` and `world_size`. We have added the `[Table(Name = "config", Public = true)]` attribute the struct. This attribute signals to SpacetimeDB that it should create a new SpacetimeDB table with the row type defined by the `Config` type's fields. > Although we're using `lower_snake_case` for our column names to have consistent column names across languages in this tutorial, you can also use `camelCase` or `PascalCase` if you prefer. See [#2168](https://github.com/clockworklabs/SpacetimeDB/issues/2168) for more information. - + The `Table` attribute with takes two parameters, a `Name` which is the name of the table and what you will use to query the table in SQL, and a `Public` visibility modifier which ensures that the rows of this table are visible to everyone. The `[PrimaryKey]` attribute, specifies that the `id` field should be used as the primary key of the table. @@ -294,7 +294,7 @@ Add this function to the `Module` class in `Lib.cs`: [Reducer] public static void Debug(ReducerContext ctx) { - Log.Info($"This reducer was called by {ctx.CallerIdentity}"); + Log.Info($"This reducer was called by {ctx.Sender}"); } ``` ::: @@ -395,7 +395,7 @@ pub fn connect(ctx: &ReducerContext) -> Result<(), String> { The `client_connected` argument to the `spacetimedb::reducer` macro indicates to SpacetimeDB that this is a special reducer. This reducer is only every called by SpacetimeDB itself when a client connects to your module. > SpacetimeDB gives you the ability to define custom reducers that automatically trigger when certain events occur. -> +> > - `init` - Called the first time you publish your module and anytime you clear the database with `spacetime publish --delete-data`. > - `client_connected` - Called when a user connects to the SpacetimeDB module. Their identity can be found in the `sender` value of the `ReducerContext`. > - `client_disconnected` - Called when a user disconnects from the SpacetimeDB module. @@ -407,16 +407,16 @@ Next let's connect our client to our module. Let's start by modifying our `Debug [Reducer(ReducerKind.ClientConnected)] public static void Connect(ReducerContext ctx) { - Log.Info($"{ctx.CallerIdentity} just connected."); + Log.Info($"{ctx.Sender} just connected."); } ``` The `ReducerKind.ClientConnected` argument to the `SpacetimeDB.Reducer` attribute indicates to SpacetimeDB that this is a special reducer. This reducer is only every called by SpacetimeDB itself when a client connects to your module. > SpacetimeDB gives you the ability to define custom reducers that automatically trigger when certain events occur. -> +> > - `ReducerKind.Init` - Called the first time you publish your module and anytime you clear the database with `spacetime publish --delete-data`. -> - `ReducerKind.ClientConnected` - Called when a user connects to the SpacetimeDB module. Their identity can be found in the `CallerIdentity` value of the `ReducerContext`. +> - `ReducerKind.ClientConnected` - Called when a user connects to the SpacetimeDB module. Their identity can be found in the `Sender` value of the `ReducerContext`. > - `ReducerKind.ClientDisconnected` - Called when a user disconnects from the SpacetimeDB module. ::: @@ -443,13 +443,26 @@ spacetime generate --lang csharp --out-dir ../client-unity/Assets/autogen # you This will generate a set of files in the `client-unity/Assets/autogen` directory which contain the code generated types and reducer functions that are defined in your module, but usable on the client. -```sh -ls ../client-unity/Assets/autogen/*.cs -../client-unity/Assets/autogen/Circle.cs ../client-unity/Assets/autogen/DbVector2.cs ../client-unity/Assets/autogen/Food.cs -../client-unity/Assets/autogen/Config.cs ../client-unity/Assets/autogen/Entity.cs ../client-unity/Assets/autogen/Player.cs +``` +├── Reducers +│ └── Connect.g.cs +├── Tables +│ ├── Circle.g.cs +│ ├── Config.g.cs +│ ├── Entity.g.cs +│ ├── Food.g.cs +│ └── Player.g.cs +├── Types +│ ├── Circle.g.cs +│ ├── Config.g.cs +│ ├── DbVector2.g.cs +│ ├── Entity.g.cs +│ ├── Food.g.cs +│ └── Player.g.cs +└── SpacetimeDBClient.g.cs ``` -This will also generate a file in the `client-unity/Assets/autogen/_Globals` directory with a type aware `DbConnection` class. We will use this class to connect to your module from Unity. +This will also generate a file in the `client-unity/Assets/autogen/SpacetimeDBClient.g.cs` directory with a type aware `DbConnection` class. We will use this class to connect to your module from Unity. > IMPORTANT! At this point there will be an error in your Unity project. Due to a [known issue](https://docs.unity3d.com/6000.0/Documentation/Manual/csharp-compiler.html) with Unity and C# 9 you need to insert the following code into your Unity project. > @@ -509,7 +522,7 @@ public class GameManager : MonoBehaviour // If the user has a SpacetimeDB auth token stored in the Unity PlayerPrefs, // we can use it to authenticate the connection. - if (PlayerPrefs.HasKey(AuthToken.GetTokenKey())) + if (AuthToken.Token != "") { builder = builder.WithToken(AuthToken.Token); } @@ -548,7 +561,7 @@ public class GameManager : MonoBehaviour } } - private void HandleSubscriptionApplied(EventContext ctx) + private void HandleSubscriptionApplied(SubscriptionEventContext ctx) { Debug.Log("Subscription applied!"); OnSubscriptionApplied?.Invoke(); diff --git a/docs/unity/part-3.md b/docs/unity/part-3.md index 1bfbc51e..52206f25 100644 --- a/docs/unity/part-3.md +++ b/docs/unity/part-3.md @@ -106,7 +106,7 @@ const uint TARGET_FOOD_COUNT = 600; public static float MassToRadius(uint mass) => MathF.Sqrt(mass); [Reducer] -public static void SpawnFood(ReducerContext ctx) +public static void SpawnFood(ReducerContext ctx, SpawnFoodTimer timer) { if (ctx.Db.player.Count == 0) //Are there no players yet? { @@ -220,7 +220,7 @@ pub fn init(ctx: &ReducerContext) -> Result<(), String> { })?; ctx.db.spawn_food_timer().try_insert(SpawnFoodTimer { scheduled_id: 0, - scheduled_at: ScheduleAt::Interval(Duration::from_millis(500).as_micros() as u64), + scheduled_at: ScheduleAt::Interval(Duration::from_millis(500).into()), })?; Ok(()) } @@ -336,12 +336,6 @@ pub fn disconnect(ctx: &ReducerContext) -> Result<(), String> { ctx.db.logged_out_player().insert(player); ctx.db.player().identity().delete(&ctx.sender); - // Remove any circles from the arena - for circle in ctx.db.circle().player_id().filter(&player_id) { - ctx.db.entity().entity_id().delete(&circle.entity_id); - ctx.db.circle().entity_id().delete(&circle.entity_id); - } - Ok(()) } ``` @@ -353,7 +347,7 @@ Next, modify your `Connect` reducer and add a new `Disconnect` reducer below it: [Reducer(ReducerKind.ClientConnected)] public static void Connect(ReducerContext ctx) { - var player = ctx.Db.logged_out_player.identity.Find(ctx.CallerIdentity); + var player = ctx.Db.logged_out_player.identity.Find(ctx.Sender); if (player != null) { ctx.Db.player.Insert(player.Value); @@ -363,7 +357,7 @@ public static void Connect(ReducerContext ctx) { ctx.Db.player.Insert(new Player { - identity = ctx.CallerIdentity, + identity = ctx.Sender, name = "", }); } @@ -372,7 +366,7 @@ public static void Connect(ReducerContext ctx) [Reducer(ReducerKind.ClientDisconnected)] public static void Disconnect(ReducerContext ctx) { - var player = ctx.Db.player.identity.Find(ctx.CallerIdentity) ?? throw new Exception("Player not found"); + var player = ctx.Db.player.identity.Find(ctx.Sender) ?? throw new Exception("Player not found"); ctx.Db.logged_out_player.Insert(player); ctx.Db.player.identity.Delete(player.identity); } @@ -469,7 +463,7 @@ const uint START_PLAYER_MASS = 15; public static void EnterGame(ReducerContext ctx, string name) { Log.Info($"Creating player with name {name}"); - var player = ctx.Db.player.identity.Find(ctx.CallerIdentity) ?? throw new Exception("Player not found"); + var player = ctx.Db.player.identity.Find(ctx.Sender) ?? throw new Exception("Player not found"); player.name = name; ctx.Db.player.identity.Update(player); SpawnPlayerInitialCircle(ctx, player.player_id); @@ -545,7 +539,7 @@ pub fn disconnect(ctx: &ReducerContext) -> Result<(), String> { [Reducer(ReducerKind.ClientDisconnected)] public static void Disconnect(ReducerContext ctx) { - var player = ctx.Db.player.identity.Find(ctx.CallerIdentity) ?? throw new Exception("Player not found"); + var player = ctx.Db.player.identity.Find(ctx.Sender) ?? throw new Exception("Player not found"); // Remove any circles from the arena foreach (var circle in ctx.Db.circle.player_id.Filter(player.player_id)) { @@ -600,7 +594,7 @@ Start by adding `SetupArena` and `CreateBorderCube` methods to your `GameManager In your `HandleSubscriptionApplied` let's now call `SetupArea` method. Modify your `HandleSubscriptionApplied` method as in the below. ```cs - private void HandleSubscriptionApplied(EventContext ctx) + private void HandleSubscriptionApplied(SubscriptionEventContext ctx) { Debug.Log("Subscription applied!"); OnSubscriptionApplied?.Invoke(); @@ -673,7 +667,7 @@ public abstract class EntityController : MonoBehaviour protected float LerpTime; protected Vector3 LerpStartPosition; - protected Vector3 LerpTargetPositio; + protected Vector3 LerpTargetPosition; protected Vector3 TargetScale; protected virtual void Spawn(uint entityId) @@ -681,7 +675,7 @@ public abstract class EntityController : MonoBehaviour EntityId = entityId; var entity = GameManager.Conn.Db.Entity.EntityId.Find(entityId); - LerpStartPosition = LerpTargetPositio = transform.position = (Vector2)entity.Position; + LerpStartPosition = LerpTargetPosition = transform.position = (Vector2)entity.Position; transform.localScale = Vector3.one; TargetScale = MassToScale(entity.Mass); } @@ -695,7 +689,7 @@ public abstract class EntityController : MonoBehaviour { LerpTime = 0.0f; LerpStartPosition = transform.position; - LerpTargetPositio = (Vector2)newVal.Position; + LerpTargetPosition = (Vector2)newVal.Position; TargetScale = MassToScale(newVal.Mass); } @@ -708,7 +702,7 @@ public abstract class EntityController : MonoBehaviour { // Interpolate position and scale LerpTime = Mathf.Min(LerpTime + Time.deltaTime, LERP_DURATION_SEC); - transform.position = Vector3.Lerp(LerpStartPosition, LerpTargetPositio, LerpTime / LERP_DURATION_SEC); + transform.position = Vector3.Lerp(LerpStartPosition, LerpTargetPosition, LerpTime / LERP_DURATION_SEC); transform.localScale = Vector3.Lerp(transform.localScale, TargetScale, Time.deltaTime * 8); } @@ -1035,7 +1029,7 @@ Next lets add some callbacks when rows change in the database. Modify the `Handl // Request all tables Conn.SubscriptionBuilder() .OnApplied(HandleSubscriptionApplied) - .Subscribe("SELECT * FROM *"); + .SubscribeToAllTables(); } ``` @@ -1184,11 +1178,10 @@ At this point, you may need to regenerate your bindings the following command fr spacetime generate --lang csharp --out-dir ../client-unity/Assets/autogen ``` -> **BUG WORKAROUND NOTE**: As of `1.0.0-rc3` you will now have a compilation error in Unity. There is currently a bug in the C# code generation that requires you to delete `autogen/LoggedOutPlayer.cs` after running this command. The last step is to call the `enter_game` reducer on the server, passing in a username for our player, which will spawn a circle for our player. For the sake of simplicity, let's call the `enter_game` reducer from the `HandleSubscriptionApplied` callback with the name "3Blave". ```cs - private void HandleSubscriptionApplied(EventContext ctx) + private void HandleSubscriptionApplied(SubscriptionEventContext ctx) { Debug.Log("Subscription applied!"); OnSubscriptionApplied?.Invoke(); diff --git a/docs/unity/part-4.md b/docs/unity/part-4.md index 78c9a3cd..e2b58dd5 100644 --- a/docs/unity/part-4.md +++ b/docs/unity/part-4.md @@ -198,7 +198,7 @@ Next, add the following reducer to the `Module` class of your `Lib.cs` file. [Reducer] public static void UpdatePlayerInput(ReducerContext ctx, DbVector2 direction) { - var player = ctx.Db.player.identity.Find(ctx.CallerIdentity) ?? throw new Exception("Player not found"); + var player = ctx.Db.player.identity.Find(ctx.Sender) ?? throw new Exception("Player not found"); foreach (var c in ctx.Db.circle.player_id.Filter(player.player_id)) { var circle = c; @@ -206,11 +206,10 @@ public static void UpdatePlayerInput(ReducerContext ctx, DbVector2 direction) circle.speed = Math.Clamp(direction.Magnitude, 0f, 1f); ctx.Db.circle.entity_id.Update(circle); } - } ``` -This is a simple reducer that takes the movement input from the client and applies them to all circles that that player controls. Note that it is not possible for a player to move another player's circles using this reducer, because the `ctx.CallerIdentity` value is not set by the client. Instead `ctx.CallerIdentity` is set by SpacetimeDB after it has authenticated that sender. You can rest assured that the caller has been authenticated as that player by the time this reducer is called. +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. @@ -243,7 +242,12 @@ pub fn move_all_players(ctx: &ReducerContext, _timer: MoveAllPlayersTimer) -> Re // Handle player input for circle in ctx.db.circle().iter() { - let mut circle_entity = ctx.db.entity().entity_id().find(&circle.entity_id).unwrap(); + let circle_entity = ctx.db.entity().entity_id().find(&circle.entity_id); + if !circle_entity.is_some() { + // This can happen if a circle is eaten by another circle + continue; + } + let mut circle_entity = circle_entity.unwrap(); let circle_radius = mass_to_radius(circle_entity.mass); let direction = circle.direction * circle.speed; let new_pos = @@ -283,7 +287,13 @@ public static void MoveAllPlayers(ReducerContext ctx, MoveAllPlayersTimer timer) // Handle player input foreach (var circle in ctx.Db.circle.Iter()) { - var circle_entity = ctx.Db.entity.entity_id.Find(circle.entity_id) ?? throw new Exception("Circle has no entity"); + var check_entity = ctx.Db.entity.entity_id.Find(circle.entity_id); + if (check_entity == null) + { + // This can happen if the circle has been eaten by another circle. + continue; + } + var circle_entity = check_entity.Value;; var circle_radius = MassToRadius(circle_entity.mass); var direction = circle_directions[circle.entity_id]; var new_pos = circle_entity.position + direction * MassToMaxMoveSpeed(circle_entity.mass); @@ -335,50 +345,48 @@ Regenerate your server bindings with: spacetime generate --lang csharp --out-dir ../client-unity/Assets/autogen ``` -> **BUG WORKAROUND NOTE**: You may have to delete LoggedOutPlayer.cs again. - ### Moving on the Client All that's left is to modify our `PlayerController` on the client to call the `update_player_input` reducer. Open `PlayerController.cs` and add an `Update` function: ```cs - public void Update() +public void Update() +{ + if (!IsLocalPlayer || NumberOfOwnedCircles == 0) + { + return; + } + + if (Input.GetKeyDown(KeyCode.Q)) { - if (!IsLocalPlayer || NumberOfOwnedCircles == 0) + if (LockInputPosition.HasValue) { - return; + LockInputPosition = null; } - - if (Input.GetKeyDown(KeyCode.Q)) + else { - if (LockInputPosition.HasValue) - { - LockInputPosition = null; - } - else - { - LockInputPosition = (Vector2)Input.mousePosition; - } + LockInputPosition = (Vector2)Input.mousePosition; } + } - // Throttled input requests - if (Time.time - LastMovementSendTimestamp >= SEND_UPDATES_FREQUENCY) - { - LastMovementSendTimestamp = Time.time; + // Throttled input requests + if (Time.time - LastMovementSendTimestamp >= SEND_UPDATES_FREQUENCY) + { + LastMovementSendTimestamp = Time.time; - var mousePosition = LockInputPosition ?? (Vector2)Input.mousePosition; - var screenSize = new Vector2 - { - x = Screen.width, - y = Screen.height, - }; - var centerOfScreen = screenSize / 2; - - var direction = (mousePosition - centerOfScreen) / (screenSize.y / 3); - if (testInputEnabled) { direction = testInput; } - GameManager.Conn.Reducers.UpdatePlayerInput(direction); - } - } + var mousePosition = LockInputPosition ?? (Vector2)Input.mousePosition; + var screenSize = new Vector2 + { + x = Screen.width, + y = Screen.height, + }; + var centerOfScreen = screenSize / 2; + + var direction = (mousePosition - centerOfScreen) / (screenSize.y / 3); + if (testInputEnabled) { direction = testInput; } + GameManager.Conn.Reducers.UpdatePlayerInput(direction); + } +} ``` Let's try it out! Press play and roam freely around the arena! Now we're cooking with gas. @@ -423,7 +431,12 @@ pub fn move_all_players(ctx: &ReducerContext, _timer: MoveAllPlayersTimer) -> Re // Handle player input for circle in ctx.db.circle().iter() { - let mut circle_entity = ctx.db.entity().entity_id().find(&circle.entity_id).unwrap(); + let circle_entity = ctx.db.entity().entity_id().find(&circle.entity_id); + if !circle_entity.is_some() { + // This can happen if a circle is eaten by another circle + continue; + } + let mut circle_entity = circle_entity.unwrap(); let circle_radius = mass_to_radius(circle_entity.mass); let direction = circle.direction * circle.speed; let new_pos = @@ -500,7 +513,13 @@ public static void MoveAllPlayers(ReducerContext ctx, MoveAllPlayersTimer timer) // Handle player input foreach (var circle in ctx.Db.circle.Iter()) { - var circle_entity = ctx.Db.entity.entity_id.Find(circle.entity_id) ?? throw new Exception("Circle has no entity"); + var check_entity = ctx.Db.entity.entity_id.Find(circle.entity_id); + if (check_entity == null) + { + // This can happen if the circle has been eaten by another circle. + continue; + } + var circle_entity = check_entity.Value;; var circle_radius = MassToRadius(circle_entity.mass); var direction = circle.direction * circle.speed; var new_pos = circle_entity.position + direction * MassToMaxMoveSpeed(circle_entity.mass); diff --git a/docs/ws/index.md b/docs/ws/index.md deleted file mode 100644 index 1a3780cc..00000000 --- a/docs/ws/index.md +++ /dev/null @@ -1,318 +0,0 @@ -# The SpacetimeDB WebSocket API - -As an extension of the [HTTP API](/docs/http), SpacetimeDB offers a WebSocket API. Clients can subscribe to a database via a WebSocket connection to receive streaming updates as the database changes, and send requests to invoke reducers. Messages received from the server over a WebSocket will follow the same total ordering of transactions as are committed to the database. - -The SpacetimeDB SDKs comminicate with their corresponding database using the WebSocket API. - -## Connecting - -To initiate a WebSocket connection, send a `GET` request to the [`/database/subscribe/:name_or_address` endpoint](/docs/http/database#databasesubscribename_or_address-get) with headers appropriate to upgrade to a WebSocket connection as per [RFC 6455](https://datatracker.ietf.org/doc/html/rfc6455). - -To re-connect with an existing identity, include its token in a [SpacetimeDB Authorization header](/docs/http). Otherwise, a new identity and token will be generated for the client. - -## Protocols - -Clients connecting via WebSocket can choose between two protocols, [`v1.bin.spacetimedb`](#binary-protocol) and [`v1.text.spacetimedb`](#text-protocol). Clients should include one of these protocols in the `Sec-WebSocket-Protocol` header of their request. - -| `Sec-WebSocket-Protocol` header value | Selected protocol | -| ------------------------------------- | -------------------------- | -| `v1.bin.spacetimedb` | [Binary](#binary-protocol) | -| `v1.text.spacetimedb` | [Text](#text-protocol) | - -### Binary Protocol - -The SpacetimeDB binary WebSocket protocol, `v1.bin.spacetimedb`, encodes messages using [ProtoBuf 3](https://protobuf.dev), and reducer and row data using [BSATN](/docs/bsatn). - -The binary protocol's messages are defined in [`client_api.proto`](https://github.com/clockworklabs/SpacetimeDB/blob/master/crates/client-api-messages/protobuf/client_api.proto). - -### Text Protocol - -The SpacetimeDB text WebSocket protocol, `v1.text.spacetimedb`, encodes messages, reducer and row data as JSON. Reducer arguments and table rows are JSON-encoded according to the [SATN JSON format](/docs/satn). - -## Messages - -### Client to server - -| Message | Description | -| ------------------------------- | --------------------------------------------------------------------------- | -| [`FunctionCall`](#functioncall) | Invoke a reducer. | -| [`Subscribe`](#subscribe) | Register queries to receive streaming updates for a subset of the database. | - -#### `FunctionCall` - -Clients send a `FunctionCall` message to request that the database run a reducer. The message includes the reducer's name and a SATS `ProductValue` of arguments. - -##### Binary: ProtoBuf definition - -```protobuf -message FunctionCall { - string reducer = 1; - bytes argBytes = 2; -} -``` - -| Field | Value | -| ---------- | -------------------------------------------------------- | -| `reducer` | The name of the reducer to invoke. | -| `argBytes` | The reducer arguments encoded as a BSATN `ProductValue`. | - -##### Text: JSON encoding - -```typescript -{ - "call": { - "fn": string, - "args": array, - } -} -``` - -| Field | Value | -| ------ | ---------------------------------------------- | -| `fn` | The name of the reducer to invoke. | -| `args` | The reducer arguments encoded as a JSON array. | - -#### `Subscribe` - -Clients send a `Subscribe` message to register SQL queries in order to receive streaming updates. - -The client will only receive [`TransactionUpdate`s](#transactionupdate) for rows to which it is subscribed, and for reducer runs which alter at least one subscribed row. As a special exception, the client is always notified when a reducer run it requests via a [`FunctionCall` message](#functioncall) fails. - -SpacetimeDB responds to each `Subscribe` message with a [`SubscriptionUpdate` message](#subscriptionupdate) containing all matching rows at the time the subscription is applied. - -Each `Subscribe` message establishes a new set of subscriptions, replacing all previous subscriptions. Clients which want to add a query to an existing subscription must send a `Subscribe` message containing all the previous queries in addition to the new query. In this case, the returned [`SubscriptionUpdate`](#subscriptionupdate) will contain all previously-subscribed rows in addition to the newly-subscribed rows. - -Each query must be a SQL `SELECT * FROM` statement on a single table with an optional `WHERE` clause. See the [SQL Reference](/docs/sql) for the subset of SQL supported by SpacetimeDB. - -##### Binary: ProtoBuf definition - -```protobuf -message Subscribe { - repeated string query_strings = 1; -} -``` - -| Field | Value | -| --------------- | ----------------------------------------------------------------- | -| `query_strings` | A sequence of strings, each of which contains a single SQL query. | - -##### Text: JSON encoding - -```typescript -{ - "subscribe": { - "query_strings": array - } -} -``` - -| Field | Value | -| --------------- | --------------------------------------------------------------- | -| `query_strings` | An array of strings, each of which contains a single SQL query. | - -### Server to client - -| Message | Description | -| ------------------------------------------- | -------------------------------------------------------------------------- | -| [`IdentityToken`](#identitytoken) | Sent once upon successful connection with the client's identity and token. | -| [`SubscriptionUpdate`](#subscriptionupdate) | Initial message in response to a [`Subscribe` message](#subscribe). | -| [`TransactionUpdate`](#transactionupdate) | Streaming update after a reducer runs containing altered rows. | - -#### `IdentityToken` - -Upon establishing a WebSocket connection, the server will send an `IdentityToken` message containing the client's identity and token. If the client included a [SpacetimeDB Authorization header](/docs/http) in their connection request, the `IdentityToken` message will contain the same token used to connect, and its corresponding identity. If the client connected anonymously, SpacetimeDB will generate a new identity and token for the client. - -##### Binary: ProtoBuf definition - -```protobuf -message IdentityToken { - bytes identity = 1; - string token = 2; -} -``` - -| Field | Value | -| ---------- | --------------------------------------- | -| `identity` | The client's public Spacetime identity. | -| `token` | The client's private access token. | - -##### Text: JSON encoding - -```typescript -{ - "IdentityToken": { - "identity": array, - "token": string - } -} -``` - -| Field | Value | -| ---------- | --------------------------------------- | -| `identity` | The client's public Spacetime identity. | -| `token` | The client's private access token. | - -#### `SubscriptionUpdate` - -In response to a [`Subscribe` message](#subscribe), the database sends a `SubscriptionUpdate` containing all of the matching rows which are resident in the database at the time the `Subscribe` was received. - -##### Binary: ProtoBuf definition - -```protobuf -message SubscriptionUpdate { - repeated TableUpdate tableUpdates = 1; -} - -message TableUpdate { - uint32 tableId = 1; - string tableName = 2; - repeated TableRowOperation tableRowOperations = 3; -} - -message TableRowOperation { - enum OperationType { - DELETE = 0; - INSERT = 1; - } - OperationType op = 1; - bytes row = 3; -} -``` - -Each `SubscriptionUpdate` contains a `TableUpdate` for each table with subscribed rows. Each `TableUpdate` contains a `TableRowOperation` for each subscribed row. `SubscriptionUpdate`, `TableUpdate` and `TableRowOperation` are also used by the [`TransactionUpdate` message](#transactionupdate) to encode rows altered by a reducer, so `TableRowOperation` includes an `OperationType` which identifies the row alteration as either an insert or a delete. When a client receives a `SubscriptionUpdate` message in response to a [`Subscribe` message](#subscribe), all of the `TableRowOperation`s will have `op` of `INSERT`. - -| `TableUpdate` field | Value | -| -------------------- | ------------------------------------------------------------------------------------------------------------- | -| `tableId` | An integer identifier for the table. A table's `tableId` is not stable, so clients should not depend on it. | -| `tableName` | The string name of the table. Clients should use this field to identify the table, rather than the `tableId`. | -| `tableRowOperations` | A `TableRowOperation` for each inserted or deleted row. | - -| `TableRowOperation` field | Value | -| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `op` | `INSERT` for inserted rows during a [`TransactionUpdate`](#transactionupdate) or rows resident upon applying a subscription; `DELETE` for deleted rows during a [`TransactionUpdate`](#transactionupdate). | -| `row` | The altered row, encoded as a BSATN `ProductValue`. | - -##### Text: JSON encoding - -```typescript -// SubscriptionUpdate: -{ - "SubscriptionUpdate": { - "table_updates": array - } -} - -// TableUpdate: -{ - "table_id": number, - "table_name": string, - "table_row_operations": array -} - -// TableRowOperation: -{ - "op": "insert" | "delete", - "row": array -} -``` - -Each `SubscriptionUpdate` contains a `TableUpdate` for each table with subscribed rows. Each `TableUpdate` contains a `TableRowOperation` for each subscribed row. `SubscriptionUpdate`, `TableUpdate` and `TableRowOperation` are also used by the [`TransactionUpdate` message](#transactionupdate) to encode rows altered by a reducer, so `TableRowOperation` includes an `"op"` field which identifies the row alteration as either an insert or a delete. When a client receives a `SubscriptionUpdate` message in response to a [`Subscribe` message](#subscribe), all of the `TableRowOperation`s will have `"op"` of `"insert"`. - -| `TableUpdate` field | Value | -| ---------------------- | -------------------------------------------------------------------------------------------------------------- | -| `table_id` | An integer identifier for the table. A table's `table_id` is not stable, so clients should not depend on it. | -| `table_name` | The string name of the table. Clients should use this field to identify the table, rather than the `table_id`. | -| `table_row_operations` | A `TableRowOperation` for each inserted or deleted row. | - -| `TableRowOperation` field | Value | -| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `op` | `"insert"` for inserted rows during a [`TransactionUpdate`](#transactionupdate) or rows resident upon applying a subscription; `"delete"` for deleted rows during a [`TransactionUpdate`](#transactionupdate). | -| `row` | The altered row, encoded as a JSON array. | - -#### `TransactionUpdate` - -Upon a reducer run, a client will receive a `TransactionUpdate` containing information about the reducer which ran and the subscribed rows which it altered. Clients will only receive a `TransactionUpdate` for a reducer invocation if either of two criteria is met: - -1. The reducer ran successfully and altered at least one row to which the client subscribes. -2. The reducer was invoked by the client, and either failed or was terminated due to insufficient energy. - -Each `TransactionUpdate` contains a [`SubscriptionUpdate`](#subscriptionupdate) with all rows altered by the reducer, including inserts and deletes; and an `Event` with information about the reducer itself, including a [`FunctionCall`](#functioncall) containing the reducer's name and arguments. - -##### Binary: ProtoBuf definition - -```protobuf -message TransactionUpdate { - Event event = 1; - SubscriptionUpdate subscriptionUpdate = 2; -} - -message Event { - enum Status { - committed = 0; - failed = 1; - out_of_energy = 2; - } - uint64 timestamp = 1; - bytes callerIdentity = 2; - FunctionCall functionCall = 3; - Status status = 4; - string message = 5; - int64 energy_quanta_used = 6; - uint64 host_execution_duration_micros = 7; -} -``` - -| Field | Value | -| -------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `event` | An `Event` containing information about the reducer run. | -| `subscriptionUpdate` | A [`SubscriptionUpdate`](#subscriptionupdate) containing all the row insertions and deletions committed by the transaction. | - -| `Event` field | Value | -| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `timestamp` | The time when the reducer started, as microseconds since the Unix epoch. | -| `callerIdentity` | The identity of the client which requested the reducer invocation. For event-driven and scheduled reducers, this is the identity of the database owner. | -| `functionCall` | A [`FunctionCall`](#functioncall) containing the name of the reducer and the arguments passed to it. | -| `status` | `committed` if the reducer ran successfully and its changes were committed to the database; `failed` if the reducer signaled an error; `out_of_energy` if the reducer was canceled due to insufficient energy. | -| `message` | The error message with which the reducer failed if `status` is `failed`, or the empty string otherwise. | -| `energy_quanta_used` | The amount of energy consumed by running the reducer. | -| `host_execution_duration_micros` | The duration of the reducer's execution, in microseconds. | - -##### Text: JSON encoding - -```typescript -// TransactionUpdate: -{ - "TransactionUpdate": { - "event": Event, - "subscription_update": SubscriptionUpdate - } -} - -// Event: -{ - "timestamp": number, - "status": "committed" | "failed" | "out_of_energy", - "caller_identity": string, - "function_call": { - "reducer": string, - "args": array, - }, - "energy_quanta_used": number, - "message": string -} -``` - -| Field | Value | -| --------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `event` | An `Event` containing information about the reducer run. | -| `subscription_update` | A [`SubscriptionUpdate`](#subscriptionupdate) containing all the row insertions and deletions committed by the transaction. | - -| `Event` field | Value | -| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `timestamp` | The time when the reducer started, as microseconds since the Unix epoch. | -| `status` | `committed` if the reducer ran successfully and its changes were committed to the database; `failed` if the reducer signaled an error; `out_of_energy` if the reducer was canceled due to insufficient energy. | -| `caller_identity` | The identity of the client which requested the reducer invocation. For event-driven and scheduled reducers, this is the identity of the database owner. | -| `function_call.reducer` | The name of the reducer. | -| `function_call.args` | The reducer arguments encoded as a JSON array. | -| `energy_quanta_used` | The amount of energy consumed by running the reducer. | -| `message` | The error message with which the reducer failed if `status` is `failed`, or the empty string otherwise. | diff --git a/nav.ts b/nav.ts index 609a7f01..4de5dae3 100644 --- a/nav.ts +++ b/nav.ts @@ -34,10 +34,7 @@ const nav: Nav = { page('Getting Started', 'getting-started', 'getting-started.md'), section('Deploying'), - page('Testnet', 'deploying/testnet', 'deploying/testnet.md'), - - section('Migration Guides'), - page('v0.12', 'migration/v0.12', 'migration/v0.12.md'), + page('Maincloud', 'deploying/maincloud', 'deploying/maincloud.md'), section('Unity Tutorial - Basic Multiplayer'), page('Overview', 'unity', 'unity/index.md'), @@ -46,6 +43,14 @@ const nav: Nav = { page('3 - Gameplay', 'unity/part-3', 'unity/part-3.md'), page('4 - Moving and Colliding', 'unity/part-4', 'unity/part-4.md'), + section('CLI Reference'), + page('CLI Reference', 'cli-reference', 'cli-reference.md'), + page( + 'SpacetimeDB Standalone Configuration', + 'cli-reference/standalone-config', + 'cli-reference/standalone-config.md' + ), + section('Server Module Languages'), page('Overview', 'modules', 'modules/index.md'), page( @@ -63,39 +68,42 @@ const nav: Nav = { section('Client SDK Languages'), page('Overview', 'sdks', 'sdks/index.md'), - page( - 'Typescript Quickstart', - 'sdks/typescript/quickstart', - 'sdks/typescript/quickstart.md' - ), - page('Typescript Reference', 'sdks/typescript', 'sdks/typescript/index.md'), - page('Rust Quickstart', 'sdks/rust/quickstart', 'sdks/rust/quickstart.md'), - page('Rust Reference', 'sdks/rust', 'sdks/rust/index.md'), page( 'C# Quickstart', 'sdks/c-sharp/quickstart', 'sdks/c-sharp/quickstart.md' ), page('C# Reference', 'sdks/c-sharp', 'sdks/c-sharp/index.md'), + page('Rust Quickstart', 'sdks/rust/quickstart', 'sdks/rust/quickstart.md'), + page('Rust Reference', 'sdks/rust', 'sdks/rust/index.md'), + page( + 'TypeScript Quickstart', + 'sdks/typescript/quickstart', + 'sdks/typescript/quickstart.md' + ), + page('TypeScript Reference', 'sdks/typescript', 'sdks/typescript/index.md'), - section('WebAssembly ABI'), - page('Module ABI Reference', 'webassembly-abi', 'webassembly-abi/index.md'), + section('SQL'), + page('SQL Reference', 'sql', 'sql/index.md'), + + section('Subscriptions'), + page('Subscription Reference', 'subscriptions', 'subscriptions/index.md'), + + section('How To'), + page('Incremental Migrations', 'how-to/incremental-migrations', 'how-to/incremental-migrations.md'), section('HTTP API'), page('HTTP', 'http', 'http/index.md'), page('`/identity`', 'http/identity', 'http/identity.md'), page('`/database`', 'http/database', 'http/database.md'), - page('`/energy`', 'http/energy', 'http/energy.md'), - - section('WebSocket API Reference'), - page('WebSocket', 'ws', 'ws/index.md'), - section('Data Format'), - page('SATN', 'satn', 'satn.md'), - page('BSATN', 'bsatn', 'bsatn.md'), + section('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('SQL'), - page('SQL Reference', 'sql', 'sql/index.md'), + section('Appendix'), + page('Appendix', 'appendix', 'appendix.md'), ], }; diff --git a/scripts/checkLinks.ts b/scripts/checkLinks.ts index 58c94f47..944f67d2 100644 --- a/scripts/checkLinks.ts +++ b/scripts/checkLinks.ts @@ -124,7 +124,7 @@ function checkLinks(): void { return; // Skip external links } - const siteLinks = ['/install', '/images']; + const siteLinks = ['/install', '/images', '/profile']; for (const siteLink of siteLinks) { if (link.startsWith(siteLink)) { return; // Skip site links