Files
Ryan f9ccf4c1c4 Move Bound struct out of SpacetimeDB.Internal to SpacetimeDB and Local out of SpacetiemDB.Runtime (#3996)
# Description of Changes
This PR fixes a C# SDK regression where using `Bound` in index filters
could trigger an ambiguous reference compiler error for Local after
upgrading to `v1.11.2`, as reported in
[#3995](https://github.com/clockworklabs/SpacetimeDB/issues/3995).
It also fixes a related warning-spam regression (`CS0436`) where user
projects could see `Local` type conflicts between generated module code
and the `SpacetimeDB.Runtime` assembly.

* Introduced a public `SpacetimeDB.Bound` type so users no longer need
to import `SpacetimeDB.Internal` to use bounds in index filters.
* Kept `SpacetimeDB.Internal.Bound` for compatibility, but added
implicit conversions between `SpacetimeDB.Internal.Bound` and
`SpacetimeDB.Bound`.
* Updated the C# code generator to emit fully-qualified
`global::SpacetimeDB.Bound` in generated index filter signatures,
avoiding `SpacetimeDB.Internal` in public-facing APIs and preventing
name collisions (e.g., `Local`).
* Updated internal runtime bounds helpers (`BTreeIndexBounds<...>`) to
explicitly use `SpacetimeDB.Bound` when constructing range-scan
arguments.
* Updated Codegen snapshot fixtures to match the new generated output
(type name + formatting).
* Fixed codegen output for `ITableView` `static abstract` member
implementations to generate `public static` methods (required for the
generated code to compile).
It also fixes a related warning-spam regression (CS0436) where user
projects could see Local type conflicts between generated module code
and the SpacetimeDB.Runtime assembly.

Additional fix (related to the `Local` reports):
* Removed the runtime assembly’s ownership of `SpacetimeDB.Local`
(introduced more recently than the generated module `Local`) to prevent
`CS0436` duplicate-type warnings. Basically, the runtime’s concrete
`Local`/`ProcedureTxContext` helpers were renamed and made internal so
the code generator remains the sole owner of module-level
`SpacetimeDB.Local`.

Regression coverage:
* Added generator regression assertions to ensure generated code does
not reference `global::SpacetimeDB.Internal.Bound<...>` and does
reference `global::SpacetimeDB.Bound<...>`.
* Added a runtime API regression assertion that `SpacetimeDB.Bound`
exists and is public in the runtime reference.
* Added a regression assertion that `SpacetimeDB.Runtime` does not
define codegen-owned types (e.g. `SpacetimeDB.Local`,
`ProcedureContext`, etc.) to prevent future `CS0436` conflicts.
* Added a “simulated downstream user file” compile check ensuring no
`CS0436` diagnostics occur when user code references
`SpacetimeDB.Local`.
# API and ABI breaking changes
None.
* No schema or wire-format changes.
* The changes are limited to C# type exposure / naming and codegen
output.
* `SpacetimeDB.Internal.Bound` remains usable via implicit conversions
(backwards compatible for existing code).
# Expected complexity level and risk
2 - Low
* Changes are isolated to C# runtime type exposure, codegen type
references, and snapshot updates.
* No runtime behavior changes to index scan encoding/decoding; only
avoids requiring SpacetimeDB.Internal in user code.
# Testing
- [X] Ran:`dotnet test
crates/bindings-csharp/Codegen.Tests/Codegen.Tests.csproj`
- [X] Ran regression tests locally.
2026-01-14 22:42:30 +00:00

270 lines
7.8 KiB
C#

namespace SpacetimeDB;
using System.Diagnostics.CodeAnalysis;
#pragma warning disable STDB_UNSTABLE
public abstract class ProcedureContextBase(
Identity sender,
ConnectionId? connectionId,
Random random,
Timestamp time
) : Internal.IInternalProcedureContext
{
public static Identity Identity => Internal.IProcedureContext.GetIdentity();
public Identity Sender { get; } = sender;
public ConnectionId? ConnectionId { get; } = connectionId;
public Random Rng { get; } = random;
public Timestamp Timestamp { get; private set; } = time;
public AuthCtx SenderAuth { get; } = AuthCtx.BuildFromSystemTables(connectionId, sender);
// NOTE: The host rejects procedure HTTP requests while a mut transaction is open
// (WOULD_BLOCK_TRANSACTION). Avoid calling `Http.*` inside WithTx.
public HttpClient Http { get; } = new();
// **Note:** must be 0..=u32::MAX
protected int CounterUuid = 0;
private Internal.TxContext? txContext;
private ProcedureTxContextBase? cachedUserTxContext;
protected abstract ProcedureTxContextBase CreateTxContext(Internal.TxContext inner);
protected internal abstract LocalBase CreateLocal();
private protected ProcedureTxContextBase RequireTxContext()
{
var inner =
txContext
?? throw new InvalidOperationException("Transaction context was not initialised.");
cachedUserTxContext ??= CreateTxContext(inner);
cachedUserTxContext.Refresh(inner);
return cachedUserTxContext;
}
public Internal.TxContext EnterTxContext(long timestampMicros)
{
var timestamp = new Timestamp(timestampMicros);
Timestamp = timestamp;
txContext =
txContext?.WithTimestamp(timestamp)
?? new Internal.TxContext(
CreateLocal(),
Sender,
ConnectionId,
timestamp,
SenderAuth,
Rng
);
return txContext;
}
public void ExitTxContext() => txContext = null;
public readonly struct TxOutcome<TResult>(bool isSuccess, TResult? value, Exception? error)
{
public bool IsSuccess { get; } = isSuccess;
public TResult? Value { get; } = value;
public Exception? Error { get; } = error;
public static TxOutcome<TResult> Success(TResult value) => new(true, value, null);
public static TxOutcome<TResult> Failure(Exception error) => new(false, default, error);
public TResult UnwrapOrThrow() =>
IsSuccess
? Value!
: throw (
Error
?? new InvalidOperationException("Transaction failed without an error object.")
);
public TResult UnwrapOrThrow(Func<Exception> fallbackFactory) =>
IsSuccess ? Value! : throw (Error ?? fallbackFactory());
}
[Experimental("STDB_UNSTABLE")]
public TResult WithTx<TResult>(Func<ProcedureTxContextBase, TResult> body) =>
TryWithTx(tx => Result<TResult, Exception>.Ok(body(tx))).UnwrapOrThrow();
[Experimental("STDB_UNSTABLE")]
public TxOutcome<TResult> TryWithTx<TResult, TError>(
Func<ProcedureTxContextBase, Result<TResult, TError>> body
)
where TError : Exception
{
try
{
var result = RunWithRetry(body);
return result switch
{
Result<TResult, TError>.OkR(var value) => TxOutcome<TResult>.Success(value),
Result<TResult, TError>.ErrR(var error) => TxOutcome<TResult>.Failure(error),
_ => throw new InvalidOperationException("Unknown Result variant."),
};
}
catch (Exception ex)
{
return TxOutcome<TResult>.Failure(ex);
}
}
// Private transaction management methods (Rust-like encapsulation)
private long StartMutTx()
{
var status = Internal.FFI.procedure_start_mut_tx(out var micros);
Internal.FFI.ErrnoHelpers.ThrowIfError(status);
return micros;
}
private void CommitMutTx()
{
var status = Internal.FFI.procedure_commit_mut_tx();
Internal.FFI.ErrnoHelpers.ThrowIfError(status);
}
private void AbortMutTx()
{
var status = Internal.FFI.procedure_abort_mut_tx();
Internal.FFI.ErrnoHelpers.ThrowIfError(status);
}
private bool CommitMutTxWithRetry(Func<bool> retryBody)
{
try
{
CommitMutTx();
return true;
}
catch (TransactionNotAnonymousException)
{
return false;
}
catch (StdbException)
{
Log.Warn("Committing anonymous transaction failed; retrying once.");
if (retryBody())
{
CommitMutTx();
return true;
}
return false;
}
}
private Result<TResult, TError> RunWithRetry<TResult, TError>(
Func<ProcedureTxContextBase, Result<TResult, TError>> body
)
where TError : Exception
{
var result = RunOnce(body);
if (result is Result<TResult, TError>.ErrR)
{
return result;
}
bool Retry()
{
result = RunOnce(body);
return result is Result<TResult, TError>.OkR;
}
if (!CommitMutTxWithRetry(Retry))
{
return result;
}
return result;
}
private Result<TResult, TError> RunOnce<TResult, TError>(
Func<ProcedureTxContextBase, Result<TResult, TError>> body
)
where TError : Exception
{
var micros = StartMutTx();
using var guard = new AbortGuard(AbortMutTx);
EnterTxContext(micros);
var txCtx = RequireTxContext();
Result<TResult, TError> result;
try
{
result = body(txCtx);
}
catch (Exception)
{
throw;
}
if (result is Result<TResult, TError>.OkR)
{
guard.Disarm();
return result;
}
AbortMutTx();
guard.Disarm();
return result;
}
private sealed class AbortGuard(Action abort) : IDisposable
{
private readonly Action abort = abort;
private bool disarmed;
public void Disarm() => disarmed = true;
public void Dispose()
{
if (!disarmed)
{
abort();
}
}
}
}
public abstract class ProcedureTxContextBase(Internal.TxContext inner)
{
internal Internal.TxContext Inner { get; private set; } = inner;
internal void Refresh(Internal.TxContext inner) => Inner = inner;
public LocalBase Db => (LocalBase)Inner.Db;
public Identity Sender => Inner.Sender;
public ConnectionId? ConnectionId => Inner.ConnectionId;
public Timestamp Timestamp => Inner.Timestamp;
public AuthCtx SenderAuth => Inner.SenderAuth;
public Random Rng => Inner.Rng;
}
public abstract class LocalBase : Internal.Local { }
internal sealed partial class RuntimeProcedureContext(
Identity sender,
ConnectionId? connectionId,
Random random,
Timestamp timestamp
) : ProcedureContextBase(sender, connectionId, random, timestamp)
{
private readonly RuntimeLocal _db = new();
protected internal override LocalBase CreateLocal() => _db;
protected override ProcedureTxContextBase CreateTxContext(Internal.TxContext inner) =>
_cached ??= new RuntimeProcedureTxContext(inner);
private RuntimeProcedureTxContext? _cached;
}
internal sealed class RuntimeProcedureTxContext : ProcedureTxContextBase
{
internal RuntimeProcedureTxContext(Internal.TxContext inner)
: base(inner) { }
public new RuntimeLocal Db => (RuntimeLocal)base.Db;
}
internal sealed class RuntimeLocal : LocalBase { }
#pragma warning restore STDB_UNSTABLE