Files
joshua-spacetime a91be36596 Add primary keys to procedural views in C# (#5246)
# Description of Changes

Adds primary keys to procedural views in C#. See for #5111 for the
equivalent feature in rust and C# as well as a more detailed
description.

# API and ABI breaking changes

None

# Expected complexity level and risk

3

# Testing

- [x] Equivalent tests as were added in #5111 for rust and typescript
2026-06-10 00:51:01 +00:00

1527 lines
47 KiB
C#

// Server module for regression tests.
// Everything we're testing for happens SDK-side so this module is very uninteresting.
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using SpacetimeDB;
[SpacetimeDB.Type]
public partial class ReturnStruct
{
public uint A;
public string B;
public ReturnStruct(uint a, string b)
{
A = a;
B = b;
}
public ReturnStruct()
{
A = 0;
B = string.Empty;
}
}
[SpacetimeDB.Type]
public partial record ReturnEnum : SpacetimeDB.TaggedEnum<(uint A, string B)>;
[SpacetimeDB.Type]
public partial struct DbVector2
{
public int X;
public int Y;
}
public static partial class Module
{
[SpacetimeDB.Table(Accessor = "my_table", Public = true)]
public partial struct MyTable
{
public ReturnStruct Field;
}
[SpacetimeDB.Table(Accessor = "where_test", Public = true)]
public partial struct WhereTest
{
[SpacetimeDB.PrimaryKey]
public uint Id;
[SpacetimeDB.Index.BTree]
public uint Value;
public string Name;
}
[SpacetimeDB.Table(Accessor = "example_data", Public = true)]
public partial struct ExampleData
{
[SpacetimeDB.PrimaryKey]
public uint Id;
[SpacetimeDB.Index.BTree]
public uint Indexed;
}
[SpacetimeDB.Table(Accessor = "my_log", Public = true)]
public partial struct MyLog
{
public Result<MyTable, string> msg;
}
[SpacetimeDB.Table(Accessor = "player", Public = true)]
public partial struct Player
{
[SpacetimeDB.PrimaryKey]
[SpacetimeDB.AutoInc]
public ulong Id;
[SpacetimeDB.Unique]
public Identity Identity;
public string Name;
}
[SpacetimeDB.Table(Accessor = "account", Public = true)]
public partial class Account
{
[SpacetimeDB.PrimaryKey]
[SpacetimeDB.AutoInc]
public ulong Id;
[SpacetimeDB.Unique]
public Identity Identity;
public string Name = "";
}
[SpacetimeDB.Table(Accessor = "player_level", Public = true)]
public partial struct PlayerLevel
{
[SpacetimeDB.Unique]
public ulong PlayerId;
[SpacetimeDB.Index.BTree]
public ulong Level;
}
[SpacetimeDB.Type]
public partial struct PlayerAndLevel
{
public ulong Id;
public Identity Identity;
public string Name;
public ulong Level;
}
[SpacetimeDB.Table(Accessor = "user", Public = true)]
public partial struct User
{
[SpacetimeDB.PrimaryKey]
public Uuid Id;
[SpacetimeDB.Index.BTree]
public string Name;
[SpacetimeDB.Index.BTree]
public bool IsAdmin;
[SpacetimeDB.Index.BTree]
public byte Age;
}
[SpacetimeDB.Table(Accessor = "score", Public = true)]
[SpacetimeDB.Index.BTree(
Accessor = "by_player_and_level",
Columns = new[] { "PlayerId", "Level" }
)]
public partial struct Score
{
public uint PlayerId;
public uint Level;
public long Points;
}
[SpacetimeDB.Table(Accessor = "nullable_vec", Public = true)]
public partial struct NullableVec
{
[SpacetimeDB.PrimaryKey]
public uint Id;
public DbVector2? Pos;
}
[SpacetimeDB.Table(Accessor = "null_string_nonnullable", Public = true)]
public partial struct NullStringNonNullable
{
[SpacetimeDB.PrimaryKey]
[SpacetimeDB.AutoInc]
public ulong Id;
public string Name;
}
[SpacetimeDB.Table(Accessor = "null_string_nullable", Public = true)]
public partial struct NullStringNullable
{
[SpacetimeDB.PrimaryKey]
[SpacetimeDB.AutoInc]
public ulong Id;
public string? Name;
}
[SpacetimeDB.Table(Accessor = "view_pk_player", Public = true)]
public partial struct ViewPkPlayer
{
[SpacetimeDB.PrimaryKey]
public ulong Id;
[SpacetimeDB.Index.BTree]
public Identity Sender;
public string Name;
}
[SpacetimeDB.Table(Accessor = "view_pk_membership", Public = true)]
public partial struct ViewPkMembership
{
[SpacetimeDB.PrimaryKey]
public ulong Id;
[SpacetimeDB.Index.BTree]
public ulong PlayerId;
}
[SpacetimeDB.Table(Accessor = "view_pk_membership_secondary", Public = true)]
public partial struct ViewPkMembershipSecondary
{
[SpacetimeDB.PrimaryKey]
public ulong Id;
[SpacetimeDB.Index.BTree]
public ulong PlayerId;
}
// === Equality Test Tables and Types ===
// Struct for testing struct equality (used in Product and Order)
[SpacetimeDB.Type]
public partial struct ProductItem
{
public uint ProductId;
public int Quantity;
}
// Simple struct - basic value type equality testing
[SpacetimeDB.Table(Accessor = "equality_person", Public = true)]
public partial struct EqualityPerson
{
[SpacetimeDB.PrimaryKey]
public uint Id;
public string Name;
}
// Complex struct with multiple fields for equality testing
[SpacetimeDB.Table(Accessor = "equality_product", Public = true)]
public partial struct EqualityProduct
{
[SpacetimeDB.PrimaryKey]
public uint Id;
public string Name;
public int Price; // Price in cents
public int Quantity;
}
// Record with reference type fields for equality testing
[SpacetimeDB.Table(Accessor = "equality_order", Public = true)]
public partial record EqualityOrder
{
[SpacetimeDB.PrimaryKey]
public uint Id;
public string? CustomerName;
public List<ProductItem>? Items;
}
// Enum for equality testing
public enum TestStatus
{
Pending,
Active,
Completed
}
// Custom sum type for testing TaggedEnum equality
[SpacetimeDB.Type]
public partial record GameAction : TaggedEnum<(string Move, string Attack, int Defend)> { }
[SpacetimeDB.Table(Accessor = "player_action", Public = true)]
public partial struct PlayerAction
{
[SpacetimeDB.PrimaryKey]
public uint Id;
public GameAction Action;
}
// List of nullable sum types - tests nullable suffix handling
[SpacetimeDB.Table(Accessor = "action_batch", Public = true)]
public partial struct ActionBatch
{
[SpacetimeDB.PrimaryKey]
public uint Id;
public List<GameAction?> Actions;
}
// Table WITHOUT primary key - triggers RawIndexDefV10.Equals/GetHashCode path
[SpacetimeDB.Table(Accessor = "log_entry", Public = true)]
public partial struct LogEntry
{
public string Message;
public ulong Timestamp;
}
// Scheduled table - tests ScheduleAt sum type (manually-written, uses ReferenceUse)
[SpacetimeDB.Table(Accessor = "scheduled_task", Scheduled = "ExecuteScheduledTask", ScheduledAt = "ScheduledAt")]
public partial struct ScheduledTask
{
[SpacetimeDB.PrimaryKey]
public ulong Id;
public string TaskName;
public ScheduleAt ScheduledAt;
}
// At-most-one row: return T?
[SpacetimeDB.View(Accessor = "my_player", Public = true)]
public static Player? MyPlayer(ViewContext ctx)
{
return ctx.Db.player.Identity.Find(ctx.Sender);
}
[SpacetimeDB.View(Accessor = "my_account", Public = true)]
public static Account? MyAccount(ViewContext ctx)
{
return ctx.Db.account.Identity.Find(ctx.Sender) as Account;
}
[SpacetimeDB.View(Accessor = "my_account_missing", Public = true)]
public static Account? MyAccountMissing(ViewContext ctx)
{
return null;
}
// Multiple rows: return a list
[SpacetimeDB.View(Accessor = "players_at_level_one", Public = true)]
public static List<PlayerAndLevel> PlayersAtLevelOne(AnonymousViewContext ctx)
{
var rows = new List<PlayerAndLevel>();
foreach (var player in ctx.Db.player_level.Level.Filter(1))
{
if (ctx.Db.player.Id.Find(player.PlayerId) is Player p)
{
var row = new PlayerAndLevel
{
Id = p.Id,
Identity = p.Identity,
Name = p.Name,
Level = player.Level,
};
rows.Add(row);
}
}
return rows;
}
[SpacetimeDB.View(Accessor = "Admins", Public = true)]
public static List<User> Admins(AnonymousViewContext ctx)
{
var rows = new List<User>();
foreach (var user in ctx.Db.user.IsAdmin.Filter(true))
{
rows.Add(user);
}
return rows;
}
[SpacetimeDB.View(Accessor = "users_named_alice", Public = true)]
public static List<User> UsersNamedAlice(AnonymousViewContext ctx)
{
var rows = new List<User>();
foreach (var user in ctx.Db.user.Name.Filter("Alice"))
{
rows.Add(user);
}
return rows;
}
[SpacetimeDB.View(Accessor = "users_age_18_65", Public = true)]
public static List<User> UsersAge1865(AnonymousViewContext ctx)
{
var rows = new List<User>();
foreach (var user in ctx.Db.user.Age.Filter(new Bound<byte>(18, 65)))
{
rows.Add(user);
}
return rows;
}
[SpacetimeDB.View(Accessor = "users_age_18_plus", Public = true)]
public static List<User> UsersAge18Plus(AnonymousViewContext ctx)
{
var rows = new List<User>();
foreach (var user in ctx.Db.user.Age.Filter(new Bound<byte>(18, byte.MaxValue)))
{
rows.Add(user);
}
return rows;
}
[SpacetimeDB.View(Accessor = "users_age_under_18", Public = true)]
public static List<User> UsersAgeUnder18(AnonymousViewContext ctx)
{
var rows = new List<User>();
foreach (var user in ctx.Db.user.Age.Filter(new Bound<byte>(byte.MinValue, 17)))
{
rows.Add(user);
}
return rows;
}
[SpacetimeDB.View(Accessor = "scores_player_123", Public = true)]
public static List<Score> ScoresPlayer123(AnonymousViewContext ctx)
{
var rows = new List<Score>();
foreach (var score in ctx.Db.score.by_player_and_level.Filter(123u))
{
rows.Add(score);
}
return rows;
}
[SpacetimeDB.View(Accessor = "scores_player_123_range", Public = true)]
public static List<Score> ScoresPlayer123Range(AnonymousViewContext ctx)
{
var rows = new List<Score>();
foreach (
var score in ctx.Db.score.by_player_and_level.Filter((123u, new Bound<uint>(1u, 10u)))
)
{
rows.Add(score);
}
return rows;
}
[SpacetimeDB.View(Accessor = "scores_player_123_level5", Public = true)]
public static List<Score> ScoresPlayer123Level5(AnonymousViewContext ctx)
{
var rows = new List<Score>();
foreach (var score in ctx.Db.score.by_player_and_level.Filter((123u, 5u)))
{
rows.Add(score);
}
return rows;
}
[SpacetimeDB.View(Accessor = "where_test_view", Public = true)]
public static WhereTest? WhereTestView(ViewContext ctx)
{
return ctx.Db.where_test.Id.Find(2);
}
[SpacetimeDB.View(Accessor = "where_test_query", Public = true)]
public static IQuery<WhereTest> WhereTestQuery(ViewContext ctx)
{
return ctx.From.where_test().Where(cols => cols.Id.Eq(SqlLit.Int(2u)));
}
[SpacetimeDB.View(Accessor = "find_where_test", Public = true)]
public static WhereTest? FindWhereTest(AnonymousViewContext ctx)
{
return ctx.Db.where_test.Id.Find(3);
}
[SpacetimeDB.View(Accessor = "nullable_vec_view", Public = true)]
public static List<NullableVec> NullableVecView(AnonymousViewContext ctx)
{
var rows = new List<NullableVec>();
if (ctx.Db.nullable_vec.Id.Find(1) is NullableVec row1)
{
rows.Add(row1);
}
if (ctx.Db.nullable_vec.Id.Find(2) is NullableVec row2)
{
rows.Add(row2);
}
return rows;
}
[SpacetimeDB.View(Accessor = "all_view_pk_players", Public = true)]
public static IQuery<ViewPkPlayer> AllViewPkPlayers(ViewContext ctx)
{
return ctx.From.view_pk_player();
}
[SpacetimeDB.View(Accessor = "sender_view_pk_players_a", Public = true)]
public static IQuery<ViewPkPlayer> SenderViewPkPlayersA(ViewContext ctx)
{
return ctx
.From.view_pk_membership()
.RightSemijoin(
ctx.From.view_pk_player(),
(membership, player) => membership.PlayerId.Eq(player.Id)
);
}
[SpacetimeDB.View(Accessor = "sender_view_pk_players_b", Public = true)]
public static IQuery<ViewPkPlayer> SenderViewPkPlayersB(ViewContext ctx)
{
return ctx
.From.view_pk_membership_secondary()
.RightSemijoin(
ctx.From.view_pk_player(),
(membership, player) => membership.PlayerId.Eq(player.Id)
);
}
[SpacetimeDB.View(
Accessor = "procedural_view_pk_players",
Public = true,
PrimaryKey = "Id"
)]
public static IEnumerable<ViewPkPlayer> ProceduralViewPkPlayers(ViewContext ctx)
{
return ctx.Db.view_pk_player.Sender.Filter(ctx.Sender);
}
// IEnumerable<T> view support - manual list building with filtering
[SpacetimeDB.View(Accessor = "ienumerable_players_from_iter", Public = true)]
public static IEnumerable<Player> IEnumerablePlayersFromIter(AnonymousViewContext ctx)
{
var result = new List<Player>();
foreach (var playerLevel in ctx.Db.player_level.Level.Filter(1ul))
{
if (ctx.Db.player.Id.Find(playerLevel.PlayerId) is Player player)
{
result.Add(player);
}
}
return result;
}
// IEnumerable<T> view support - direct filter-to-list conversion
[SpacetimeDB.View(Accessor = "ienumerable_admins_from_filter", Public = true)]
public static IEnumerable<User> IEnumerableAdminsFromFilter(AnonymousViewContext ctx)
{
return ctx.Db.user.IsAdmin.Filter(true).ToList();
}
// IEnumerable<T> view support - complex logic with joins and custom objects
[SpacetimeDB.View(Accessor = "ienumerable_players_with_levels", Public = true)]
public static IEnumerable<PlayerAndLevel> IEnumerablePlayersWithLevels(AnonymousViewContext ctx)
{
var result = new List<PlayerAndLevel>();
foreach (var playerLevel in ctx.Db.player_level.Level.Filter(1ul))
{
if (ctx.Db.player.Id.Find(playerLevel.PlayerId) is Player player)
{
result.Add(
new PlayerAndLevel
{
Id = player.Id,
Identity = player.Identity,
Name = player.Name,
Level = playerLevel.Level,
}
);
}
}
return result;
}
[SpacetimeDB.Reducer]
public static void Delete(ReducerContext ctx, uint id)
{
LogStopwatch sw = new("Delete");
ctx.Db.example_data.Id.Delete(id);
}
[SpacetimeDB.Reducer]
public static void Add(ReducerContext ctx, uint id, uint indexed)
{
ctx.Db.example_data.Insert(new ExampleData { Id = id, Indexed = indexed });
}
[SpacetimeDB.Reducer]
public static void ThrowError(ReducerContext ctx, string error)
{
throw new Exception(error);
}
[SpacetimeDB.Reducer]
public static void InsertResult(ReducerContext ctx, Result<MyTable, string> msg)
{
ctx.Db.my_log.Insert(new MyLog { msg = msg });
}
[SpacetimeDB.Reducer]
public static void SetNullableVec(ReducerContext ctx, uint id, bool hasPos, int x, int y)
{
var row = new NullableVec { Id = id, Pos = hasPos ? new DbVector2 { X = x, Y = y } : null };
if (ctx.Db.nullable_vec.Id.Find(id) is null)
{
ctx.Db.nullable_vec.Insert(row);
}
else
{
ctx.Db.nullable_vec.Id.Update(row);
}
}
[SpacetimeDB.Reducer]
public static void InsertEmptyStringIntoNonNullable(ReducerContext ctx)
{
ctx.Db.null_string_nonnullable.Insert(new NullStringNonNullable { Name = "" });
}
[SpacetimeDB.Reducer]
public static void InsertNullStringIntoNonNullable(ReducerContext ctx)
{
ctx.Db.null_string_nonnullable.Insert(new NullStringNonNullable { Name = null! });
}
[SpacetimeDB.Reducer]
public static void InsertNullStringIntoNullable(ReducerContext ctx)
{
ctx.Db.null_string_nullable.Insert(new NullStringNullable { Name = null });
}
[SpacetimeDB.Reducer]
public static void InsertWhereTest(ReducerContext ctx, uint id, uint value, string name)
{
ctx.Db.where_test.Insert(
new WhereTest
{
Id = id,
Value = value,
Name = name,
}
);
}
[SpacetimeDB.Reducer]
public static void UpdateWhereTest(ReducerContext ctx, uint id, uint value, string name)
{
if (ctx.Db.where_test.Id.Find(id) is null)
{
throw new Exception($"where_test id={id} missing");
}
ctx.Db.where_test.Id.Update(
new WhereTest
{
Id = id,
Value = value,
Name = name,
}
);
}
[SpacetimeDB.Reducer]
public static void InsertViewPkPlayer(ReducerContext ctx, ulong id, string name)
{
ctx.Db.view_pk_player.Insert(
new ViewPkPlayer
{
Id = id,
Sender = ctx.Sender,
Name = name,
}
);
}
[SpacetimeDB.Reducer]
public static void UpdateViewPkPlayer(ReducerContext ctx, ulong id, string name)
{
ctx.Db.view_pk_player.Id.Update(
new ViewPkPlayer
{
Id = id,
Sender = ctx.Sender,
Name = name,
}
);
}
[SpacetimeDB.Reducer]
public static void InsertViewPkMembership(ReducerContext ctx, ulong id, ulong playerId)
{
ctx.Db.view_pk_membership.Insert(new ViewPkMembership { Id = id, PlayerId = playerId });
}
[SpacetimeDB.Reducer]
public static void InsertViewPkMembershipSecondary(ReducerContext ctx, ulong id, ulong playerId)
{
ctx.Db.view_pk_membership_secondary.Insert(
new ViewPkMembershipSecondary { Id = id, PlayerId = playerId }
);
}
[Reducer(ReducerKind.ClientConnected)]
public static void ClientConnected(ReducerContext ctx)
{
Log.Info($"Connect {ctx.Sender}");
if (ctx.Db.player.Identity.Find(ctx.Sender) is Player player)
{
// We are not logging player login status, so do nothing
}
else
{
// Lets setup a new player with a level of 1
ctx.Db.player.Insert(new Player { Identity = ctx.Sender, Name = "NewPlayer" });
var playerId = (ctx.Db.player.Identity.Find(ctx.Sender)!).Value.Id;
ctx.Db.player_level.Insert(new PlayerLevel { PlayerId = playerId, Level = 1 });
}
if (ctx.Db.account.Identity.Find(ctx.Sender) is null)
{
ctx.Db.account.Insert(new Account { Identity = ctx.Sender, Name = "Account" });
}
if (ctx.Db.nullable_vec.Id.Find(1) is null)
{
ctx.Db.nullable_vec.Insert(
new NullableVec
{
Id = 1,
Pos = new DbVector2 { X = 1, Y = 2 },
}
);
}
if (ctx.Db.nullable_vec.Id.Find(2) is null)
{
ctx.Db.nullable_vec.Insert(new NullableVec { Id = 2, Pos = null });
}
if (ctx.Db.user.Count == 0)
{
foreach (
var (Name, IsAdmin, Age) in new List<(string Name, bool IsAdmin, byte Age)>
{
("Alice", true, (byte)30),
("Bob", false, (byte)16),
("Charlie", true, (byte)22),
}
)
{
ctx.Db.user.Insert(
new User
{
Id = ctx.NewUuidV7(),
Name = Name,
IsAdmin = IsAdmin,
Age = Age,
}
);
}
}
if (ctx.Db.score.Count == 0)
{
foreach (
var (PlayerId, Level, Points) in new List<(uint PlayerId, uint Level, long Points)>
{
(123u, 1u, 1_000),
(123u, 5u, 5_000),
(123u, 10u, 10_000),
(999u, 2u, 2_500),
}
)
{
ctx.Db.score.Insert(
new Score
{
PlayerId = PlayerId,
Level = Level,
Points = Points,
}
);
}
}
if (ctx.Db.where_test.Id.Find(1) is null)
{
ctx.Db.where_test.Insert(
new WhereTest
{
Id = 1,
Value = 5,
Name = "this_name_is_unimportant",
}
);
}
if (ctx.Db.where_test.Id.Find(2) is null)
{
ctx.Db.where_test.Insert(
new WhereTest
{
Id = 2,
Value = 15,
Name = "this_name_will_get_updated",
}
);
}
if (ctx.Db.where_test.Id.Find(3) is null)
{
ctx.Db.where_test.Insert(
new WhereTest
{
Id = 3,
Value = 15,
Name = "this_name_will_not_be_updated",
}
);
}
}
[SpacetimeDB.Procedure]
public static uint ReturnPrimitive(ProcedureContext ctx, uint lhs, uint rhs)
{
return lhs + rhs;
}
[SpacetimeDB.Procedure]
public static ReturnStruct ReturnStructProcedure(ProcedureContext ctx, uint a, string b)
{
return new ReturnStruct(a, b);
}
[SpacetimeDB.Procedure]
public static ReturnEnum ReturnEnumA(ProcedureContext ctx, uint a)
{
return new ReturnEnum.A(a);
}
[SpacetimeDB.Procedure]
public static ReturnEnum ReturnEnumB(ProcedureContext ctx, string b)
{
return new ReturnEnum.B(b);
}
[SpacetimeDB.Procedure]
public static Uuid ReturnUuid(ProcedureContext ctx, Uuid u)
{
return u;
}
[SpacetimeDB.Procedure]
public static SpacetimeDB.Unit WillPanic(ProcedureContext ctx)
{
throw new InvalidOperationException("This procedure is expected to panic");
}
[SpacetimeDB.Procedure]
[Experimental("STDB_UNSTABLE")]
public static string ReadMySchemaViaHttp(ProcedureContext ctx)
{
try
{
var moduleIdentity = ProcedureContext.Identity;
var uri = $"http://localhost:3000/v1/database/{moduleIdentity}/schema?version=9";
var res = ctx.Http.Get(uri, System.TimeSpan.FromSeconds(2));
return res switch
{
Result<HttpResponse, HttpError>.OkR(var v) => "OK " + v.Body.ToStringUtf8Lossy(),
Result<HttpResponse, HttpError>.ErrR(var e) => "ERR " + e.Message,
_ => throw new InvalidOperationException("Unknown Result variant."),
};
}
catch (Exception e)
{
return "EXN " + e;
}
}
[SpacetimeDB.Procedure]
[Experimental("STDB_UNSTABLE")]
public static string InvalidHttpRequest(ProcedureContext ctx)
{
try
{
var res = ctx.Http.Get("http://foo.invalid/", System.TimeSpan.FromMilliseconds(250));
return res switch
{
Result<HttpResponse, HttpError>.OkR(var v) => "OK " + v.Body.ToStringUtf8Lossy(),
Result<HttpResponse, HttpError>.ErrR(var e) => "ERR " + e.Message,
_ => throw new InvalidOperationException("Unknown Result variant."),
};
}
catch (Exception e)
{
return "EXN " + e;
}
}
#pragma warning disable STDB_UNSTABLE
[SpacetimeDB.Procedure]
public static void InsertWithTxCommit(ProcedureContext ctx)
{
ctx.WithTx(tx =>
{
tx.Db.my_table.Insert(new MyTable { Field = new ReturnStruct(a: 42, b: "magic") });
return new Unit();
});
AssertRowCount(ctx, 1);
}
[SpacetimeDB.Procedure]
public static void InsertWithTxRollback(ProcedureContext ctx)
{
var outcome = ctx.TryWithTx<SpacetimeDB.Unit, InvalidOperationException>(tx =>
{
tx.Db.my_table.Insert(new MyTable { Field = new ReturnStruct(a: 42, b: "magic") });
throw new InvalidOperationException("rollback");
});
Debug.Assert(!outcome.IsSuccess, "TryWithTxAsync should report failure");
AssertRowCount(ctx, 0);
}
[SpacetimeDB.Procedure]
public static Result<ReturnStruct, string> InsertWithTxRollbackResult(ProcedureContext ctx)
{
try
{
var outcome = ctx.TryWithTx<SpacetimeDB.Unit, InvalidOperationException>(tx =>
{
tx.Db.my_table.Insert(new MyTable { Field = new ReturnStruct(a: 42, b: "magic") });
throw new InvalidOperationException("rollback");
});
Debug.Assert(!outcome.IsSuccess, "TryWithTxAsync should report failure");
AssertRowCount(ctx, 0);
return Result<ReturnStruct, string>.Ok(new ReturnStruct(a: 42, b: "magic"));
}
catch (System.Exception e)
{
return Result<ReturnStruct, string>.Err(e.ToString());
}
}
private static void AssertRowCount(ProcedureContext ctx, ulong expected)
{
ctx.WithTx(tx =>
{
var actual = tx.Db.my_table.Count;
if (actual != expected)
{
throw new InvalidOperationException(
$"Expected {expected} MyTable rows but found {actual}."
);
}
return 0;
});
}
[SpacetimeDB.Table(Accessor = "retry_log", Public = true)]
public partial class RetryLog
{
[SpacetimeDB.PrimaryKey]
public uint Id;
public uint Attempts;
}
[SpacetimeDB.Table(Accessor = "test_event", Public = true, Event = true)]
public partial struct TestEvent
{
public string Name;
public ulong Value;
}
[SpacetimeDB.Procedure]
public static void InsertWithTxRetry(ProcedureContext ctx)
{
const uint key = 1;
var outcome = ctx.TryWithTx<uint, Exception>(tx =>
{
var existing = tx.Db.retry_log.Id.Find(key);
if (existing is null)
{
tx.Db.retry_log.Insert(new RetryLog { Id = key, Attempts = 1 });
return Result<uint, Exception>.Err(new Exception("conflict"));
}
// Use the unique index Update method
var newAttempts = existing.Attempts + 1;
tx.Db.retry_log.Id.Update(new RetryLog { Id = key, Attempts = newAttempts });
return Result<uint, Exception>.Ok(newAttempts);
});
if (!outcome.IsSuccess)
{
outcome = ctx.TryWithTx<uint, Exception>(tx =>
{
var existing = tx.Db.retry_log.Id.Find(key);
if (existing is null)
{
tx.Db.retry_log.Insert(new RetryLog { Id = key, Attempts = 1 });
return Result<uint, Exception>.Err(new Exception("conflict"));
}
// Use the unique index Update method
var newAttempts = existing.Attempts + 1;
tx.Db.retry_log.Id.Update(new RetryLog { Id = key, Attempts = newAttempts });
return Result<uint, Exception>.Ok(newAttempts);
});
}
Debug.Assert(outcome.IsSuccess, "Retry should have succeeded");
}
[SpacetimeDB.Reducer]
public static void EmitTestEvent(ReducerContext ctx, string name, ulong value)
{
ctx.Db.test_event.Insert(new TestEvent { Name = name, Value = value });
}
[SpacetimeDB.Reducer]
public static void Noop(ReducerContext ctx) { }
// === Equality Test Reducers ===
[SpacetimeDB.Reducer]
public static void AddEqualityPerson(ReducerContext ctx, uint id, string name)
{
ctx.Db.equality_person.Insert(new EqualityPerson { Id = id, Name = name });
}
[SpacetimeDB.Reducer]
public static void AddEqualityProduct(ReducerContext ctx, uint id, string name, int price, int quantity)
{
ctx.Db.equality_product.Insert(new EqualityProduct { Id = id, Name = name, Price = price, Quantity = quantity });
}
[SpacetimeDB.Reducer]
public static void AddEqualityOrder(ReducerContext ctx, uint id, string? customerName, List<ProductItem>? items)
{
ctx.Db.equality_order.Insert(new EqualityOrder { Id = id, CustomerName = customerName, Items = items });
}
[SpacetimeDB.Reducer]
public static void RunEqualityTests(ReducerContext ctx)
{
Log.Info("=== Testing Equality ===");
// Test 1: Direct string comparison (no allocation)
bool stringEqual = "Alice" == "Alice";
Log.Info($"Test 1 - String equality: {stringEqual}");
// Test 2: Enum equality (no allocation)
var s1 = TestStatus.Pending;
var s2 = TestStatus.Pending;
bool enumEqual = s1 == s2;
Log.Info($"Test 2 - Enum equality: {enumEqual}");
// Test 3: Integer equality (no allocation)
int i1 = 42;
int i2 = 42;
bool intEqual = i1 == i2;
Log.Info($"Test 3 - Int equality: {intEqual}");
Log.Info("=== Equality Tests Complete ===");
}
[SpacetimeDB.Reducer]
public static void RunComplexEqualityTests(ReducerContext ctx)
{
Log.Info("=== Testing Complex Equality ===");
// Test struct .Equals() - this used to cause boxing/allocation
var item1 = new ProductItem { ProductId = 1, Quantity = 10 };
var item2 = new ProductItem { ProductId = 1, Quantity = 10 };
var item3 = new ProductItem { ProductId = 2, Quantity = 5 };
bool structEqual = item1.Equals(item2);
bool structNotEqual = item1.Equals(item3);
Log.Info($"Struct equality (same): {structEqual}");
Log.Info($"Struct equality (different): {structNotEqual}");
// Test Person struct equality (table type)
var person1 = new EqualityPerson { Id = 100, Name = "Test" };
var person2 = new EqualityPerson { Id = 100, Name = "Test" };
var person3 = new EqualityPerson { Id = 200, Name = "Other" };
bool personEqual = person1.Equals(person2);
bool personNotEqual = person1.Equals(person3);
Log.Info($"Person struct equality (same): {personEqual}");
Log.Info($"Person struct equality (different): {personNotEqual}");
Log.Info("=== Complex Equality Tests Complete ===");
}
[SpacetimeDB.Reducer]
public static void RunEnumEqualityTests(ReducerContext ctx)
{
var s1 = TestStatus.Pending;
var s2 = TestStatus.Pending;
var s3 = TestStatus.Active;
bool equal1 = s1 == s2; // Should be true
bool equal2 = s1 == s3; // Should be false
Log.Info($"Enum equality (same): {equal1}");
Log.Info($"Enum equality (different): {equal2}");
}
[SpacetimeDB.Reducer]
public static void TestTableWithoutPrimaryKey(ReducerContext ctx)
{
Log.Info("=== Testing Table WITHOUT Primary Key (RawIndexDefV10 path) ===");
// This triggers RawIndexDefV10.Equals/GetHashCode which previously failed
ctx.Db.log_entry.Insert(new LogEntry { Message = "Test 1", Timestamp = 1000 });
ctx.Db.log_entry.Insert(new LogEntry { Message = "Test 2", Timestamp = 2000 });
int count = 0;
foreach (var entry in ctx.Db.log_entry.Iter())
{
count++;
Log.Info($"LogEntry: {entry.Message} at {entry.Timestamp}");
}
Log.Info($"Total log entries: {count}");
Log.Info("=== Table Without Primary Key Test Complete ===");
}
[SpacetimeDB.Reducer]
public static void TestSumTypeEquality(ReducerContext ctx)
{
Log.Info("=== Testing Sum Type (TaggedEnum) Equality ===");
// Test sum type equality via generated Equals
var action1 = new GameAction.Move("North");
var action2 = new GameAction.Move("North");
var action3 = new GameAction.Attack("Sword");
bool equal = action1.Equals(action2);
bool notEqual = action1.Equals(action3);
Log.Info($"Sum type equality (same variant): {equal}");
Log.Info($"Sum type equality (different variant): {!notEqual}");
// Insert and retrieve sum type from table
ctx.Db.player_action.Insert(new PlayerAction { Id = 1, Action = action1 });
ctx.Db.player_action.Insert(new PlayerAction { Id = 2, Action = action3 });
foreach (var pa in ctx.Db.player_action.Iter())
{
var desc = pa.Action switch
{
GameAction.Move(var m) => $"Move: {m}",
GameAction.Attack(var a) => $"Attack: {a}",
GameAction.Defend(var d) => $"Defend: {d}",
_ => "Unknown"
};
Log.Info($"PlayerAction {pa.Id}: {desc}");
}
Log.Info("=== Sum Type Equality Test Complete ===");
}
[SpacetimeDB.Reducer]
public static void TestListOfNullableSumTypes(ReducerContext ctx)
{
Log.Info("=== Testing List of Nullable Sum Types ===");
// This exercises SumTypeUse with nullable suffix (GameAction?)
var actions = new List<GameAction?>
{
new GameAction.Move("North"),
null,
new GameAction.Attack("Bow"),
null
};
ctx.Db.action_batch.Insert(new ActionBatch { Id = 1, Actions = actions });
foreach (var batch in ctx.Db.action_batch.Iter())
{
Log.Info($"Batch {batch.Id} has {batch.Actions?.Count ?? 0} actions");
if (batch.Actions != null)
{
for (int i = 0; i < batch.Actions.Count; i++)
{
var desc = batch.Actions[i] switch
{
null => "null",
GameAction.Move(var m) => $"Move({m})",
GameAction.Attack(var a) => $"Attack({a})",
GameAction.Defend(var d) => $"Defend({d})",
_ => "Unknown"
};
Log.Info($" Action {i}: {desc}");
}
}
}
Log.Info("=== List of Nullable Sum Types Test Complete ===");
}
[SpacetimeDB.Reducer]
public static void ExecuteScheduledTask(ReducerContext ctx, ScheduledTask task)
{
Log.Info($"Executing scheduled task: {task.TaskName}");
}
[SpacetimeDB.Reducer]
public static void ScheduleTask(ReducerContext ctx, ulong delayMicros)
{
Log.Info("=== Testing Scheduled Table (ScheduleAt sum type) ===");
// ScheduleAt is a manually-written sum type - tests ReferenceUse path
var scheduledAt = ScheduleAt.TimeSpanFromMicroseconds((long)delayMicros);
ctx.Db.scheduled_task.Insert(new ScheduledTask
{
Id = 1,
TaskName = "Test Task",
ScheduledAt = scheduledAt
});
Log.Info($"Scheduled task for {delayMicros} microseconds from now");
Log.Info("=== Scheduled Table Test Complete ===");
}
[SpacetimeDB.Reducer]
public static void TestReducerWithSumTypeParam(ReducerContext ctx, GameAction action)
{
Log.Info("=== Testing Reducer with Sum Type Parameter ===");
// This exercises SumTypeUse.EqualsStatement for parameter comparison
var match = action switch
{
GameAction.Move(var m) => $"Moving: {m}",
GameAction.Attack(var a) => $"Attacking with: {a}",
GameAction.Defend(var d) => $"Defending with power: {d}",
_ => "Unknown action"
};
Log.Info(match);
Log.Info("=== Reducer with Sum Type Parameter Test Complete ===");
}
[SpacetimeDB.Reducer]
public static void RunAllEqualityTests(ReducerContext ctx)
{
Log.Info("========== Starting Equality Tests ==========");
// Insert test data
ctx.Db.equality_person.Insert(new EqualityPerson { Id = 1, Name = "Alice" });
ctx.Db.equality_product.Insert(new EqualityProduct { Id = 1, Name = "Widget", Price = 999, Quantity = 100 });
// Run tests
RunEqualityTests(ctx);
RunComplexEqualityTests(ctx);
RunEnumEqualityTests(ctx);
// New tests for sum type fixes
TestTableWithoutPrimaryKey(ctx);
TestSumTypeEquality(ctx);
TestListOfNullableSumTypes(ctx);
ScheduleTask(ctx, 1000000); // 1 second delay
// Test reducer with sum type parameter
TestReducerWithSumTypeParam(ctx, new GameAction.Attack("Magic Sword"));
Log.Info("========== All Equality Tests Complete ==========");
}
[SpacetimeDB.Procedure]
public static void InsertWithTxPanic(ProcedureContext ctx)
{
try
{
ctx.WithTx<object>(tx =>
{
// Insert a row
tx.Db.my_table.Insert(
new MyTable { Field = new ReturnStruct(a: 99, b: "panic-test") }
);
// Throw an exception to abort the transaction
throw new InvalidOperationException("panic abort");
});
}
catch (InvalidOperationException ex) when (ex.Message == "panic abort")
{
// Expected exception - transaction should be aborted
}
// Verify no rows were inserted due to the exception
AssertRowCount(ctx, 0);
}
[SpacetimeDB.Procedure]
public static void DanglingTxWarning(ProcedureContext ctx)
{
// This test demonstrates transaction cleanup when an unhandled exception occurs
// during transaction processing, which should trigger auto-abort behavior
var exceptionCaught = false;
try
{
ctx.WithTx<object>(tx =>
{
// Insert a row
tx.Db.my_table.Insert(
new MyTable { Field = new ReturnStruct(a: 123, b: "dangling") }
);
// Simulate an unexpected system exception that might leave transaction in limbo
// This should trigger the transaction cleanup/auto-abort mechanisms
throw new SystemException("Simulated system failure during transaction");
});
}
catch (SystemException)
{
exceptionCaught = true;
}
// Verify the exception was caught and no rows were persisted
if (!exceptionCaught)
{
throw new InvalidOperationException("Expected SystemException was not thrown");
}
// Verify no rows were persisted due to transaction abort
AssertRowCount(ctx, 0);
}
[SpacetimeDB.Procedure]
public static ReturnStruct TxContextCapabilities(ProcedureContext ctx)
{
var result = ctx.WithTx(tx =>
{
// Test 1: Verify transaction context has database access
var initialCount = tx.Db.my_table.Count;
// Test 2: Insert data and verify it's visible within the same transaction
tx.Db.my_table.Insert(new MyTable { Field = new ReturnStruct(a: 200, b: "tx-test") });
var countAfterInsert = tx.Db.my_table.Count;
if (countAfterInsert != initialCount + 1)
{
throw new InvalidOperationException(
$"Expected count {initialCount + 1}, got {countAfterInsert}"
);
}
// Test 3: Verify transaction context properties are accessible
var txSender = tx.Sender;
var txTimestamp = tx.Timestamp;
if (txSender.Equals(ctx.Sender) == false)
{
throw new InvalidOperationException(
"Transaction sender should match procedure sender"
);
}
// Test 4: Return data from within transaction
return new ReturnStruct(a: (uint)countAfterInsert, b: $"sender:{txSender}");
});
// Verify the row was committed - use flexible row count check
try
{
ctx.WithTx(tx =>
{
var actualCount = tx.Db.my_table.Count;
if (actualCount == 0)
{
throw new InvalidOperationException(
"Expected at least 1 MyTable row but found none - transaction may not have committed"
);
}
return 0;
});
}
catch (Exception ex)
{
// Log the assertion failure but don't fail the procedure
Log.Error($"TxContextCapabilities row count assertion failed: {ex.Message}");
// Still return the valid result from the transaction
}
return result;
}
[SpacetimeDB.Procedure]
public static ReturnStruct AuthenticationCapabilities(ProcedureContext ctx)
{
// Test 1: Verify authentication context is accessible from procedure context
var procAuth = ctx.SenderAuth;
var procSender = ctx.Sender;
var procConnectionId = ctx.ConnectionId;
var result = ctx.WithTx(tx =>
{
// Test 2: Verify authentication context is accessible from transaction context
var txAuth = tx.SenderAuth;
var txSender = tx.Sender;
var txConnectionId = tx.ConnectionId;
// Test 3: Authentication contexts should be consistent
if (txSender.Equals(procSender) == false)
{
throw new InvalidOperationException(
$"Transaction sender {txSender} should match procedure sender {procSender}"
);
}
if (txConnectionId.Equals(procConnectionId) == false)
{
throw new InvalidOperationException(
$"Transaction connectionId {txConnectionId} should match procedure connectionId {procConnectionId}"
);
}
// Test 4: Insert data with authentication information
tx.Db.my_table.Insert(
new MyTable
{
Field = new ReturnStruct(
a: (uint)(txSender.GetHashCode() & 0xFF),
b: $"auth:sender:{txSender}:conn:{txConnectionId}"
),
}
);
// Test 5: Check JWT claims (if available)
var jwtInfo = "no-jwt";
try
{
var jwt = txAuth.Jwt;
if (jwt != null)
{
jwtInfo = $"jwt:present:identity:{jwt.Identity}";
}
}
catch
{
// JWT may not be available in test environment
jwtInfo = "jwt:unavailable";
}
return new ReturnStruct(a: (uint)(txSender.GetHashCode() & 0xFF), b: jwtInfo);
});
return result;
}
[SpacetimeDB.Procedure]
public static ReturnStruct SubscriptionEventOffset(ProcedureContext ctx)
{
// This procedure tests that subscription events carry transaction offset information
// We'll insert data and return information that helps verify the transaction offset
var result = ctx.WithTx(tx =>
{
// Insert a row that will trigger subscription events
var testData = new MyTable
{
Field = new ReturnStruct(
a: 999, // Use a distinctive value to identify this test
b: $"offset-test:{tx.Timestamp.MicrosecondsSinceUnixEpoch}"
),
};
tx.Db.my_table.Insert(testData);
// Return data that can be used to correlate with subscription events
return new ReturnStruct(
a: 999,
b: $"committed:{tx.Timestamp.MicrosecondsSinceUnixEpoch}"
);
});
// At this point, the transaction should be committed and subscription events
// should be generated with the transaction offset information
return result;
}
[SpacetimeDB.Procedure]
public static ReturnStruct DocumentationGapChecks(
ProcedureContext ctx,
uint inputValue,
string inputText
)
{
// This procedure tests various documentation gaps and edge cases
// Test 1: Parameter handling - procedures can accept multiple parameters
if (inputValue == 0)
{
throw new ArgumentException("inputValue cannot be zero");
}
if (string.IsNullOrEmpty(inputText))
{
throw new ArgumentException("inputText cannot be null or empty");
}
var result = ctx.WithTx(tx =>
{
// Test 2: Multiple database operations in single transaction
var count = tx.Db.my_table.Count;
// Test 3: Conditional logic based on database state
if (count > 10)
{
// Don't insert if too many rows
return new ReturnStruct(a: (uint)count, b: $"skipped:too-many-rows:{count}");
}
// Test 4: Complex data manipulation
var processedValue = inputValue * 2 + (uint)inputText.Length;
tx.Db.my_table.Insert(
new MyTable
{
Field = new ReturnStruct(
a: processedValue,
b: $"doc-gap:{inputText}:processed:{processedValue}"
),
}
);
// Test 5: Return computed results
return new ReturnStruct(
a: processedValue,
b: $"success:input:{inputText}:result:{processedValue}"
);
});
// Test 6: Post-transaction validation
var finalCount = ctx.WithTx(tx => tx.Db.my_table.Count);
if (finalCount <= 0)
{
throw new InvalidOperationException("Expected at least one row after transaction");
}
return result;
}
#pragma warning restore STDB_UNSTABLE
}