From 3905bbaf3f2ea03d67931c7d3a6e5977a3ad40b8 Mon Sep 17 00:00:00 2001 From: Ryan Date: Mon, 29 Jun 2026 08:17:29 -0700 Subject: [PATCH 1/5] Regenerate case conversion TS bindings (#5434) # Description of Changes Updates the TypeScript code generator to emit correct `.name()` values on table fields, and updates the three affected Case Conversion Test snapshots. **Problem:** For source identifiers with mixed/PascalCase casing (e.g., `Player1Id`), the in-process codegen path used by `cargo test` and `check-diff.sh` emitted `.name()` calls using the pre-conversion identifier (`name('Player1Id')`) instead of the canonical database column name (`name('player_1_id')`). The CLI `spacetime generate` path was already correct because its `extract-schema` round-trip canonicalizes names before codegen. The two paths disagreed, causing `check-diff` CI failures. **Root cause / Fix:** `write_object_type_builder_fields` in `crates/codegen/src/typescript.rs` was using the `TypespaceForGenerate` pre-conversion identifier for `.name()`. It now accepts an optional `&[ColumnDef]` and emits `column.name` for table fields. The camelCase accessor key (e.g., `player1Id`) is unchanged; only the underlying wire/database column name is corrected to snake_case. The three snapshot files in `crates/bindings-typescript/case-conversion-test-client/src/module_bindings/` are updated to match the corrected output. # API and ABI breaking changes No API or ABI changes at the Rust crate or C ABI level. For TypeScript users, this is a bug fix for the in-process generation path; the CLI path was already correct. # Expected complexity level and risk 1 - Trivial # Testing - [X] Local testing - Verified the in-process `cargo test -p spacetimedb-sdk -- case_conversion` path on `master` generated incorrect PascalCase `.name()` values (`name("Player1Id")`, missing `.name()` for `currentLevel2`/`status3Field`. - Verified the same in-process path on the PR branch generates correct snake_case `.name()` values (`name("player_1_id")`, `name("current_level_2")`, `name("status_3_field")`), matching the updated snapshots. - Verified the CLI `spacetime generate --lang typescript` path produces identical snake_case output on both `master` and the PR branch. - Created a standalone TypeScript test that confirms the generated bindings produce SQL with canonical snake_case column names (`"current_level_2"`, `"player_1_id"`, `"player_ref"`) via the SDK `toSql()` function. - [X] CI passing --------- Signed-off-by: Ryan Co-authored-by: clockwork-labs-bot Co-authored-by: Zeke Foppa <196249+bfops@users.noreply.github.com> --- .../src/module_bindings/person_2_table.ts | 8 ++--- .../person_at_level_2_table.ts | 8 ++--- .../src/module_bindings/player_1_table.ts | 6 ++-- crates/codegen/src/typescript.rs | 31 ++++++++++++++----- 4 files changed, 34 insertions(+), 19 deletions(-) diff --git a/crates/bindings-typescript/case-conversion-test-client/src/module_bindings/person_2_table.ts b/crates/bindings-typescript/case-conversion-test-client/src/module_bindings/person_2_table.ts index 5a94f4ea501..9359fb15439 100644 --- a/crates/bindings-typescript/case-conversion-test-client/src/module_bindings/person_2_table.ts +++ b/crates/bindings-typescript/case-conversion-test-client/src/module_bindings/person_2_table.ts @@ -12,10 +12,10 @@ import { import { Person3Info } from './types'; export default __t.row({ - person2Id: __t.u32().primaryKey().name('Person2Id'), - firstName: __t.string().name('FirstName'), - playerRef: __t.u32(), + person2Id: __t.u32().primaryKey().name('person_2_id'), + firstName: __t.string().name('first_name'), + playerRef: __t.u32().name('player_ref'), get personInfo() { - return Person3Info; + return Person3Info.name('person_info'); }, }); diff --git a/crates/bindings-typescript/case-conversion-test-client/src/module_bindings/person_at_level_2_table.ts b/crates/bindings-typescript/case-conversion-test-client/src/module_bindings/person_at_level_2_table.ts index 5a94f4ea501..9359fb15439 100644 --- a/crates/bindings-typescript/case-conversion-test-client/src/module_bindings/person_at_level_2_table.ts +++ b/crates/bindings-typescript/case-conversion-test-client/src/module_bindings/person_at_level_2_table.ts @@ -12,10 +12,10 @@ import { import { Person3Info } from './types'; export default __t.row({ - person2Id: __t.u32().primaryKey().name('Person2Id'), - firstName: __t.string().name('FirstName'), - playerRef: __t.u32(), + person2Id: __t.u32().primaryKey().name('person_2_id'), + firstName: __t.string().name('first_name'), + playerRef: __t.u32().name('player_ref'), get personInfo() { - return Person3Info; + return Person3Info.name('person_info'); }, }); diff --git a/crates/bindings-typescript/case-conversion-test-client/src/module_bindings/player_1_table.ts b/crates/bindings-typescript/case-conversion-test-client/src/module_bindings/player_1_table.ts index ac5c3648fea..fba1e5f12f3 100644 --- a/crates/bindings-typescript/case-conversion-test-client/src/module_bindings/player_1_table.ts +++ b/crates/bindings-typescript/case-conversion-test-client/src/module_bindings/player_1_table.ts @@ -12,10 +12,10 @@ import { import { Player2Status } from './types'; export default __t.row({ - player1Id: __t.u32().primaryKey().name('Player1Id'), + player1Id: __t.u32().primaryKey().name('player_1_id'), playerName: __t.string().name('player_name'), - currentLevel2: __t.u32(), + currentLevel2: __t.u32().name('current_level_2'), get status3Field() { - return Player2Status; + return Player2Status.name('status_3_field'); }, }); diff --git a/crates/codegen/src/typescript.rs b/crates/codegen/src/typescript.rs index 457d8960d1a..a2d1cbf8e6c 100644 --- a/crates/codegen/src/typescript.rs +++ b/crates/codegen/src/typescript.rs @@ -15,7 +15,7 @@ use convert_case::{Case, Casing}; use spacetimedb_lib::sats::layout::PrimitiveType; use spacetimedb_lib::sats::AlgebraicTypeRef; use spacetimedb_primitives::ColId; -use spacetimedb_schema::def::{ConstraintDef, IndexDef, ModuleDef, ReducerDef, TableDef, TypeDef}; +use spacetimedb_schema::def::{ColumnDef, ConstraintDef, IndexDef, ModuleDef, ReducerDef, TableDef, TypeDef}; use spacetimedb_schema::identifier::Identifier; use spacetimedb_schema::reducer_name::ReducerName; use spacetimedb_schema::schema::TableSchema; @@ -85,7 +85,15 @@ impl Lang for TypeScript { writeln!(out, "export default __t.row({{"); out.indent(1); - write_object_type_builder_fields(module, out, &product_def.elements, table.primary_key, true, true).unwrap(); + write_object_type_builder_fields( + module, + out, + &product_def.elements, + table.primary_key, + true, + Some(&table.columns), + ) + .unwrap(); out.dedent(1); writeln!(out, "}});"); OutputFile { @@ -143,7 +151,7 @@ impl Lang for TypeScript { writeln!(out, "export const params = {{"); out.with_indent(|out| { - write_object_type_builder_fields(module, out, &procedure.params_for_generate.elements, None, true, false) + write_object_type_builder_fields(module, out, &procedure.params_for_generate.elements, None, true, None) .unwrap() }); writeln!(out, "}};"); @@ -815,7 +823,7 @@ fn define_body_for_reducer(module: &ModuleDef, out: &mut Indenter, params: &[(Id writeln!(out, "}};"); } else { writeln!(out); - out.with_indent(|out| write_object_type_builder_fields(module, out, params, None, true, false).unwrap()); + out.with_indent(|out| write_object_type_builder_fields(module, out, params, None, true, None).unwrap()); writeln!(out, "}};"); } } @@ -840,7 +848,7 @@ fn define_body_for_product( writeln!(out, "}});"); } else { writeln!(out); - out.with_indent(|out| write_object_type_builder_fields(module, out, elements, None, true, false).unwrap()); + out.with_indent(|out| write_object_type_builder_fields(module, out, elements, None, true, None).unwrap()); writeln!(out, "}});"); } writeln!(out, "export type {name} = __Infer;"); @@ -932,7 +940,7 @@ fn write_object_type_builder_fields( elements: &[(Identifier, AlgebraicTypeUse)], primary_key: Option, convert_case: bool, - write_original_name: bool, + columns: Option<&[ColumnDef]>, ) -> anyhow::Result<()> { for (i, (ident, ty)) in elements.iter().enumerate() { let name = if convert_case { @@ -945,7 +953,14 @@ fn write_object_type_builder_fields( Some(pk) => pk.idx() == i, None => false, }; - let original_name = (write_original_name && convert_case && *name != **ident).then_some(&**ident); + // The `.name(..)` value is the in-database (canonical) column name, which may + // differ from the generated camelCase accessor key. Emit it only when the + // canonical name differs, so the client maps to the correct wire/column name + // regardless of the source identifier's casing. + let original_name = columns + .and_then(|columns| columns.get(i)) + .map(|column| column.name.deref()) + .filter(|canonical| convert_case && *canonical != name.as_str()); write_type_builder_field(module, out, &name, original_name, ty, is_primary_key)?; } @@ -1083,7 +1098,7 @@ fn define_body_for_sum( (Identifier::for_test(pascal), ty.clone()) }) .collect(); - out.with_indent(|out| write_object_type_builder_fields(module, out, &pascal_variants, None, false, false).unwrap()); + out.with_indent(|out| write_object_type_builder_fields(module, out, &pascal_variants, None, false, None).unwrap()); writeln!(out, "}});"); writeln!(out, "export type {name} = __Infer;"); out.newline(); From cda345a4f1ff01d55c8599b582ae7d33f7d84e53 Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Mon, 29 Jun 2026 09:36:56 -0700 Subject: [PATCH 2/5] Supply empty arg hash for anonymous views at runtime (#5449) # Description of Changes Instead of baking it into the query plan. The query plan should parameterize anonymous views just like sender-scoped views. We need this for the next step which is plan sharing for subscriptions. # API and ABI breaking changes None # Expected complexity level and risk 1 # Testing Existing coverage --- crates/execution/src/lib.rs | 23 +++++++++++++++++------ crates/physical-plan/src/plan.rs | 21 +++++++++++---------- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/crates/execution/src/lib.rs b/crates/execution/src/lib.rs index 34d02317d1f..7eac07f7c29 100644 --- a/crates/execution/src/lib.rs +++ b/crates/execution/src/lib.rs @@ -1,9 +1,12 @@ use anyhow::Result; use core::hash::{Hash, Hasher}; use core::ops::RangeBounds; -use spacetimedb_lib::{hash_sender_view_args, identity::AuthCtx, query::Delta, AlgebraicType, Identity}; +use spacetimedb_lib::{ + hash_empty_view_args, hash_sender_view_args, identity::AuthCtx, query::Delta, AlgebraicType, Identity, +}; use spacetimedb_physical_plan::plan::{ - ParamResolver, ParamSlot, ProjectField, TupleField, PARAM_SENDER, PARAM_VIEW_ARG_HASH, + ParamResolver, ParamSlot, ProjectField, TupleField, PARAM_SENDER, PARAM_VIEW_ARG_HASH_EMPTY, + PARAM_VIEW_ARG_HASH_SENDER, }; use spacetimedb_primitives::{ColList, IndexId, TableId}; use spacetimedb_sats::bsatn::{BufReservedFill, EncodeError, ToBsatn}; @@ -18,14 +21,16 @@ pub mod pipelined; #[derive(Debug, Clone, Copy)] pub struct ExecutionParams { sender: Identity, - view_arg_hash: u256, + empty_view_arg_hash: u256, + sender_view_arg_hash: u256, } impl ExecutionParams { pub fn from_sender(sender: Identity) -> Self { Self { sender, - view_arg_hash: hash_sender_view_args(sender).to_u256(), + empty_view_arg_hash: hash_empty_view_args().to_u256(), + sender_view_arg_hash: hash_sender_view_args(sender).to_u256(), } } @@ -40,8 +45,14 @@ impl ParamResolver for ExecutionParams { PARAM_SENDER if ty.is_identity() => self.sender.into(), PARAM_SENDER if ty.is_bytes() => AlgebraicValue::Bytes(self.sender.to_be_byte_array().into()), PARAM_SENDER => panic!("unsupported type for :sender: {ty:?}"), - PARAM_VIEW_ARG_HASH if matches!(ty, AlgebraicType::U256) => AlgebraicValue::U256(self.view_arg_hash.into()), - PARAM_VIEW_ARG_HASH => panic!("unsupported type for view arg hash: {ty:?}"), + PARAM_VIEW_ARG_HASH_EMPTY if matches!(ty, AlgebraicType::U256) => { + AlgebraicValue::U256(self.empty_view_arg_hash.into()) + } + PARAM_VIEW_ARG_HASH_EMPTY => panic!("unsupported type for empty view arg hash: {ty:?}"), + PARAM_VIEW_ARG_HASH_SENDER if matches!(ty, AlgebraicType::U256) => { + AlgebraicValue::U256(self.sender_view_arg_hash.into()) + } + PARAM_VIEW_ARG_HASH_SENDER => panic!("unsupported type for sender view arg hash: {ty:?}"), ParamSlot(slot) => panic!("unknown physical plan parameter slot: {slot}"), } } diff --git a/crates/physical-plan/src/plan.rs b/crates/physical-plan/src/plan.rs index 3dfbd82d80c..aba742cce6b 100644 --- a/crates/physical-plan/src/plan.rs +++ b/crates/physical-plan/src/plan.rs @@ -6,9 +6,7 @@ use spacetimedb_expr::{ expr::{AggType, CollectViews}, StatementSource, }; -use spacetimedb_lib::{ - empty_view_arg_hash_value, query::Delta, sats::size_of::SizeOf, AlgebraicType, AlgebraicValue, ProductValue, -}; +use spacetimedb_lib::{query::Delta, sats::size_of::SizeOf, AlgebraicType, AlgebraicValue, ProductValue}; use spacetimedb_primitives::{ColId, ColOrCols, ColSet, IndexId, TableId, ViewId}; use spacetimedb_schema::schema::{IndexSchema, TableSchema, VIEW_ARG_HASH_COL}; use spacetimedb_sql_parser::ast::{BinOp, LogOp}; @@ -42,7 +40,8 @@ pub trait ParamResolver { pub struct ParamSlot(pub u16); pub const PARAM_SENDER: ParamSlot = ParamSlot(0); -pub const PARAM_VIEW_ARG_HASH: ParamSlot = ParamSlot(1); +pub const PARAM_VIEW_ARG_HASH_EMPTY: ParamSlot = ParamSlot(1); +pub const PARAM_VIEW_ARG_HASH_SENDER: ParamSlot = ParamSlot(2); /// Physical plans always terminate with a projection. /// This type of projection returns row ids. @@ -553,9 +552,10 @@ impl PhysicalPlan { /// If a view has private arguments, its backing table has an `arg_hash` column. /// This column tracks which rows belong to which argument tuple. /// - /// As a result, queries over such views cannot read the entire backing table. - /// They must only select the rows corresponding to the caller of the query. - /// Hence we must add an implicit selection over these types of views. + /// As a result, queries over views cannot read the entire backing table. + /// They must only select the rows corresponding to the view arguments used + /// by each view reference. Hence we must add an implicit selection over + /// these types of views. /// /// Ex. /// ```sql @@ -569,11 +569,12 @@ impl PhysicalPlan { fn expand_views(self) -> Self { match self { Self::TableScan(scan, label) if scan.schema.is_view() => { - let arg_hash = if scan.schema.is_anonymous_view() { - PhysicalExpr::Value(empty_view_arg_hash_value()) + let param = if scan.schema.is_anonymous_view() { + PARAM_VIEW_ARG_HASH_EMPTY } else { - PhysicalExpr::Param(PARAM_VIEW_ARG_HASH, AlgebraicType::U256) + PARAM_VIEW_ARG_HASH_SENDER }; + let arg_hash = PhysicalExpr::Param(param, AlgebraicType::U256); Self::Filter( Box::new(Self::TableScan(scan, label)), PhysicalExpr::BinOp( From b44156d1dacaacc358a4c3cc288126c10c510422 Mon Sep 17 00:00:00 2001 From: Ludv1gL <116286389+Ludv1gL@users.noreply.github.com> Date: Mon, 29 Jun 2026 18:48:05 +0200 Subject: [PATCH 3/5] fix(ts-sdk): Add primaryKey() and index() to SumBuilderImpl and SumColumnBuilder (#4389) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Codegen unconditionally emits `.primaryKey()` on all primary key columns, including enum types - `SumBuilderImpl` (used for **all** object-form enums via `__t.enum("Name", { ... })`) only implemented `Defaultable` and `Nameable` — not `PrimaryKeyable` or `Indexable` - This causes a runtime `TypeError: SC.primaryKey is not a function` whenever an enum type is used as a primary key column ## Fix Add `Indexable` and `PrimaryKeyable` interfaces and their methods to: - `SumBuilderImpl` (the base class for all enum type builders) - `SumColumnBuilder` (the column builder returned by `SumBuilderImpl` methods) This matches the existing pattern already present in `SimpleSumBuilderImpl` and `SimpleSumColumnBuilder`. ## Why SumBuilderImpl and not just SimpleSumBuilderImpl? Codegen always generates object-form enums: ```typescript export const PlatformModuleType = __t.enum("PlatformModuleType", { Standard: __t.unit, Control: __t.unit, }); ``` The runtime dispatch in `type_builders.ts` (line ~3656) routes object-form enums to `SumBuilder` (backed by `SumBuilderImpl`), never to `SimpleSumBuilder`. So `SimpleSumBuilderImpl`'s existing `primaryKey()`/`index()` methods are effectively dead code for codegen output. ## Test plan - [x] Verify enum types used as primary keys no longer throw `TypeError: SC.primaryKey is not a function` - [ ] Verify existing `SimpleSumBuilderImpl`/`SimpleSumColumnBuilder` behavior is unchanged (subclass overrides still work) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 Co-authored-by: Zeke Foppa <196249+bfops@users.noreply.github.com> --- .../src/lib/type_builders.ts | 62 ++++++++++++++++++- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/crates/bindings-typescript/src/lib/type_builders.ts b/crates/bindings-typescript/src/lib/type_builders.ts index 136e040a2b6..41d2da11015 100644 --- a/crates/bindings-typescript/src/lib/type_builders.ts +++ b/crates/bindings-typescript/src/lib/type_builders.ts @@ -1568,7 +1568,9 @@ class SumBuilderImpl extends TypeBuilder, VariantsToSumType> implements Defaultable, VariantsToSumType>, - Nameable, VariantsToSumType> + Nameable, VariantsToSumType>, + Indexable, VariantsToSumType>, + PrimaryKeyable, VariantsToSumType> { readonly variants: Variants; readonly typeName: string | undefined; @@ -1673,6 +1675,33 @@ class SumBuilderImpl ): SumColumnBuilder> { return new SumColumnBuilder(this, set(defaultMetadata, { name })); } + index(): SumColumnBuilder< + Variants, + SetField + >; + index>( + algorithm: N + ): SumColumnBuilder>; + index( + algorithm: IndexTypes = 'btree' + ): SumColumnBuilder< + Variants, + SetField + > { + return new SumColumnBuilder( + this, + set(defaultMetadata, { indexType: algorithm }) + ); + } + primaryKey(): SumColumnBuilder< + Variants, + SetField + > { + return new SumColumnBuilder( + this, + set(defaultMetadata, { isPrimaryKey: true }) + ); + } } export const SumBuilder: { @@ -3273,7 +3302,9 @@ export class SumColumnBuilder< extends ColumnBuilder, VariantsToSumType, M> implements Defaultable, VariantsToSumType>, - Nameable, VariantsToSumType> + Nameable, VariantsToSumType>, + Indexable, VariantsToSumType>, + PrimaryKeyable, VariantsToSumType> { default( value: EnumType @@ -3294,6 +3325,33 @@ export class SumColumnBuilder< set(this.columnMetadata, { name }) ); } + index(): SumColumnBuilder< + Variants, + SetField + >; + index>( + algorithm: N + ): SumColumnBuilder>; + index( + algorithm: IndexTypes = 'btree' + ): SumColumnBuilder< + Variants, + SetField + > { + return new SumColumnBuilder( + this.typeBuilder, + set(this.columnMetadata, { indexType: algorithm }) + ); + } + primaryKey(): SumColumnBuilder< + Variants, + SetField + > { + return new SumColumnBuilder( + this.typeBuilder, + set(this.columnMetadata, { isPrimaryKey: true }) + ); + } } export class SimpleSumColumnBuilder< From 92bd4bba88c44b4fcc6eafcf474479db6540109a Mon Sep 17 00:00:00 2001 From: Kilian Strunz <93079615+kistz@users.noreply.github.com> Date: Mon, 29 Jun 2026 19:18:57 +0200 Subject: [PATCH 4/5] [Rust] Capability based DbContext (#5307) # Description of Changes An evolution of https://github.com/clockworklabs/SpacetimeDB/pull/4707 where i explored adding a `db_read_only` method to `DbContext`. (hence i would appreciate your thoughts @gefjon ) This was the wrong appraoch in retrospect because there are still some annoyances with this. Furthermore there is really no benefit to adding the associated type `DbView` and is just making the ergonomics worse. So here is the new approach which solved all my problems: Making a trait for every capability which the various contexts are providing because the Databse is only one of them. These capabilities are (and pretty much the whole relevant div for this pr because the impl blocks are trivial) and should be self explanatory: ```rust pub trait CtxDbRead { fn db_read_only(&self) -> &LocalReadOnly; } pub trait CtxDbWrite: CtxDbRead { fn db(&self) -> &Local; } pub trait CtxWithSender { fn sender(&self) -> Identity; } pub trait CtxWithTimestamp { fn timestamp(&self) -> Timestamp; } pub trait CtxWithHttp { fn http(&self) -> &HttpClient; } ``` ## Why is this relevant? Lets look at an example building on the previous pr: You have abstracted your code in a trait which you can call for every context e.g. authorization. Now this does not work because the sender method is not available on `DbContext`. ```rust impl Authorization for Db { fn test(&self,args:Args) { self.db_read_only().do_i_have_perms().find(self.sender()); //ERROR: no sender method } ``` Now this is really annoying since you now have to pass additional parameters to the method. Instead we can now specific these capabilities in the type system: ```rust impl Authorization for Db { fn test(&self,args:Args) { self.db_read_only().do_i_have_perms().find(self.sender()); //WORKS NOW YAY } ``` Additonally there could be also a `+ CtxWithTimestamp` if you wanted to for example store a last logged in date or smth (you get the idea) Now this is far better because `.sender` is available for `ViewContext` for example so you can authorize with the same method. ## Alternatives/Bikeshedding I chose the names CtxWith because really the `Contexts` are the common denominator and not the `Database`. Thats also the reason why its `CtxDbRead` because you are expressing: "All context where i get read access to the databse (e.g. everything). Other names have felt worse. Also the deprecation can be removed but i think this approach is strictly superior and i dont think there are currently many people relying on it. # API and ABI breaking changes None. one deprecation for the old `DbContext` but this can also be removed if desired. # Expected complexity level and risk 1. Additive change with extremly minimal surface # Testing - [x] Works for my project --------- Signed-off-by: Kilian Strunz <93079615+kistz@users.noreply.github.com> Co-authored-by: Phoebe Goldman --- crates/bindings/src/http.rs | 16 +- crates/bindings/src/lib.rs | 325 +++++++++++++++++- modules/keynote-benchmarks/src/lib.rs | 6 +- .../sdk-test-procedure-concurrency/src/lib.rs | 12 +- modules/sdk-test-procedure/src/lib.rs | 8 +- 5 files changed, 333 insertions(+), 34 deletions(-) diff --git a/crates/bindings/src/http.rs b/crates/bindings/src/http.rs index 0fb8a63d53b..4c8ea487c47 100644 --- a/crates/bindings/src/http.rs +++ b/crates/bindings/src/http.rs @@ -13,7 +13,7 @@ use crate::StdbRng; #[cfg(feature = "unstable")] use crate::{try_with_tx, with_tx, Timestamp, TxContext}; use bytes::Bytes; -#[cfg(all(feature = "rand", feature = "unstable"))] +#[cfg(all(feature = "rand08", feature = "unstable"))] use rand08::RngCore; #[cfg(feature = "unstable")] use spacetimedb_lib::db::raw_def::v10::MethodOrAny; @@ -22,10 +22,10 @@ use spacetimedb_lib::http as st_http; use spacetimedb_lib::http::{character_is_acceptable_for_route_path, ACCEPTABLE_ROUTE_PATH_CHARS_HUMAN_DESCRIPTION}; #[cfg(feature = "unstable")] use spacetimedb_lib::Identity; -#[cfg(all(feature = "unstable", feature = "rand"))] +#[cfg(all(feature = "unstable", feature = "rand08"))] use spacetimedb_lib::Uuid; use spacetimedb_lib::{bsatn, TimeDuration}; -#[cfg(all(feature = "unstable", feature = "rand"))] +#[cfg(all(feature = "unstable", feature = "rand08"))] use std::cell::Cell; #[cfg(all(feature = "unstable", feature = "rand08"))] use std::cell::OnceCell; @@ -72,7 +72,7 @@ pub use spacetimedb_bindings_macro::http_handler as handler; /// Register a [`Router`](struct@Router) to route HTTP requests to handlers. /// -/// This should annotate a function of no arguments which returns a [`Router`](struct@router). +/// This should annotate a function of no arguments which returns a [`Router`](struct@Router). /// /// ```no_run /// # use spacetimedb::http::{handler, router, Request, Response, Body, HandlerContext, Router}; @@ -109,7 +109,7 @@ pub struct HandlerContext { /// A counter used for generating UUIDv7 values. /// **Note:** must be 0..=u32::MAX - #[cfg(feature = "rand")] + #[cfg(feature = "rand08")] pub(crate) counter_uuid: Cell, } @@ -121,7 +121,7 @@ impl HandlerContext { http: HttpClient {}, #[cfg(feature = "rand08")] rng: OnceCell::new(), - #[cfg(feature = "rand")] + #[cfg(feature = "rand08")] counter_uuid: Cell::new(0), } } @@ -148,7 +148,7 @@ impl HandlerContext { } /// Create a new random [`Uuid`] `v4` using the built-in RNG. - #[cfg(feature = "rand")] + #[cfg(feature = "rand08")] pub fn new_uuid_v4(&self) -> anyhow::Result { let mut bytes = [0u8; 16]; self.rng().try_fill_bytes(&mut bytes)?; @@ -156,7 +156,7 @@ impl HandlerContext { } /// Create a new sortable [`Uuid`] `v7` using the built-in RNG, counter and timestamp. - #[cfg(feature = "rand")] + #[cfg(feature = "rand08")] pub fn new_uuid_v7(&self) -> anyhow::Result { let mut random_bytes = [0u8; 4]; self.rng().try_fill_bytes(&mut random_bytes)?; diff --git a/crates/bindings/src/lib.rs b/crates/bindings/src/lib.rs index 0d375719829..2c7faa78c6b 100644 --- a/crates/bindings/src/lib.rs +++ b/crates/bindings/src/lib.rs @@ -1,9 +1,11 @@ #![doc = include_str!("../README.md")] // ^ if you are working on docs, go read the top comment of README.md please. -use core::cell::{Cell, LazyCell, OnceCell, RefCell}; +use core::cell::{LazyCell, OnceCell, RefCell}; use core::ops::Deref; use spacetimedb_lib::bsatn; +#[cfg(feature = "rand08")] +use std::cell::Cell; use std::rc::Rc; #[cfg(feature = "unstable")] @@ -24,9 +26,11 @@ pub use spacetimedb_query_builder as query_builder; #[cfg(feature = "unstable")] pub use client_visibility_filter::Filter; pub use log; -#[cfg(feature = "rand")] +#[cfg(feature = "rand08")] +use rand::distributions::{Distribution, Standard}; +#[cfg(feature = "rand08")] pub use rand08 as rand; -#[cfg(feature = "rand")] +#[cfg(feature = "rand08")] use rand08::RngCore; #[cfg(feature = "rand08")] pub use rng::StdbRng; @@ -42,6 +46,9 @@ pub use spacetimedb_lib::ser::Serialize; pub use spacetimedb_lib::AlgebraicValue; pub use spacetimedb_lib::ConnectionId; // `FilterableValue` re-exported purely for rustdoc. +#[cfg(feature = "unstable")] +use crate::http::HandlerContext; +use crate::http::HttpClient; pub use spacetimedb_lib::FilterableValue; pub use spacetimedb_lib::Identity; pub use spacetimedb_lib::ScheduleAt; @@ -1027,7 +1034,7 @@ pub struct ReducerContext { rng: std::cell::OnceCell, /// A counter used for generating UUIDv7 values. /// **Note:** must be 0..=u32::MAX - #[cfg(feature = "rand")] + #[cfg(feature = "rand08")] counter_uuid: Cell, } @@ -1042,7 +1049,7 @@ impl ReducerContext { sender_auth: AuthCtx::internal(), #[cfg(feature = "rand08")] rng: std::cell::OnceCell::new(), - #[cfg(feature = "rand")] + #[cfg(feature = "rand08")] counter_uuid: Cell::new(0), } } @@ -1057,7 +1064,7 @@ impl ReducerContext { sender_auth: AuthCtx::from_connection_id_opt(connection_id), #[cfg(feature = "rand08")] rng: std::cell::OnceCell::new(), - #[cfg(feature = "rand")] + #[cfg(feature = "rand08")] counter_uuid: Cell::new(0), } } @@ -1123,7 +1130,7 @@ impl ReducerContext { /// } /// # } /// ``` - #[cfg(feature = "rand")] + #[cfg(feature = "rand08")] pub fn new_uuid_v4(&self) -> anyhow::Result { let mut bytes = [0u8; 16]; self.rng().try_fill_bytes(&mut bytes)?; @@ -1145,7 +1152,7 @@ impl ReducerContext { /// } /// # } /// ``` - #[cfg(feature = "rand")] + #[cfg(feature = "rand08")] pub fn new_uuid_v7(&self) -> anyhow::Result { let mut random_bytes = [0u8; 4]; self.rng().try_fill_bytes(&mut random_bytes)?; @@ -1268,7 +1275,7 @@ pub struct ProcedureContext { /// A counter used for generating UUIDv7 values. /// **Note:** must be 0..=u32::MAX // Disabled when compiling without `rand`, as both v4 and v7 UUIDs have random components. - #[cfg(feature = "rand")] + #[cfg(feature = "rand08")] counter_uuid: Cell, } @@ -1281,7 +1288,7 @@ impl ProcedureContext { http: http::HttpClient {}, #[cfg(feature = "rand08")] rng: std::cell::OnceCell::new(), - #[cfg(feature = "rand")] + #[cfg(feature = "rand08")] counter_uuid: Cell::new(0), } } @@ -1421,7 +1428,7 @@ impl ProcedureContext { /// } /// # } /// ``` - #[cfg(feature = "rand")] + #[cfg(feature = "rand08")] pub fn new_uuid_v4(&self) -> anyhow::Result { let mut bytes = [0u8; 16]; self.rng().try_fill_bytes(&mut bytes)?; @@ -1443,7 +1450,7 @@ impl ProcedureContext { /// } /// # } /// ``` - #[cfg(feature = "rand")] + #[cfg(feature = "rand08")] pub fn new_uuid_v7(&self) -> anyhow::Result { let mut random_bytes = [0u8; 4]; self.rng().try_fill_bytes(&mut random_bytes)?; @@ -1452,6 +1459,7 @@ impl ProcedureContext { } /// A handle on a database with a particular table schema. +#[deprecated(note = "Use the capability based traits (CtxDbRead, CtxDbWrite) instead!")] pub trait DbContext { /// A view into the tables of a database. /// @@ -1473,6 +1481,7 @@ pub trait DbContext { fn db_read_only(&self) -> &LocalReadOnly; } +#[allow(deprecated)] impl DbContext for AnonymousViewContext { type DbView = LocalReadOnly; @@ -1485,6 +1494,7 @@ impl DbContext for AnonymousViewContext { } } +#[allow(deprecated)] impl DbContext for ReducerContext { type DbView = Local; @@ -1497,6 +1507,7 @@ impl DbContext for ReducerContext { } } +#[allow(deprecated)] impl DbContext for TxContext { type DbView = Local; @@ -1509,6 +1520,7 @@ impl DbContext for TxContext { } } +#[allow(deprecated)] impl DbContext for ViewContext { type DbView = LocalReadOnly; @@ -1538,6 +1550,295 @@ impl Local { } } +/// Contexts which provide read access to the database. +/// +/// This trait is useful for writing reusable logic which is generic over the context type, +/// allowing it to be used from views, reducers, and transactions started by procedures and HTTP handlers. +/// +/// When operating on a concrete-typed [`ViewContext`], [`ReducerContext`] or [`TxContext`], +/// this trait is not necessary, as the context's `db` field provides the same (or greater, read-write) access. +pub trait CtxDbRead { + fn db_read_only(&self) -> &LocalReadOnly; +} + +impl CtxDbRead for TxContext { + fn db_read_only(&self) -> &LocalReadOnly { + &LocalReadOnly {} + } +} + +impl CtxDbRead for ReducerContext { + fn db_read_only(&self) -> &LocalReadOnly { + &LocalReadOnly {} + } +} + +impl CtxDbRead for ViewContext { + fn db_read_only(&self) -> &LocalReadOnly { + &LocalReadOnly {} + } +} + +impl CtxDbRead for AnonymousViewContext { + fn db_read_only(&self) -> &LocalReadOnly { + &LocalReadOnly {} + } +} + +/// Contexts which provide read-write access to the database. +/// +/// This trait is useful for writing reusable logic which is generic over the context type, +/// allowing it to be used from reducers and from transactions started by procedures and HTTP handlers. +/// +/// When operating on a concrete-typed [`ReducerContext`] or [`TxContext`], this trait is not necessary, +/// as the context's `db` field provides the same access. +pub trait CtxDbWrite: CtxDbRead { + fn db(&self) -> &Local; +} + +impl CtxDbWrite for TxContext { + fn db(&self) -> &Local { + &Local {} + } +} + +impl CtxDbWrite for ReducerContext { + fn db(&self) -> &Local { + &Local {} + } +} + +/// Contexts which can retrieve the sender [`Identity`]. +/// +/// This trait is useful for writing reusable logic which is generic over the context type, +/// allowing it to be used from views, reducers, and transactions started by procedures and HTTP handlers. +/// +/// When operating on a concrete-typed [`ViewContext`], [`ReducerContext`], [`ProcedureContext`] or [`TxContext`], +/// this trait is not necessary, as the context's inherent `sender` method provides the same access. +pub trait CtxWithSender { + fn sender(&self) -> Identity; +} + +impl CtxWithSender for ViewContext { + fn sender(&self) -> Identity { + self.sender + } +} + +impl CtxWithSender for ReducerContext { + fn sender(&self) -> Identity { + self.sender + } +} + +impl CtxWithSender for TxContext { + fn sender(&self) -> Identity { + self.0.sender + } +} + +impl CtxWithSender for ProcedureContext { + fn sender(&self) -> Identity { + self.sender + } +} + +/// Contexts which can retrieve the current [`Timestamp`]. +/// +/// This trait is useful for writing reusable logic which is generic over the context type, +/// allowing it to be used from reducers, procedures, HTTP handlers, +/// and transactions started by procedures and HTTP handlers. +/// +/// When operating on a concrete-typed [`ReducerContext`], [`ProcedureContext`] +#[cfg_attr(feature = "unstable", doc = ", [`HandlerContext`]")] +/// or [`TxContext`], +/// this trait is not necessary, as the context's `timestamp` field provides the same access. +pub trait CtxWithTimestamp { + fn timestamp(&self) -> Timestamp; +} + +impl CtxWithTimestamp for ReducerContext { + fn timestamp(&self) -> Timestamp { + self.timestamp + } +} + +impl CtxWithTimestamp for TxContext { + fn timestamp(&self) -> Timestamp { + self.timestamp + } +} + +impl CtxWithTimestamp for ProcedureContext { + fn timestamp(&self) -> Timestamp { + self.timestamp + } +} + +#[cfg(feature = "unstable")] +impl CtxWithTimestamp for HandlerContext { + fn timestamp(&self) -> Timestamp { + self.timestamp + } +} + +/// Contexts which can retrieve the current [`AuthCtx`]. +/// +/// This trait is useful for writing reusable logic which is generic over the context type, +/// allowing it to be used from reducers and procedures. +/// +/// When operating on a concrete-typed [`ReducerContext`], [`ProcedureContext`], [`TxContext`], +/// this trait is not necessary, as the context's sender_auth method provides the same access. +pub trait CtxWithSenderAuth { + fn sender_auth(&self) -> &AuthCtx; +} + +impl CtxWithSenderAuth for ReducerContext { + fn sender_auth(&self) -> &AuthCtx { + self.sender_auth() + } +} + +impl CtxWithSenderAuth for TxContext { + fn sender_auth(&self) -> &AuthCtx { + self.0.sender_auth() + } +} + +/// Contexts which can enter a start a transaction with a [`TxContext`] inside the function. +/// +/// This trait is useful for writing reusable logic which is generic over the context type, +/// allowing it to be used from reducers and procedures. +/// +/// When operating on a concrete-typed +#[cfg_attr(feature = "unstable", doc = "[`HandlerContext`] or")] +/// [`ProcedureContext`], +/// this trait is not necessary, as the context's methods provide the same access. +pub trait CtxWithTxManagement { + fn with_tx(&mut self, body: impl Fn(&TxContext) -> T) -> T; + fn try_with_tx(&mut self, body: impl Fn(&TxContext) -> Result) -> Result; +} + +impl CtxWithTxManagement for ProcedureContext { + fn with_tx(&mut self, body: impl Fn(&TxContext) -> T) -> T { + self.with_tx(body) + } + + fn try_with_tx(&mut self, body: impl Fn(&TxContext) -> Result) -> Result { + self.try_with_tx(body) + } +} + +#[cfg(feature = "unstable")] +impl CtxWithTxManagement for HandlerContext { + fn with_tx(&mut self, body: impl Fn(&TxContext) -> T) -> T { + self.with_tx(body) + } + + fn try_with_tx(&mut self, body: impl Fn(&TxContext) -> Result) -> Result { + self.try_with_tx(body) + } +} + +/// Contexts which can retrieve the current [`StdbRng`] state. +/// +/// This trait is useful for writing reusable logic which is generic over the context type, +/// allowing it to be used from reducers and procedures. +/// +/// When operating on a concrete-typed +#[cfg_attr(feature = "unstable", doc = "[`HandlerContext`],")] +/// [`ProcedureContext`], [`ReducerContext`], [`TxContext`] +/// this trait is not necessary, as the context's methods provide the same access. +#[cfg(feature = "rand08")] +pub trait CtxWithRng { + fn rng(&self) -> &StdbRng; + fn random(&self) -> T + where + Standard: Distribution; +} + +#[cfg(feature = "rand08")] +impl CtxWithRng for ProcedureContext { + fn rng(&self) -> &StdbRng { + self.rng() + } + + fn random(&self) -> T + where + Standard: Distribution, + { + self.random() + } +} + +#[cfg(all(feature = "unstable", feature = "rand08"))] +impl CtxWithRng for HandlerContext { + fn rng(&self) -> &StdbRng { + self.rng() + } + + fn random(&self) -> T + where + Standard: Distribution, + { + self.random() + } +} + +#[cfg(feature = "rand08")] +impl CtxWithRng for ReducerContext { + fn rng(&self) -> &StdbRng { + self.rng() + } + + fn random(&self) -> T + where + Standard: Distribution, + { + self.random() + } +} + +#[cfg(feature = "rand08")] +impl CtxWithRng for TxContext { + fn rng(&self) -> &StdbRng { + self.0.rng() + } + + fn random(&self) -> T + where + Standard: Distribution, + { + self.0.random() + } +} + +/// Contexts which can perform outgoing HTTP requests. +/// +/// This type is useful for writing reusable logic which is generic over the context type, +/// allowing it to be used from procedures and HTTP handlers. +/// +/// When operating on a concrete-typed [`ProcedureContext`] +#[cfg_attr(feature = "unstable", doc = "or [`HandlerContext`],")] +/// this trait is not necessary, +/// as the context's `http` field provides the same access. +pub trait CtxWithHttp { + fn http(&self) -> &HttpClient; +} + +#[cfg(feature = "unstable")] +impl CtxWithHttp for HandlerContext { + fn http(&self) -> &HttpClient { + &self.http + } +} + +impl CtxWithHttp for ProcedureContext { + fn http(&self) -> &HttpClient { + &self.http + } +} + /// The [JWT] of an [`AuthCtx`]. /// /// [JWT]: https://en.wikipedia.org/wiki/JSON_Web_Token diff --git a/modules/keynote-benchmarks/src/lib.rs b/modules/keynote-benchmarks/src/lib.rs index 3ba8394089c..70d68bc0152 100644 --- a/modules/keynote-benchmarks/src/lib.rs +++ b/modules/keynote-benchmarks/src/lib.rs @@ -1,5 +1,5 @@ use core::ops::AddAssign; -use spacetimedb::{log_stopwatch::LogStopwatch, rand::Rng, reducer, table, DbContext, ReducerContext, Table}; +use spacetimedb::{log_stopwatch::LogStopwatch, rand::Rng, reducer, table, ReducerContext, Table}; #[derive(Clone, Copy, Debug)] #[table(accessor = position, public)] @@ -39,7 +39,7 @@ fn init(ctx: &ReducerContext) { // Insert 10^6 randomized positions and velocities, // but with incrementing and corresponding ids. - let db = ctx.db(); + let db = &ctx.db; let mut rng = ctx.rng(); for id in 0..1_000_000 { let (x, y, z) = rng.r#gen(); @@ -71,7 +71,7 @@ fn update_positions_by_collect(ctx: &ReducerContext) { #[reducer] fn roundtrip(ctx: &ReducerContext) { // Warmup the index. - let id = ctx.db().velocity().id(); + let id = ctx.db.velocity().id(); for x in 0..10_000 { id.find(x); } diff --git a/modules/sdk-test-procedure-concurrency/src/lib.rs b/modules/sdk-test-procedure-concurrency/src/lib.rs index 4f99c3a422e..e8874309f8e 100644 --- a/modules/sdk-test-procedure-concurrency/src/lib.rs +++ b/modules/sdk-test-procedure-concurrency/src/lib.rs @@ -1,6 +1,4 @@ -use spacetimedb::{ - procedure, reducer, table, DbContext, ProcedureContext, ReducerContext, ScheduleAt, Table, TxContext, -}; +use spacetimedb::{procedure, reducer, table, ProcedureContext, ReducerContext, ScheduleAt, Table, TxContext}; use std::time::Duration; #[table(public, accessor = procedure_concurrency_row)] @@ -19,7 +17,7 @@ fn insert_procedure_concurrency_row(ctx: &TxContext, insertion_context: &str) { #[reducer] fn insert_reducer_row(ctx: &ReducerContext) { - ctx.db().procedure_concurrency_row().insert(ProcedureConcurrencyRow { + ctx.db.procedure_concurrency_row().insert(ProcedureConcurrencyRow { insertion_order: 0, insertion_context: "reducer".into(), }); @@ -82,7 +80,7 @@ struct ScheduledReducerRow { #[reducer] fn insert_scheduled_reducer(ctx: &ReducerContext, _schedule: ScheduledReducerRow) { - ctx.db().procedure_concurrency_row().insert(ProcedureConcurrencyRow { + ctx.db.procedure_concurrency_row().insert(ProcedureConcurrencyRow { insertion_order: 0, insertion_context: "scheduled_reducer".into(), }); @@ -130,11 +128,11 @@ fn scheduled_procedure_sleep_between_inserts(ctx: &mut ProcedureContext, _schedu #[reducer] fn schedule_procedure_then_reducer(ctx: &ReducerContext) { - ctx.db().scheduled_procedure_row().insert(ScheduledProcedureRow { + ctx.db.scheduled_procedure_row().insert(ScheduledProcedureRow { scheduled_id: 0, scheduled_at: ctx.timestamp.into(), }); - ctx.db().scheduled_reducer_row().insert(ScheduledReducerRow { + ctx.db.scheduled_reducer_row().insert(ScheduledReducerRow { scheduled_id: 0, scheduled_at: (ctx.timestamp + Duration::from_secs(2)).into(), }); diff --git a/modules/sdk-test-procedure/src/lib.rs b/modules/sdk-test-procedure/src/lib.rs index a8befa92e3b..5eb2f848ad5 100644 --- a/modules/sdk-test-procedure/src/lib.rs +++ b/modules/sdk-test-procedure/src/lib.rs @@ -1,6 +1,6 @@ use spacetimedb::{ - duration, procedure, reducer, table, DbContext, ProcedureContext, ReducerContext, ScheduleAt, SpacetimeType, Table, - Timestamp, TxContext, Uuid, + duration, procedure, reducer, table, ProcedureContext, ReducerContext, ScheduleAt, SpacetimeType, Table, Timestamp, + TxContext, Uuid, }; #[derive(SpacetimeType)] @@ -108,7 +108,7 @@ fn insert_with_tx_rollback(ctx: &mut ProcedureContext) { #[reducer] fn schedule_proc(ctx: &ReducerContext) { // Schedule the procedure to run in 1s. - ctx.db().scheduled_proc_table().insert(ScheduledProcTable { + ctx.db.scheduled_proc_table().insert(ScheduledProcTable { scheduled_id: 0, scheduled_at: duration!("1000ms").into(), // Store the timestamp at which this reducer was called. @@ -136,7 +136,7 @@ fn scheduled_proc(ctx: &mut ProcedureContext, data: ScheduledProcTable) { let ScheduledProcTable { reducer_ts, x, y, .. } = data; let procedure_ts = ctx.timestamp; ctx.with_tx(|ctx| { - ctx.db().proc_inserts_into().insert(ProcInsertsInto { + ctx.db.proc_inserts_into().insert(ProcInsertsInto { reducer_ts, procedure_ts, x, From c9c7115c1588ee2b55b87e04fb92e5dda471e9c0 Mon Sep 17 00:00:00 2001 From: Ludv1gL <116286389+Ludv1gL@users.noreply.github.com> Date: Mon, 29 Jun 2026 21:19:48 +0200 Subject: [PATCH 5/5] fix(client-api): parse single-IP X-Forwarded-For values (#4839) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `XForwardedFor::decode` requires a comma and fails to decode `X-Forwarded-For: 1.2.3.4`. Under axum 0.8 this now surfaces as HTTP 400 on every single-hop-proxy request. ## Fix Accept comma-separated or single-IP values — take the first entry either way. ## Reproducer Any browser going through a proxy that does `.insert("x-forwarded-for", client_ip)` (standard single-hop pattern) sees 400 Bad Request on WebSocket subscribe. ## Version regression Bug latent since the axum migration in 2023-06 (commit b4dae7475). axum 0.7 silently dropped the decoder error via `Option>`; axum 0.8 (#2713) promotes it to a 400 rejection. Co-authored-by: Zeke Foppa <196249+bfops@users.noreply.github.com> --- crates/client-api/src/util.rs | 44 +++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/crates/client-api/src/util.rs b/crates/client-api/src/util.rs index adc1f632bc4..0cc87a82bfd 100644 --- a/crates/client-api/src/util.rs +++ b/crates/client-api/src/util.rs @@ -49,8 +49,10 @@ impl headers::Header for XForwardedFor { fn decode<'i, I: Iterator>(values: &mut I) -> Result { let val = values.next().ok_or_else(headers::Error::invalid)?; let val = val.to_str().map_err(|_| headers::Error::invalid())?; - let (first, _) = val.split_once(',').ok_or_else(headers::Error::invalid)?; - let ip = first.trim().parse().map_err(|_| headers::Error::invalid())?; + // X-Forwarded-For is a comma-separated chain. For a single-hop + // proxy there is no comma; take the first IP either way. + let first = val.split(',').next().unwrap_or(val).trim(); + let ip = first.parse().map_err(|_| headers::Error::invalid())?; Ok(XForwardedFor(ip)) } @@ -185,3 +187,41 @@ impl FromRequest for EmptyBody { Ok(Self) } } + +#[cfg(test)] +mod tests { + use super::*; + use headers::Header; + + fn decode_one(raw: &str) -> Result { + let val = HeaderValue::from_str(raw).unwrap(); + let values = [val]; + XForwardedFor::decode(&mut values.iter()) + } + + #[test] + fn decodes_single_ip() { + // Single-hop proxies (e.g. nginx inserting the client IP) emit + // a value with no comma. This must succeed. + let got = decode_one("10.0.0.1").expect("single IP should decode"); + assert_eq!(got.0, "10.0.0.1".parse::().unwrap()); + } + + #[test] + fn decodes_chain_takes_first() { + let got = decode_one("10.0.0.1, 192.168.1.1, 172.16.0.1").expect("chain should decode"); + assert_eq!(got.0, "10.0.0.1".parse::().unwrap()); + } + + #[test] + fn decodes_chain_trims_whitespace() { + let got = decode_one(" 10.0.0.1 , 192.168.1.1").expect("chain should decode"); + assert_eq!(got.0, "10.0.0.1".parse::().unwrap()); + } + + #[test] + fn rejects_non_ip() { + assert!(decode_one("not-an-ip").is_err()); + assert!(decode_one("not-an-ip, 10.0.0.1").is_err()); + } +}