mirror of
https://github.com/clockworklabs/SpacetimeDB.git
synced 2026-07-25 11:32:30 -04:00
a91be36596
# 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
1527 lines
47 KiB
C#
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
|
|
}
|