Merge remote-tracking branch 'origin/master' into jdetter/vm-cache

This commit is contained in:
John Detter
2026-05-05 02:01:38 -05:00
29 changed files with 382 additions and 126 deletions
+5 -1
View File
@@ -993,6 +993,10 @@ jobs:
const publicRef = (context.eventName === 'pull_request') ? context.payload.pull_request.head.ref : context.sha;
const publicPrNumber = context.payload.pull_request?.number ?? context.payload.inputs?.pr_number;
const preDispatch = new Date().toISOString();
const inputs = { public_ref: publicRef };
if (publicPrNumber) {
inputs.public_pr_number = String(publicPrNumber);
}
// Dispatch the workflow in the target repository
await github.rest.actions.createWorkflowDispatch({
@@ -1000,7 +1004,7 @@ jobs:
repo: targetRepo,
workflow_id: workflowId,
ref: targetRef,
inputs: { public_ref: publicRef, public_pr_number: String(publicPrNumber) }
inputs,
});
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
@@ -97,17 +97,22 @@ public:
*
* Example:
* @code
* auto module_id = ctx.identity();
* auto module_id = ctx.database_identity();
* std::string url = "http://localhost:3000/v1/database/" +
* module_id.to_hex() + "/schema?version=9";
* @endcode
*/
Identity identity() const {
Identity database_identity() const {
std::array<uint8_t, 32> id_bytes;
::identity(id_bytes.data());
return Identity(id_bytes);
}
[[deprecated("Use database_identity() instead.")]]
Identity identity() const {
return database_identity();
}
/**
* @brief Get the random number generator for this procedure call
*
@@ -58,11 +58,16 @@ public:
return *rng_instance;
}
Identity identity() const {
Identity database_identity() const {
std::array<uint8_t, 32> buffer;
::identity(buffer.data());
return Identity(buffer);
}
[[deprecated("Use database_identity() instead.")]]
Identity identity() const {
return database_identity();
}
/**
* Generate a new random UUID v4.
@@ -69,7 +69,9 @@ public:
// Access to ReducerContext methods
Identity sender() const { return ctx_.sender(); }
const AuthCtx& sender_auth() const { return ctx_.sender_auth(); }
Identity identity() const { return ctx_.identity(); }
Identity database_identity() const { return ctx_.database_identity(); }
[[deprecated("Use database_identity() instead.")]]
Identity identity() const { return database_identity(); }
StdbRng& rng() const { return ctx_.rng(); }
/**
@@ -655,9 +655,13 @@ namespace SpacetimeDB
// **Note:** must be 0..=u32::MAX
internal int CounterUuid;
public Identity DatabaseIdentity => Internal.IReducerContext.GetDatabaseIdentity();
// We need this property to be non-static for parity with client SDK.
public Identity Identity => Internal.IReducerContext.GetIdentity();
// We keep this property for compatibility with existing module code.
[global::System.Obsolete(
"ReducerContext.Identity is deprecated. Use DatabaseIdentity instead."
)]
public Identity Identity => DatabaseIdentity;
internal ReducerContext(
Identity identity,
@@ -57,9 +57,13 @@ namespace SpacetimeDB
// **Note:** must be 0..=u32::MAX
internal int CounterUuid;
public Identity DatabaseIdentity => Internal.IReducerContext.GetDatabaseIdentity();
// We need this property to be non-static for parity with client SDK.
public Identity Identity => Internal.IReducerContext.GetIdentity();
// We keep this property for compatibility with existing module code.
[global::System.Obsolete(
"ReducerContext.Identity is deprecated. Use DatabaseIdentity instead."
)]
public Identity Identity => DatabaseIdentity;
internal ReducerContext(
Identity identity,
@@ -499,9 +499,13 @@ namespace SpacetimeDB
// **Note:** must be 0..=u32::MAX
internal int CounterUuid;
public Identity DatabaseIdentity => Internal.IReducerContext.GetDatabaseIdentity();
// We need this property to be non-static for parity with client SDK.
public Identity Identity => Internal.IReducerContext.GetIdentity();
// We keep this property for compatibility with existing module code.
[global::System.Obsolete(
"ReducerContext.Identity is deprecated. Use DatabaseIdentity instead."
)]
public Identity Identity => DatabaseIdentity;
internal ReducerContext(
Identity identity,
+4 -2
View File
@@ -2189,8 +2189,10 @@ public class Module : IIncrementalGenerator
public readonly AuthCtx SenderAuth;
// **Note:** must be 0..=u32::MAX
internal int CounterUuid;
// We need this property to be non-static for parity with client SDK.
public Identity Identity => Internal.IReducerContext.GetIdentity();
public Identity DatabaseIdentity => Internal.IReducerContext.GetDatabaseIdentity();
// We keep this property for compatibility with existing module code.
[global::System.Obsolete("ReducerContext.Identity is deprecated. Use DatabaseIdentity instead.")]
public Identity Identity => DatabaseIdentity;
internal ReducerContext(Identity identity, ConnectionId? connectionId, Random random,
Timestamp time, AuthCtx? senderAuth = null)
@@ -1,15 +1,19 @@
namespace SpacetimeDB.Internal;
using System;
using System.Text;
using SpacetimeDB.BSATN;
public interface IReducerContext
{
public static Identity GetIdentity()
public static Identity GetDatabaseIdentity()
{
FFI.identity(out var identity);
return identity;
}
[Obsolete("IReducerContext.GetIdentity() is deprecated. Use GetDatabaseIdentity() instead.")]
public static Identity GetIdentity() => GetDatabaseIdentity();
}
public interface IReducer
@@ -103,6 +103,8 @@ export interface JwtClaims {
*/
export type ReducerCtx<SchemaDef extends UntypedSchemaDef> = Readonly<{
sender: Identity;
databaseIdentity: Identity;
/** @deprecated Use `databaseIdentity` instead. */
identity: Identity;
timestamp: Timestamp;
connectionId: ConnectionId | null;
@@ -75,6 +75,8 @@ export interface ProcedureOpts {
export interface ProcedureCtx<S extends UntypedSchemaDef> {
readonly sender: Identity;
readonly databaseIdentity: Identity;
/** @deprecated Use `databaseIdentity` instead. */
readonly identity: Identity;
readonly timestamp: Timestamp;
readonly connectionId: ConnectionId | null;
@@ -195,10 +197,14 @@ const ProcedureCtxImpl = class ProcedureCtx<S extends UntypedSchemaDef>
this.#dbView = dbView;
}
get identity() {
get databaseIdentity() {
return (this.#identity ??= new Identity(sys.identity()));
}
get identity() {
return this.databaseIdentity;
}
get random() {
return (this.#random ??= makeRandom(this.timestamp));
}
@@ -221,10 +221,14 @@ export const ReducerCtxImpl = class ReducerCtx<
me.#senderAuth = undefined;
}
get identity() {
get databaseIdentity() {
return (this.#identity ??= new Identity(sys.identity()));
}
get identity() {
return this.databaseIdentity;
}
get senderAuth() {
return (this.#senderAuth ??= AuthCtxImpl.fromSystemTables(
this.connectionId,
+41 -4
View File
@@ -687,7 +687,7 @@ pub use spacetimedb_bindings_macro::table;
///
/// #[reducer]
/// fn scheduled(ctx: &ReducerContext, args: ScheduledArgs) -> Result<(), String> {
/// if ctx.sender() != ctx.identity() {
/// if ctx.sender() != ctx.database_identity() {
/// return Err("Reducer `scheduled` may not be invoked by clients, only via scheduling.".into());
/// }
/// // Reducer body...
@@ -1081,7 +1081,7 @@ impl ReducerContext {
}
/// Read the current module's [`Identity`].
pub fn identity(&self) -> Identity {
pub fn database_identity(&self) -> Identity {
// Hypothetically, we *could* read the module identity out of the system tables.
// However, this would be:
// - Onerous, because we have no tooling to inspect the system tables from module code.
@@ -1093,6 +1093,12 @@ impl ReducerContext {
Identity::from_byte_array(spacetimedb_bindings_sys::identity())
}
/// Read the current module's [`Identity`].
#[deprecated(note = "Use `ReducerContext::database_identity` instead.")]
pub fn identity(&self) -> Identity {
self.database_identity()
}
/// Create an anonymous (no sender) read-only view context
pub fn as_anonymous_read_only(&self) -> AnonymousViewContext {
AnonymousViewContext::default()
@@ -1459,9 +1465,14 @@ pub trait DbContext {
///
/// This method is provided for times when a programmer wants to be generic over the `DbContext` type.
/// Concrete-typed code is expected to read the `.db` field off the particular `DbContext` implementor.
/// Currently, being this generic is only meaningful in clients,
/// as `ReducerContext` is the only implementor of `DbContext` within modules.
fn db(&self) -> &Self::DbView;
/// Get a read-only view into the tables.
///
/// This method is provided for times when a programmer wants to be generic over the `DbContext` type.
/// Concrete-typed code is expected to read the `.db` field off the particular `DbContext` implementor.
#[cfg(feature = "unstable")]
fn db_read_only(&self) -> &LocalReadOnly;
}
impl DbContext for AnonymousViewContext {
@@ -1470,6 +1481,11 @@ impl DbContext for AnonymousViewContext {
fn db(&self) -> &Self::DbView {
&self.db
}
#[cfg(feature = "unstable")]
fn db_read_only(&self) -> &LocalReadOnly {
&self.db
}
}
impl DbContext for ReducerContext {
@@ -1478,6 +1494,11 @@ impl DbContext for ReducerContext {
fn db(&self) -> &Self::DbView {
&self.db
}
#[cfg(feature = "unstable")]
fn db_read_only(&self) -> &LocalReadOnly {
self.db.get_read_only()
}
}
#[cfg(feature = "unstable")]
@@ -1487,6 +1508,10 @@ impl DbContext for TxContext {
fn db(&self) -> &Self::DbView {
&self.db
}
fn db_read_only(&self) -> &LocalReadOnly {
self.db.get_read_only()
}
}
impl DbContext for ViewContext {
@@ -1495,6 +1520,11 @@ impl DbContext for ViewContext {
fn db(&self) -> &Self::DbView {
&self.db
}
#[cfg(feature = "unstable")]
fn db_read_only(&self) -> &LocalReadOnly {
&self.db
}
}
// `ProcedureContext` is *not* a `DbContext`
@@ -1508,6 +1538,13 @@ impl DbContext for ViewContext {
#[non_exhaustive]
pub struct Local {}
impl Local {
#[cfg(feature = "unstable")]
fn get_read_only(&self) -> &LocalReadOnly {
&LocalReadOnly {}
}
}
/// The [JWT] of an [`AuthCtx`].
///
/// [JWT]: https://en.wikipedia.org/wiki/JSON_Web_Token
+89 -2
View File
@@ -28,10 +28,97 @@ impl Uninstall {
Ok(None) => {}
Err(e) => tracing::warn!("{e:#}"),
}
let dir = paths.cli_bin_dir.version_dir(&version);
if !dir.0.exists() {
anyhow::bail!("v{version} is not installed");
}
if yes.confirm(format!("Uninstall v{version}?"))? {
let dir = paths.cli_bin_dir.version_dir(&version);
std::fs::remove_dir_all(dir)?;
std::fs::remove_dir_all(&dir)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use spacetimedb_paths::FromPathUnchecked;
use spacetimedb_paths::RootDir;
fn make_temp_paths() -> (tempfile::TempDir, SpacetimePaths) {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path().join("spacetime");
std::fs::create_dir_all(&base).unwrap();
let root = RootDir::from_path_unchecked(base);
let paths = SpacetimePaths::from_root_dir(&root);
(tmp, paths)
}
#[test]
fn test_uninstall_nonexistent_version_errors_before_prompt() {
let (_tmp, paths) = make_temp_paths();
let uninstall = Uninstall {
version: "9.9.9".to_owned(),
yes: ForceYes { yes: true },
};
let result = uninstall.exec(&paths);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.to_string().contains("9.9.9"),
"error should mention the version number"
);
assert!(
err.to_string().contains("not installed"),
"error should say 'not installed'"
);
}
#[test]
fn test_uninstall_current_version_errors() {
let (_tmp, paths) = make_temp_paths();
// Create the "current" symlink target so it exists on disk
let current_dir = paths.cli_bin_dir.version_dir("2.0.0");
std::fs::create_dir_all(&current_dir.0).unwrap();
paths.cli_bin_dir.set_current_version("2.0.0").unwrap();
let uninstall = Uninstall {
version: "2.0.0".to_owned(),
yes: ForceYes { yes: true },
};
let result = uninstall.exec(&paths);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("currently used version"),);
}
#[test]
fn test_uninstall_current_keyword_errors() {
let (_tmp, paths) = make_temp_paths();
let uninstall = Uninstall {
version: "current".to_owned(),
yes: ForceYes { yes: true },
};
let result = uninstall.exec(&paths);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("cannot remove `current`"),);
}
#[test]
fn test_uninstall_existing_version_with_yes() {
let (_tmp, paths) = make_temp_paths();
let version_dir = paths.cli_bin_dir.version_dir("1.0.0");
std::fs::create_dir_all(&version_dir.0).unwrap();
// Create a dummy file so we can verify the directory existed
std::fs::write(version_dir.0.join("spacetime"), "dummy").unwrap();
assert!(version_dir.0.exists(), "version dir should exist before");
let uninstall = Uninstall {
version: "1.0.0".to_owned(),
yes: ForceYes { yes: true },
};
uninstall.exec(&paths).unwrap();
assert!(!version_dir.0.exists(), "version dir should be removed after uninstall");
}
}
@@ -147,9 +147,7 @@ impl 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.
Chain a call to `.on_connect_error(callback)` to your builder to register a callback to run when a connection attempt fails asynchronously. Errors which prevent `build` from creating the connection are returned by `build` instead.
#### Callback `on_disconnect`
@@ -162,7 +160,7 @@ impl DbConnectionBuilder {
}
```
Chain a call to `.on_disconnect(callback)` to your builder to register a callback to run when your `DbConnection` disconnects from the remote database, 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 established `DbConnection` disconnects from the remote database, either as a result of a call to [`disconnect`](#method-disconnect) or due to an error.
#### Method `with_token`
+1
View File
@@ -12,6 +12,7 @@ Step-by-step guides for common tasks.
- **Deployment**
- [Deploy to MainCloud](./00100-how-to/00100-deploy/00100-maincloud.md) - Deploy to SpacetimeDB's managed cloud
- [Railway](./00100-how-to/00100-deploy/00300-railway.md) - Deploy SpacetimeDB with the official Railway template
- [Self-Hosting](./00100-how-to/00100-deploy/00200-self-hosting.md) - Run SpacetimeDB on your own infrastructure
- **Database Features**
@@ -0,0 +1,71 @@
---
title: Railway
slug: /how-to/deploy/railway
---
Railway is a hosted platform for deploying infrastructure and application services. If you want to run SpacetimeDB without managing your own VM, the official Railway template is a quick way to get started.
The template deploys the first-party `clockworklabs/spacetime` image, exposes port `3000`, and provisions persistent storage at `/stdb`. Once the service is running, you can publish one or more databases to it with the SpacetimeDB CLI.
## Prerequisites
1. A [Railway account](https://railway.com/)
2. The SpacetimeDB CLI installed: [Install SpacetimeDB](https://spacetimedb.com/install)
3. A SpacetimeDB module project ready to publish
## Step 1: Deploy the Railway template
Open the official deployment template:
[SpacetimeDB Template](https://railway.com/deploy/spacetimedb)
Then:
1. Click **Deploy Now**.
2. Create a new Railway project or choose an existing one.
3. Wait for the deployment to finish.
4. In Railway, open your service and copy its public domain or attach a custom domain.
That domain is the base URL your CLI and clients will use to connect to this SpacetimeDB instance.
## Step 2: Add the Railway deployment to your CLI
Register your Railway deployment as a named server:
```bash
spacetime server add --url https://<your-railway-domain> railway
```
For example:
```bash
spacetime server add --url https://my-railway-app.up.railway.app railway
```
You can optionally verify the connection:
```bash
spacetime server ping railway
```
## Step 3: Publish your database
From your SpacetimeDB project, publish a database to the Railway deployment:
```bash
spacetime publish my-database --server railway
```
To update an existing database later, run the same command again.
## Step 4: Connect clients
After publishing, connect your client to your Railway-hosted database using your Railway domain as the server URI and your database name.
See [Connecting to SpacetimeDB](../../../00200-core-concepts/00600-clients/00300-connection.md) for the current client connection patterns across supported SDKs.
## Notes
- The Railway template sets up the SpacetimeDB server itself, but it does not publish your module for you. You still deploy your database schema and logic with `spacetime publish`.
- A single Railway-hosted SpacetimeDB instance can host multiple databases.
- If you want full control over the host, reverse proxy, and operating system setup, see [Self-hosting](./00200-self-hosting.md).
+4 -4
View File
@@ -280,7 +280,7 @@ SPACETIMEDB_REDUCER(list_over_age, ReducerContext ctx, uint8_t age) {
// Log module identity
SPACETIMEDB_REDUCER(log_module_identity, ReducerContext ctx) {
LOG_INFO("Module identity: " + ctx.identity().to_string());
LOG_INFO("Module identity: " + ctx.database_identity().to_string());
return Ok();
}
@@ -550,8 +550,8 @@ SPACETIMEDB_REDUCER(test_btree_index_args, ReducerContext ctx) {
// Test reducer for assertions
SPACETIMEDB_REDUCER(assert_caller_identity_is_module_identity, ReducerContext ctx) {
LOG_INFO("Sender: " + ctx.sender().to_string() + " Identity: " + ctx.identity().to_string());
if (ctx.sender() != ctx.identity()) {
LOG_INFO("Sender: " + ctx.sender().to_string() + " Identity: " + ctx.database_identity().to_string());
if (ctx.sender() != ctx.database_identity()) {
LOG_ERROR("Assertion failed: caller identity does not match module identity");
} else {
LOG_INFO("Assertion passed: caller identity matches module identity");
@@ -693,7 +693,7 @@ SPACETIMEDB_PROCEDURE(Unit, with_tx, ProcedureContext ctx) {
// Hit SpacetimeDB's schema HTTP route and return its result as a string
SPACETIMEDB_PROCEDURE(std::string, get_my_schema_via_http, ProcedureContext ctx) {
Identity module_identity = ctx.identity();
Identity module_identity = ctx.database_identity();
std::string url = "http://localhost:3000/v1/database/" + module_identity.to_string() + "/schema?version=9";
auto result = ctx.http.get(url);
+2 -2
View File
@@ -300,7 +300,7 @@ static partial class Module
public static void log_module_identity(ReducerContext ctx)
{
// Note: converting to lowercase to match the Rust formatting.
Log.Info($"Module identity: {ctx.Identity.ToString().ToLower()}");
Log.Info($"Module identity: {ctx.DatabaseIdentity.ToString().ToLower()}");
}
[Reducer]
@@ -492,7 +492,7 @@ static partial class Module
public static void assert_caller_identity_is_module_identity(ReducerContext ctx)
{
var caller = ctx.Sender;
var owner = ctx.Identity;
var owner = ctx.DatabaseIdentity;
if (!caller.Equals(owner))
{
throw new Exception($"Caller {caller} is not the owner {owner}");
+3 -3
View File
@@ -331,7 +331,7 @@ export const listOverAge = spacetimedb.reducer(
// log_module_identity()
export const log_module_identity = spacetimedb.reducer(ctx => {
console.info(`Module identity: ${ctx.identity}`);
console.info(`Module identity: ${ctx.databaseIdentity}`);
});
// test(arg: TestAlias(TestA), arg2: TestB, arg3: TestC, arg4: TestF)
@@ -494,7 +494,7 @@ export const test_btree_index_args = spacetimedb.reducer(ctx => {
export const assert_caller_identity_is_module_identity = spacetimedb.reducer(
ctx => {
const caller = ctx.sender;
const owner = ctx.identity;
const owner = ctx.databaseIdentity;
if (String(caller) !== String(owner)) {
throw new Error(`Caller ${caller} is not the owner ${owner}`);
} else {
@@ -507,7 +507,7 @@ export const assert_caller_identity_is_module_identity = spacetimedb.reducer(
//
// This is a silly thing to do, but an effective test of the procedure HTTP API.
export const getMySchemaViaHttp = spacetimedb.procedure(t.string(), ctx => {
const module_identity = ctx.identity;
const module_identity = ctx.databaseIdentity;
try {
const response = ctx.http.fetch(
`http://localhost:3000/v1/database/${module_identity}/schema?version=9`
+2 -2
View File
@@ -295,7 +295,7 @@ pub fn list_over_age(ctx: &ReducerContext, age: u8) {
#[spacetimedb::reducer]
fn log_module_identity(ctx: &ReducerContext) {
log::info!("Module identity: {}", ctx.identity());
log::info!("Module identity: {}", ctx.database_identity());
}
#[spacetimedb::reducer]
@@ -508,7 +508,7 @@ fn test_btree_index_args(ctx: &ReducerContext) {
#[spacetimedb::reducer]
fn assert_caller_identity_is_module_identity(ctx: &ReducerContext) {
let caller = ctx.sender();
let owner = ctx.identity();
let owner = ctx.database_identity();
if caller != owner {
panic!("Caller {caller} is not the owner {owner}");
} else {
+1 -1
View File
@@ -142,7 +142,7 @@ SPACETIMEDB_PROCEDURE(Unit, insert_with_tx_rollback, ProcedureContext ctx) {
// Test HTTP GET request to the module's own schema endpoint
SPACETIMEDB_PROCEDURE(std::string, read_my_schema, ProcedureContext ctx) {
// Get the module identity (database address)
Identity module_identity = ctx.identity();
Identity module_identity = ctx.database_identity();
std::string identity_hex = module_identity.to_hex_string();
LOG_INFO("read_my_schema using identity: " + identity_hex);
+1 -1
View File
@@ -91,7 +91,7 @@ export const will_panic = spacetimedb.procedure(t.unit(), _ctx => {
});
export const read_my_schema = spacetimedb.procedure(t.string(), ctx => {
const module_identity = ctx.identity;
const module_identity = ctx.databaseIdentity;
const response = ctx.http.fetch(
`http://localhost:3000/v1/database/${module_identity}/schema?version=9`
);
@@ -12,7 +12,7 @@ using RegressionTests.Shared;
using SpacetimeDB;
using SpacetimeDB.Types;
const string HOST = "http://localhost:3000";
string HOST = Environment.GetEnvironmentVariable("SPACETIMEDB_SERVER_URL") ?? "http://localhost:3000";
const string DBNAME = "btree-repro";
const string THROW_ERROR_MESSAGE = "this is an error";
const uint UPDATED_WHERE_TEST_VALUE = 42;
@@ -9,7 +9,7 @@ using RegressionTests.Shared;
using SpacetimeDB;
using SpacetimeDB.Types;
const string HOST = "http://localhost:3000";
string HOST = Environment.GetEnvironmentVariable("SPACETIMEDB_SERVER_URL") ?? "http://localhost:3000";
const string DBNAME = "procedure-tests";
uint waiting = 0;
@@ -9,7 +9,7 @@ using RegressionTests.Shared;
using SpacetimeDB;
using SpacetimeDB.Types;
const string HOST = "http://localhost:3000";
string HOST = Environment.GetEnvironmentVariable("SPACETIMEDB_SERVER_URL") ?? "http://localhost:3000";
const string DBNAME = "republish-test";
uint waiting = 0;
+7 -6
View File
@@ -7,6 +7,7 @@ set -ueo pipefail
SDK_PATH="$(dirname "$0")/.."
SDK_PATH="$(realpath "$SDK_PATH")"
STDB_PATH="$SDK_PATH/../.."
SPACETIMEDB_SERVER_URL="${SPACETIMEDB_SERVER_URL:-local}"
# Regenerate Bindings
"$SDK_PATH/tools~/gen-regression-tests.sh"
@@ -15,13 +16,13 @@ STDB_PATH="$SDK_PATH/../.."
cargo build --manifest-path "$STDB_PATH/crates/standalone/Cargo.toml"
# Publish module for btree test
cargo run --manifest-path "$STDB_PATH/crates/cli/Cargo.toml" -- publish -c -y --server local -p "$SDK_PATH/examples~/regression-tests/server" btree-repro
cargo run --manifest-path "$STDB_PATH/crates/cli/Cargo.toml" -- publish -c -y --server "$SPACETIMEDB_SERVER_URL" -p "$SDK_PATH/examples~/regression-tests/server" btree-repro
# Publish module for republishing module test
cargo run --manifest-path "$STDB_PATH/crates/cli/Cargo.toml" -- publish -c -y --server local -p "$SDK_PATH/examples~/regression-tests/republishing/server-initial" republish-test
cargo run --manifest-path "$STDB_PATH/crates/cli/Cargo.toml" call --server local republish-test insert 1
cargo run --manifest-path "$STDB_PATH/crates/cli/Cargo.toml" -- publish --server local -p "$SDK_PATH/examples~/regression-tests/republishing/server-republish" --break-clients republish-test
cargo run --manifest-path "$STDB_PATH/crates/cli/Cargo.toml" call --server local republish-test insert 2
cargo run --manifest-path "$STDB_PATH/crates/cli/Cargo.toml" -- publish -c -y --server "$SPACETIMEDB_SERVER_URL" -p "$SDK_PATH/examples~/regression-tests/republishing/server-initial" republish-test
cargo run --manifest-path "$STDB_PATH/crates/cli/Cargo.toml" call --server "$SPACETIMEDB_SERVER_URL" republish-test insert 1
cargo run --manifest-path "$STDB_PATH/crates/cli/Cargo.toml" -- publish --server "$SPACETIMEDB_SERVER_URL" -p "$SDK_PATH/examples~/regression-tests/republishing/server-republish" --break-clients republish-test
cargo run --manifest-path "$STDB_PATH/crates/cli/Cargo.toml" call --server "$SPACETIMEDB_SERVER_URL" republish-test insert 2
echo "Cleanup obj~ folders generated in $SDK_PATH/examples~/regression-tests/procedure-client"
# There is a bug in the code generator that creates obj~ folders in the output directory using a Rust project.
@@ -29,7 +30,7 @@ rm -rf "$SDK_PATH/examples~/regression-tests/procedure-client"/*/obj~
rm -rf "$SDK_PATH/examples~/regression-tests/procedure-client/module_bindings"/*/obj~
# Publish module for procedure tests
cargo run --manifest-path "$STDB_PATH/crates/cli/Cargo.toml" -- publish -c -y --server local -p "$STDB_PATH/modules/sdk-test-procedure" procedure-tests
cargo run --manifest-path "$STDB_PATH/crates/cli/Cargo.toml" -- publish -c -y --server "$SPACETIMEDB_SERVER_URL" -p "$STDB_PATH/modules/sdk-test-procedure" procedure-tests
# Run client for btree test
cd "$SDK_PATH/examples~/regression-tests/client" && dotnet run -c Debug
+93 -49
View File
@@ -137,18 +137,25 @@ impl<M: SpacetimeModule> DbContextImpl<M> {
fn process_message(&self, msg: ParsedMessage<M>) -> crate::Result<()> {
self.debug_log(|out| writeln!(out, "`process_message`: {msg:?}"));
match msg {
// Error: treat this as an erroneous disconnect.
ParsedMessage::Error(e) => {
let disconnect_ctx = self.make_event_ctx(Some(e.clone()));
self.invoke_disconnected(&disconnect_ctx);
Err(e)
}
// Error: route as a connection error if we never finished connecting,
// otherwise treat it as an erroneous disconnect.
ParsedMessage::Error(e) => Err(self.end_connection(Some(e))),
// Initial `IdentityToken` message:
// confirm that the received identity and connection ID are what we expect,
// store them,
// then invoke the on_connect callback.
// store them, then invoke the on_connect callback.
ParsedMessage::IdentityToken(identity, token, conn_id) => {
let on_connect = {
let mut inner = self.inner.lock().unwrap();
match inner.connection_lifecycle {
ConnectionLifecycle::Connecting => {
inner.connection_lifecycle = ConnectionLifecycle::Connected;
inner.on_connect.take()
}
ConnectionLifecycle::Connected => None,
ConnectionLifecycle::Ended => return Ok(()),
}
};
{
// Don't hold the `self.identity` lock while running callbacks.
// Callbacks can (will) call [`DbContext::identity`], which acquires that lock,
@@ -170,8 +177,7 @@ impl<M: SpacetimeModule> DbContextImpl<M> {
}
*conn_id_store = Some(conn_id);
}
let mut inner = self.inner.lock().unwrap();
if let Some(on_connect) = inner.on_connect.take() {
if let Some(on_connect) = on_connect {
let ctx = <M::DbConnection as DbConnection>::new(self.clone());
on_connect(&ctx, identity, &token);
}
@@ -306,23 +312,47 @@ impl<M: SpacetimeModule> DbContextImpl<M> {
applied_diff.invoke_row_callbacks(&row_event_ctx, &mut inner.db_callbacks);
}
/// Invoke the on-disconnect callback, and mark [`Self::is_active`] false.
fn invoke_disconnected(&self, ctx: &M::ErrorContext) {
/// Mark the connection lifecycle as ended, route the terminal event to the
/// appropriate connection callback, and mark [`Self::is_active`] false.
///
/// Returns the terminal error that should be returned from `advance_*` methods.
fn end_connection(&self, callback_error: Option<crate::Error>) -> crate::Error {
let mut inner = self.inner.lock().unwrap();
// When we disconnect, we first call the on_disconnect method,
// then we call the `on_error` method for all subscriptions.
// We don't change the client cache at all.
let return_error = callback_error.clone().unwrap_or(crate::Error::Disconnected);
let lifecycle = inner.connection_lifecycle;
if lifecycle == ConnectionLifecycle::Ended {
return return_error;
}
inner.connection_lifecycle = ConnectionLifecycle::Ended;
// Set `send_chan` to `None`, since `Self::is_active` checks that.
*self.send_chan.lock().unwrap() = None;
// Grap the `on_disconnect` callback and invoke it.
if let Some(disconnect_callback) = inner.on_disconnect.take() {
disconnect_callback(ctx, ctx.event().clone());
}
match lifecycle {
ConnectionLifecycle::Connecting => {
let callback_error = callback_error.unwrap_or_else(|| crate::Error::FailedToConnect {
source: InternalError::new("Connection closed before receiving the initial connection message"),
});
let ctx: M::ErrorContext = self.make_event_ctx(Some(callback_error.clone()));
if let Some(connect_error_callback) = inner.on_connect_error.take() {
connect_error_callback(&ctx, callback_error.clone());
}
callback_error
}
ConnectionLifecycle::Connected => {
let ctx: M::ErrorContext = self.make_event_ctx(callback_error.clone());
if let Some(disconnect_callback) = inner.on_disconnect.take() {
disconnect_callback(&ctx, callback_error.clone());
}
// Call the `on_disconnect` method for all subscriptions.
inner.subscriptions.on_disconnect(ctx);
// Call the `on_disconnect` method for all subscriptions.
inner.subscriptions.on_disconnect(&ctx);
return_error
}
ConnectionLifecycle::Ended => return_error,
}
}
fn make_event_ctx<E, Ctx: AbstractEventContext<Module = M, Event = E>>(&self, event: E) -> Ctx {
@@ -447,10 +477,19 @@ impl<M: SpacetimeModule> DbContextImpl<M> {
// Disconnect: close the connection.
PendingMutation::Disconnect => {
{
let mut inner = self.inner.lock().unwrap();
if inner.connection_lifecycle == ConnectionLifecycle::Connecting {
// If the user cancels before the initial connection finishes,
// don't report that as a connection error.
inner.connection_lifecycle = ConnectionLifecycle::Ended;
}
}
// Set `send_chan` to `None`, since `Self::is_active` checks that.
// This will close the WebSocket loop in websocket.rs,
// sending a close frame to the server,
// eventually resulting in disconnect callbacks being called.
// eventually resulting in disconnect callbacks being called
// if the initial connection had completed.
*self.send_chan.lock().unwrap() = None;
}
@@ -540,11 +579,7 @@ impl<M: SpacetimeModule> DbContextImpl<M> {
// `Stream::poll_next`. No comment on whether this is a good mental
// model or not.
let res = match get_lock_sync(&self.recv).try_next() {
Ok(None) => {
let disconnect_ctx = self.make_event_ctx(None);
self.invoke_disconnected(&disconnect_ctx);
Err(crate::Error::Disconnected)
}
Ok(None) => Err(self.end_connection(None)),
Err(_) => Ok(false),
Ok(Some(msg)) => self.process_message(msg).map(|_| true),
};
@@ -599,11 +634,7 @@ impl<M: SpacetimeModule> DbContextImpl<M> {
pub fn advance_one_message_blocking(&self) -> crate::Result<()> {
match self.runtime.block_on(self.get_message()) {
Message::Local(pending) => self.apply_mutation(pending),
Message::Ws(None) => {
let disconnect_ctx = self.make_event_ctx(None);
self.invoke_disconnected(&disconnect_ctx);
Err(crate::Error::Disconnected)
}
Message::Ws(None) => Err(self.end_connection(None)),
Message::Ws(Some(msg)) => self.process_message(msg),
}
}
@@ -614,11 +645,7 @@ impl<M: SpacetimeModule> DbContextImpl<M> {
pub async fn advance_one_message_async(&self) -> crate::Result<()> {
match self.get_message().await {
Message::Local(pending) => self.apply_mutation(pending),
Message::Ws(None) => {
let disconnect_ctx = self.make_event_ctx(None);
self.invoke_disconnected(&disconnect_ctx);
Err(crate::Error::Disconnected)
}
Message::Ws(None) => Err(self.end_connection(None)),
Message::Ws(Some(msg)) => self.process_message(msg),
}
}
@@ -784,6 +811,16 @@ type OnConnectErrorCallback<M> = Box<dyn FnOnce(&<M as SpacetimeModule>::ErrorCo
type OnDisconnectCallback<M> =
Box<dyn FnOnce(&<M as SpacetimeModule>::ErrorContext, Option<crate::Error>) + Send + 'static>;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ConnectionLifecycle {
/// Waiting for the server's initial connection message.
Connecting,
/// The server has sent the initial connection message.
Connected,
/// The connection has already reached a terminal lifecycle state.
Ended,
}
/// All the stuff in a [`DbContextImpl`] which can safely be locked while invoking callbacks.
pub(crate) struct DbContextImplInner<M: SpacetimeModule> {
/// `Some` if not within the context of an outer runtime. The `Runtime` must
@@ -796,9 +833,8 @@ pub(crate) struct DbContextImplInner<M: SpacetimeModule> {
reducer_callbacks: ReducerCallbacks<M>,
pub(crate) subscriptions: SubscriptionManager<M>,
connection_lifecycle: ConnectionLifecycle,
on_connect: Option<OnConnectCallback<M>>,
#[allow(unused)]
// TODO: Make use of this to handle `ParsedMessage::Error` before receiving `IdentityToken`.
on_connect_error: Option<OnConnectErrorCallback<M>>,
on_disconnect: Option<OnDisconnectCallback<M>>,
@@ -1040,9 +1076,10 @@ but you must call one of them, or else the connection will never progress.
/// If this method is not invoked, or `None` is supplied,
/// the SpacetimeDB host will generate a new anonymous `Identity`.
///
/// If the passed token is invalid or rejected by the host,
/// the connection will fail asynchrnonously.
// FIXME: currently this causes `disconnect` to be called rather than `on_connect_error`.
/// If the token is rejected before a connection context is created, [`Self::build`]
/// returns an error. If the host reports the rejection after the WebSocket is
/// established but before the initial connection message, [`Self::on_connect_error`]
/// is invoked.
pub fn with_token(mut self, token: Option<impl Into<String>>) -> Self {
self.token = token.map(|token| token.into());
self
@@ -1095,9 +1132,10 @@ but you must call one of them, or else the connection will never progress.
self
}
/// Register a callback to run when the connection is successfully initiated.
/// Register a callback to run when the connection is successfully established.
///
/// The callback will receive three arguments:
/// The connection is established after the initial connection message is
/// received from the host. The callback will receive three arguments:
/// - The `DbConnection` which has successfully connected.
/// - The `Identity` of the successful connection.
/// - The private access token which can be used to later re-authenticate as the same `Identity`.
@@ -1116,9 +1154,11 @@ Instead of registering multiple `on_connect` callbacks, register a single callba
self
}
/// Register a callback to run when the connection fails asynchronously,
/// e.g. due to invalid credentials.
// FIXME: currently never called; `on_disconnect` is called instead.
/// Register a callback to run when a connection attempt fails asynchronously.
///
/// This callback is invoked only before the initial connection message is
/// received from the host. Errors which prevent [`Self::build`] from creating
/// a connection are returned by [`Self::build`] instead.
pub fn on_connect_error(mut self, callback: impl FnOnce(&M::ErrorContext, crate::Error) + Send + 'static) -> Self {
if self.on_connect_error.is_some() {
panic!(
@@ -1132,8 +1172,11 @@ Instead of registering multiple `on_connect_error` callbacks, register a single
self
}
/// Register a callback to run when the connection is closed.
// FIXME: currently also called when the connection fails asynchronously, instead of `on_connect_error`.
/// Register a callback to run when an established connection is closed.
///
/// The connection is established after the initial connection message is
/// received from the host. Connection failures before that point invoke
/// [`Self::on_connect_error`] instead.
pub fn on_disconnect(
mut self,
callback: impl FnOnce(&M::ErrorContext, Option<crate::Error>) + Send + 'static,
@@ -1166,6 +1209,7 @@ fn build_db_ctx_inner<M: SpacetimeModule>(
reducer_callbacks: ReducerCallbacks::default(),
subscriptions: SubscriptionManager::default(),
connection_lifecycle: ConnectionLifecycle::Connecting,
on_connect: on_connect_cb,
on_connect_error: on_connect_error_cb,
on_disconnect: on_disconnect_cb,
-29
View File
@@ -1,29 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(readlink -f "$(dirname "$0")")"
stdb_root="$(realpath "$script_dir/../")"
set -euox pipefail
cd "$stdb_root"
tools/clippy.sh
cargo test --all
if which python3 >/dev/null ; then
python3 -m smoketests
elif which python >/dev/null ; then
python -m smoketests
else
echo "Can't find python, not running smoketests"
fi
if which dotnet >/dev/null ; then
dotnet test crates/bindings-csharp
else
echo "Can't find dotnet, not running smoketests"
fi