diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 27d2b730d..22f9e0eb3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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)); diff --git a/crates/bindings-cpp/include/spacetimedb/procedure_context.h b/crates/bindings-cpp/include/spacetimedb/procedure_context.h index 1ee1b88f7..f9107d702 100644 --- a/crates/bindings-cpp/include/spacetimedb/procedure_context.h +++ b/crates/bindings-cpp/include/spacetimedb/procedure_context.h @@ -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 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 * diff --git a/crates/bindings-cpp/include/spacetimedb/reducer_context.h b/crates/bindings-cpp/include/spacetimedb/reducer_context.h index 307c107e5..8c8fba26e 100644 --- a/crates/bindings-cpp/include/spacetimedb/reducer_context.h +++ b/crates/bindings-cpp/include/spacetimedb/reducer_context.h @@ -58,11 +58,16 @@ public: return *rng_instance; } - Identity identity() const { + Identity database_identity() const { std::array buffer; ::identity(buffer.data()); return Identity(buffer); } + + [[deprecated("Use database_identity() instead.")]] + Identity identity() const { + return database_identity(); + } /** * Generate a new random UUID v4. diff --git a/crates/bindings-cpp/include/spacetimedb/tx_context.h b/crates/bindings-cpp/include/spacetimedb/tx_context.h index 68d1d3181..1a04ef027 100644 --- a/crates/bindings-cpp/include/spacetimedb/tx_context.h +++ b/crates/bindings-cpp/include/spacetimedb/tx_context.h @@ -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(); } /** diff --git a/crates/bindings-csharp/Codegen.Tests/fixtures/diag/snapshots/Module#FFI.verified.cs b/crates/bindings-csharp/Codegen.Tests/fixtures/diag/snapshots/Module#FFI.verified.cs index 3ce055b4b..bb33a8b65 100644 --- a/crates/bindings-csharp/Codegen.Tests/fixtures/diag/snapshots/Module#FFI.verified.cs +++ b/crates/bindings-csharp/Codegen.Tests/fixtures/diag/snapshots/Module#FFI.verified.cs @@ -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, diff --git a/crates/bindings-csharp/Codegen.Tests/fixtures/explicitnames/snapshots/Module#FFI.verified.cs b/crates/bindings-csharp/Codegen.Tests/fixtures/explicitnames/snapshots/Module#FFI.verified.cs index a9774bfc6..c96357625 100644 --- a/crates/bindings-csharp/Codegen.Tests/fixtures/explicitnames/snapshots/Module#FFI.verified.cs +++ b/crates/bindings-csharp/Codegen.Tests/fixtures/explicitnames/snapshots/Module#FFI.verified.cs @@ -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, diff --git a/crates/bindings-csharp/Codegen.Tests/fixtures/server/snapshots/Module#FFI.verified.cs b/crates/bindings-csharp/Codegen.Tests/fixtures/server/snapshots/Module#FFI.verified.cs index 4632875e0..4c6be2c99 100644 --- a/crates/bindings-csharp/Codegen.Tests/fixtures/server/snapshots/Module#FFI.verified.cs +++ b/crates/bindings-csharp/Codegen.Tests/fixtures/server/snapshots/Module#FFI.verified.cs @@ -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, diff --git a/crates/bindings-csharp/Codegen/Module.cs b/crates/bindings-csharp/Codegen/Module.cs index e778b6d69..f6ffa50b9 100644 --- a/crates/bindings-csharp/Codegen/Module.cs +++ b/crates/bindings-csharp/Codegen/Module.cs @@ -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) diff --git a/crates/bindings-csharp/Runtime/Internal/IReducer.cs b/crates/bindings-csharp/Runtime/Internal/IReducer.cs index ec2e698a1..878c98a2a 100644 --- a/crates/bindings-csharp/Runtime/Internal/IReducer.cs +++ b/crates/bindings-csharp/Runtime/Internal/IReducer.cs @@ -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 diff --git a/crates/bindings-typescript/src/lib/reducers.ts b/crates/bindings-typescript/src/lib/reducers.ts index a4252ba68..0eae2adc2 100644 --- a/crates/bindings-typescript/src/lib/reducers.ts +++ b/crates/bindings-typescript/src/lib/reducers.ts @@ -103,6 +103,8 @@ export interface JwtClaims { */ export type ReducerCtx = Readonly<{ sender: Identity; + databaseIdentity: Identity; + /** @deprecated Use `databaseIdentity` instead. */ identity: Identity; timestamp: Timestamp; connectionId: ConnectionId | null; diff --git a/crates/bindings-typescript/src/server/procedures.ts b/crates/bindings-typescript/src/server/procedures.ts index 5e0791c15..39e5f5854 100644 --- a/crates/bindings-typescript/src/server/procedures.ts +++ b/crates/bindings-typescript/src/server/procedures.ts @@ -75,6 +75,8 @@ export interface ProcedureOpts { export interface ProcedureCtx { 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 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)); } diff --git a/crates/bindings-typescript/src/server/runtime.ts b/crates/bindings-typescript/src/server/runtime.ts index e05d4c7f3..5031b1d85 100644 --- a/crates/bindings-typescript/src/server/runtime.ts +++ b/crates/bindings-typescript/src/server/runtime.ts @@ -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, diff --git a/crates/bindings/src/lib.rs b/crates/bindings/src/lib.rs index c15e90891..121957389 100644 --- a/crates/bindings/src/lib.rs +++ b/crates/bindings/src/lib.rs @@ -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 diff --git a/crates/update/src/cli/uninstall.rs b/crates/update/src/cli/uninstall.rs index 0ec1f69ca..3c9e14dbe 100644 --- a/crates/update/src/cli/uninstall.rs +++ b/crates/update/src/cli/uninstall.rs @@ -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(¤t_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"); + } +} diff --git a/docs/docs/00200-core-concepts/00600-clients/00500-rust-reference.md b/docs/docs/00200-core-concepts/00600-clients/00500-rust-reference.md index 72a291784..ecd0a02c8 100644 --- a/docs/docs/00200-core-concepts/00600-clients/00500-rust-reference.md +++ b/docs/docs/00200-core-concepts/00600-clients/00500-rust-reference.md @@ -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` diff --git a/docs/docs/00300-resources/00000-index.md b/docs/docs/00300-resources/00000-index.md index 4829607cd..7377229fb 100644 --- a/docs/docs/00300-resources/00000-index.md +++ b/docs/docs/00300-resources/00000-index.md @@ -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** diff --git a/docs/docs/00300-resources/00100-how-to/00100-deploy/00300-railway.md b/docs/docs/00300-resources/00100-how-to/00100-deploy/00300-railway.md new file mode 100644 index 000000000..89395a1b3 --- /dev/null +++ b/docs/docs/00300-resources/00100-how-to/00100-deploy/00300-railway.md @@ -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:// 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). diff --git a/modules/module-test-cpp/src/lib.cpp b/modules/module-test-cpp/src/lib.cpp index a50f8e74f..bc5305b1f 100644 --- a/modules/module-test-cpp/src/lib.cpp +++ b/modules/module-test-cpp/src/lib.cpp @@ -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); diff --git a/modules/module-test-cs/Lib.cs b/modules/module-test-cs/Lib.cs index 57aefc07a..7ac37fc3d 100644 --- a/modules/module-test-cs/Lib.cs +++ b/modules/module-test-cs/Lib.cs @@ -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}"); diff --git a/modules/module-test-ts/src/index.ts b/modules/module-test-ts/src/index.ts index 7861153c7..b8f0c01d5 100644 --- a/modules/module-test-ts/src/index.ts +++ b/modules/module-test-ts/src/index.ts @@ -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` diff --git a/modules/module-test/src/lib.rs b/modules/module-test/src/lib.rs index fe1d49169..56e6b288e 100644 --- a/modules/module-test/src/lib.rs +++ b/modules/module-test/src/lib.rs @@ -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 { diff --git a/modules/sdk-test-procedure-cpp/src/lib.cpp b/modules/sdk-test-procedure-cpp/src/lib.cpp index df9f54ae1..31e366970 100644 --- a/modules/sdk-test-procedure-cpp/src/lib.cpp +++ b/modules/sdk-test-procedure-cpp/src/lib.cpp @@ -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); diff --git a/modules/sdk-test-procedure-ts/src/index.ts b/modules/sdk-test-procedure-ts/src/index.ts index c9cd308d7..f89aa7666 100644 --- a/modules/sdk-test-procedure-ts/src/index.ts +++ b/modules/sdk-test-procedure-ts/src/index.ts @@ -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` ); diff --git a/sdks/csharp/examples~/regression-tests/client/Program.cs b/sdks/csharp/examples~/regression-tests/client/Program.cs index 4e254fed3..c3b88d1b0 100644 --- a/sdks/csharp/examples~/regression-tests/client/Program.cs +++ b/sdks/csharp/examples~/regression-tests/client/Program.cs @@ -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; diff --git a/sdks/csharp/examples~/regression-tests/procedure-client/Program.cs b/sdks/csharp/examples~/regression-tests/procedure-client/Program.cs index 8997f5963..2345af3af 100644 --- a/sdks/csharp/examples~/regression-tests/procedure-client/Program.cs +++ b/sdks/csharp/examples~/regression-tests/procedure-client/Program.cs @@ -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; diff --git a/sdks/csharp/examples~/regression-tests/republishing/client/Program.cs b/sdks/csharp/examples~/regression-tests/republishing/client/Program.cs index e8493cee8..41fc80bb8 100644 --- a/sdks/csharp/examples~/regression-tests/republishing/client/Program.cs +++ b/sdks/csharp/examples~/regression-tests/republishing/client/Program.cs @@ -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; diff --git a/sdks/csharp/tools~/run-regression-tests.sh b/sdks/csharp/tools~/run-regression-tests.sh index 847409c9c..168eeab66 100644 --- a/sdks/csharp/tools~/run-regression-tests.sh +++ b/sdks/csharp/tools~/run-regression-tests.sh @@ -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 diff --git a/sdks/rust/src/db_connection.rs b/sdks/rust/src/db_connection.rs index 838894522..332aac1b3 100644 --- a/sdks/rust/src/db_connection.rs +++ b/sdks/rust/src/db_connection.rs @@ -137,18 +137,25 @@ impl DbContextImpl { fn process_message(&self, msg: ParsedMessage) -> 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 DbContextImpl { } *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 = ::new(self.clone()); on_connect(&ctx, identity, &token); } @@ -306,23 +312,47 @@ impl DbContextImpl { 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 { 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>(&self, event: E) -> Ctx { @@ -447,10 +477,19 @@ impl DbContextImpl { // 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 DbContextImpl { // `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 DbContextImpl { 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 DbContextImpl { 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 = Box::ErrorCo type OnDisconnectCallback = Box::ErrorContext, Option) + 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 { /// `Some` if not within the context of an outer runtime. The `Runtime` must @@ -796,9 +833,8 @@ pub(crate) struct DbContextImplInner { reducer_callbacks: ReducerCallbacks, pub(crate) subscriptions: SubscriptionManager, + connection_lifecycle: ConnectionLifecycle, on_connect: Option>, - #[allow(unused)] - // TODO: Make use of this to handle `ParsedMessage::Error` before receiving `IdentityToken`. on_connect_error: Option>, on_disconnect: Option>, @@ -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>) -> 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) + Send + 'static, @@ -1166,6 +1209,7 @@ fn build_db_ctx_inner( 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, diff --git a/tools/run-all-tests.sh b/tools/run-all-tests.sh deleted file mode 100755 index a2d0dbf99..000000000 --- a/tools/run-all-tests.sh +++ /dev/null @@ -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 -