Skip to content

Commit 412df5a

Browse files
committed
Add UUID built-in convenience type to SpacetimeDB
1 parent 31a8d5f commit 412df5a

File tree

81 files changed

+4065
-248
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

81 files changed

+4065
-248
lines changed

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,7 @@ unicode-ident = "1.0.12"
299299
unicode-normalization = "0.1.23"
300300
url = "2.3.1"
301301
urlencoding = "2.1.2"
302-
uuid = { version = "1.18.1", features = ["v4"] }
302+
uuid = { version = "1.18.1", default-features = false }
303303
v8 = "140.2"
304304
walkdir = "2.2.5"
305305
wasmbin = "0.6"

crates/bindings-csharp/BSATN.Runtime/BSATN/U128.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,4 +90,20 @@ private BigInteger AsBigInt() =>
9090

9191
/// <inheritdoc cref="object.ToString()" />
9292
public override string ToString() => AsBigInt().ToString();
93+
94+
public Guid ToGuid()
95+
{
96+
Span<byte> bytes = stackalloc byte[16];
97+
if (BitConverter.IsLittleEndian)
98+
{
99+
BitConverter.TryWriteBytes(bytes, _lower);
100+
BitConverter.TryWriteBytes(bytes[8..], _upper);
101+
}
102+
else
103+
{
104+
BitConverter.TryWriteBytes(bytes, _upper);
105+
BitConverter.TryWriteBytes(bytes[8..], _lower);
106+
}
107+
return new Guid(bytes);
108+
}
93109
}

crates/bindings-csharp/BSATN.Runtime/Builtins.cs

Lines changed: 224 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -365,7 +365,7 @@ public readonly TimeDuration TimeDurationSince(Timestamp earlier) =>
365365
public static Timestamp operator -(Timestamp point, TimeDuration interval) =>
366366
new Timestamp(checked(point.MicrosecondsSinceUnixEpoch - interval.Microseconds));
367367

368-
public int CompareTo(Timestamp that)
368+
public readonly int CompareTo(Timestamp that)
369369
{
370370
return this.MicrosecondsSinceUnixEpoch.CompareTo(that.MicrosecondsSinceUnixEpoch);
371371
}
@@ -605,3 +605,226 @@ public AlgebraicType GetAlgebraicType(ITypeRegistrar registrar) =>
605605
// --- / customized ---
606606
}
607607
}
608+
609+
/// <summary>
610+
/// A generator for monotonically increasing <see cref="Timestamp"/> s by millisecond increments.
611+
/// </summary>
612+
public sealed class ClockGenerator(Timestamp start)
613+
{
614+
private long _microsSinceUnixEpoch = start.MicrosecondsSinceUnixEpoch;
615+
616+
/// <summary>
617+
/// Returns the next <see cref="Timestamp"/> in the sequence, guaranteed to be
618+
/// greater than the previous one returned by this method.
619+
///
620+
/// UUIDv7 requires monotonic millisecond timestamps, so each tick
621+
/// increases the timestamp by at least 1 millisecond (1_000 microseconds).
622+
///
623+
/// # Exceptions
624+
///
625+
/// If the internal timestamp overflows i64 microseconds.
626+
/// </summary>
627+
public Timestamp Tick()
628+
{
629+
checked
630+
{
631+
_microsSinceUnixEpoch += 1000;
632+
}
633+
return new Timestamp(_microsSinceUnixEpoch);
634+
}
635+
636+
public static implicit operator ClockGenerator(Timestamp t) => new(t);
637+
}
638+
639+
/// <summary>
640+
/// A universally unique identifier (UUID).
641+
///
642+
/// Wraps the native <see cref="Guid"/> type and provides methods
643+
/// to generate nil, random (v4), and time-ordered (v7) UUIDs.
644+
/// </summary>
645+
[StructLayout(LayoutKind.Sequential)]
646+
public readonly record struct Uuid : IEquatable<Uuid>, IComparable, IComparable<Uuid>
647+
{
648+
private readonly U128 value;
649+
internal Uuid(U128 val) => value = val;
650+
public static readonly Uuid NIL = new(FromGuid(Guid.Empty));
651+
652+
public static Uuid Nil() => NIL;
653+
654+
public static U128 FromGuid(Guid guid)
655+
{
656+
Span<byte> bytes = stackalloc byte[16];
657+
guid.TryWriteBytes(bytes);
658+
if (BitConverter.IsLittleEndian)
659+
{
660+
var lower = BitConverter.ToUInt64(bytes);
661+
var upper = BitConverter.ToUInt64(bytes[8..]);
662+
return new U128(upper, lower);
663+
}
664+
else
665+
{
666+
var upper = BitConverter.ToUInt64(bytes);
667+
var lower = BitConverter.ToUInt64(bytes[8..]);
668+
return new U128(upper, lower);
669+
}
670+
}
671+
672+
private static Guid GuidV4(ReadOnlySpan<byte> randomBytes)
673+
{
674+
if (randomBytes.Length != 16)
675+
{
676+
throw new ArgumentException("Must be 16 bytes", nameof(randomBytes));
677+
}
678+
679+
Span<byte> bytes = stackalloc byte[16];
680+
randomBytes.CopyTo(bytes);
681+
bytes[6] = (byte)((bytes[6] & 0x0F) | 0x40); // version 4
682+
bytes[8] = (byte)((bytes[8] & 0x3F) | 0x80); // variant RFC 4122
683+
684+
return new Guid(randomBytes);
685+
}
686+
687+
/// <summary>
688+
/// Create a UUIDv4 from explicit random bytes.
689+
/// </summary>
690+
/// <remarks>
691+
/// This method assumes the provided bytes are already sufficiently random;
692+
/// it will only set the appropriate bits for the UUID version and variant.
693+
/// </remarks>
694+
/// <example>
695+
/// <code>
696+
/// var randomBytes = new byte[16];
697+
/// var uuid = Uuid.FromRandomBytesV4(randomBytes);
698+
/// Console.WriteLine(uuid);
699+
/// // Output: 00000000-0000-4000-8000-000000000000
700+
/// </code>
701+
/// </example>
702+
public static Uuid FromRandomBytesV4(ReadOnlySpan<byte> randomBytes)
703+
{
704+
return new(FromGuid(GuidV4(randomBytes)));
705+
}
706+
707+
/// <summary>
708+
/// Create a UUIDv7 from a UNIX timestamp (milliseconds) and 10 random bytes.
709+
/// </summary>
710+
/// <remarks>
711+
/// This method sets the variant field within the counter bytes without
712+
/// shifting data around it. Callers using the counter as a monotonic
713+
/// value should avoid storing significant data in the two least significant
714+
/// bits of the third byte.
715+
/// </remarks>
716+
/// <example>
717+
/// <code>
718+
/// ulong millis = 1686000000000UL;
719+
/// var randomBytes = new byte[10];
720+
/// var uuid = Uuid.FromUnixMillisV7(millis, randomBytes);
721+
/// Console.WriteLine(uuid);
722+
/// // Output: 01888d6e-5c00-7000-8000-000000000000
723+
/// </code>
724+
/// </example>
725+
public static Uuid FromUnixMillisV7(long millisSinceUnixEpoch, ReadOnlySpan<byte> randomBytes)
726+
{
727+
// TODO: Convert to ` CreateVersion7` from .NET 9 when we can.
728+
if (millisSinceUnixEpoch < 0)
729+
{
730+
throw new ArgumentOutOfRangeException(nameof(millisSinceUnixEpoch), "Timestamp precedes Unix epoch");
731+
}
732+
733+
// Generate random 16 bytes
734+
var bytes = GuidV4(randomBytes).ToByteArray();
735+
736+
// Insert 48-bit timestamp (big endian)
737+
bytes[0] = (byte)((millisSinceUnixEpoch >> 40) & 0xFF);
738+
bytes[1] = (byte)((millisSinceUnixEpoch >> 32) & 0xFF);
739+
bytes[2] = (byte)((millisSinceUnixEpoch >> 24) & 0xFF);
740+
bytes[3] = (byte)((millisSinceUnixEpoch >> 16) & 0xFF);
741+
bytes[4] = (byte)((millisSinceUnixEpoch >> 8) & 0xFF);
742+
bytes[5] = (byte)(millisSinceUnixEpoch & 0xFF);
743+
744+
// Set version (0111) and variant (10xx)
745+
bytes[6] = (byte)((bytes[6] & 0x0F) | 0x70);
746+
bytes[8] = (byte)((bytes[8] & 0x3F) | 0x80);
747+
748+
return new Uuid(FromGuid(new Guid(bytes)));
749+
}
750+
751+
/// <summary>
752+
/// Generate a UUIDv7 using a monotonic <see cref="ClockGenerator"/>.
753+
/// </summary>
754+
/// <remarks>
755+
/// This method sets the variant field within the counter bytes without
756+
/// shifting data around it. Callers using the counter as a monotonic
757+
/// value should avoid storing significant data in the two least significant
758+
/// bits of the third byte.
759+
/// </remarks>
760+
/// <example>
761+
/// <code>
762+
/// var clock = new ClockGenerator(1686000000000UL);
763+
/// var randomBytes = new byte[10];
764+
/// var uuid = Uuid.FromClockV7(clock, randomBytes);
765+
/// Console.WriteLine(uuid);
766+
/// // Output: 0000647e-5181-7000-8000-000000000000
767+
/// </code>
768+
/// </example>
769+
public static Uuid FromClockV7(ClockGenerator clock, ReadOnlySpan<byte> randomBytes)
770+
771+
{
772+
var millis = clock.Tick().MicrosecondsSinceUnixEpoch / 1000;
773+
return FromUnixMillisV7(millis, randomBytes);
774+
}
775+
776+
/// <summary>
777+
/// Parses a UUID from its string representation.
778+
/// </summary>
779+
/// <example>
780+
/// <code>
781+
/// var s = "01888d6e-5c00-7000-8000-000000000000";
782+
/// var uuid = Uuid.Parse(s);
783+
/// Console.WriteLine(uuid.ToString() == s); // True
784+
/// </code>
785+
/// </example>
786+
public static Uuid Parse(string s) => new(FromGuid(Guid.Parse(s)));
787+
788+
/// <summary>
789+
/// Converts this instance to a <see cref="Guid"/>.
790+
/// </summary>
791+
public Guid ToGuid() => value.ToGuid();
792+
793+
public override readonly string ToString() => ToGuid().ToString();
794+
795+
public readonly int CompareTo(Uuid other) => ToGuid().CompareTo(other.ToGuid());
796+
/// <inheritdoc cref="IComparable.CompareTo(object)" />
797+
public int CompareTo(object? value)
798+
{
799+
if (value is Uuid other)
800+
{
801+
return CompareTo(other);
802+
}
803+
else if (value is null)
804+
{
805+
return 1;
806+
}
807+
else
808+
{
809+
throw new ArgumentException("Argument must be a Uuid", nameof(value));
810+
}
811+
}
812+
public static bool operator <(Uuid l, Uuid r) => l.CompareTo(r) < 0;
813+
public static bool operator >(Uuid l, Uuid r) => l.CompareTo(r) > 0;
814+
815+
816+
public readonly partial struct BSATN : IReadWrite<Uuid>
817+
{
818+
public Uuid Read(BinaryReader reader) => new(new SpacetimeDB.BSATN.U128Stdb().Read(reader));
819+
public void Write(BinaryWriter writer, Uuid value) => new SpacetimeDB.BSATN.U128Stdb().Write(writer, value.value);
820+
// --- / auto-generated ---
821+
822+
// --- customized ---
823+
public AlgebraicType GetAlgebraicType(ITypeRegistrar registrar) =>
824+
// Return a Product directly, not a Ref, because this is a special type.
825+
new AlgebraicType.Product([
826+
// Using this specific name here is important.
827+
new("__uuid__", new AlgebraicType.U128(default))
828+
]);
829+
}
830+
}

crates/bindings-csharp/Codegen/Module.cs

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,8 @@ or SpecialType.System_Int64
175175
SpecialType.System_String or SpecialType.System_Boolean => true,
176176
SpecialType.None => type.ToString()
177177
is "SpacetimeDB.ConnectionId"
178-
or "SpacetimeDB.Identity",
178+
or "SpacetimeDB.Identity"
179+
or "SpacetimeDB.Uuid",
179180
_ => false,
180181
}
181182
)
@@ -1464,25 +1465,67 @@ internal ReducerContext(Identity identity, ConnectionId? connectionId, Random ra
14641465
Timestamp = time;
14651466
SenderAuth = AuthCtx.BuildFromSystemTables(connectionId, identity);
14661467
}
1468+
1469+
/// <summary>
1470+
/// Create a new UUIDv4 using the built-in RNG.
1471+
/// </summary>
1472+
/// <remarks>
1473+
/// This method fills 16 random bytes using the context RNG,
1474+
/// sets version and variant bits for UUIDv4, and returns the result.
1475+
/// </remarks>
1476+
/// <example>
1477+
/// <code>
1478+
/// var uuid = ctx.NewUuidV4();
1479+
/// Console.WriteLine(uuid);
1480+
/// </code>
1481+
/// </example>
1482+
public Uuid NewUuidV4()
1483+
{
1484+
var bytes = new byte[16];
1485+
Rng.NextBytes(bytes);
1486+
return Uuid.FromRandomBytesV4(bytes);
1487+
}
1488+
1489+
/// <summary>
1490+
/// Create a new UUIDv7 using the provided <see cref="ClockGenerator"/>.
1491+
/// </summary>
1492+
/// <remarks>
1493+
/// To preserve monotonicity guarantees, do not call this from multiple
1494+
/// threads or contexts sharing the same <see cref="ClockGenerator"/>.
1495+
/// Use a dedicated instance per logical context.
1496+
/// </remarks>
1497+
/// <example>
1498+
/// <code>
1499+
/// var clock = new ClockGenerator(ctx.Timestamp);
1500+
/// var uuid = ctx.NewUuidV7(clock);
1501+
/// Console.WriteLine(uuid);
1502+
/// </code>
1503+
/// </example>
1504+
public Uuid NewUuidV7(ClockGenerator clock)
1505+
{
1506+
var bytes = new byte[10];
1507+
Rng.NextBytes(bytes);
1508+
return Uuid.FromClockV7(clock, bytes);
1509+
}
14671510
}
1468-
1469-
public sealed record ViewContext : DbContext<Internal.LocalReadOnly>, Internal.IViewContext
1511+
1512+
public sealed record ViewContext : DbContext<Internal.LocalReadOnly>, Internal.IViewContext
14701513
{
14711514
public Identity Sender { get; }
1472-
1515+
14731516
internal ViewContext(Identity sender, Internal.LocalReadOnly db)
14741517
: base(db)
14751518
{
14761519
Sender = sender;
14771520
}
14781521
}
14791522
1480-
public sealed record AnonymousViewContext : DbContext<Internal.LocalReadOnly>, Internal.IAnonymousViewContext
1523+
public sealed record AnonymousViewContext : DbContext<Internal.LocalReadOnly>, Internal.IAnonymousViewContext
14811524
{
14821525
internal AnonymousViewContext(Internal.LocalReadOnly db)
14831526
: base(db) { }
14841527
}
1485-
1528+
14861529
namespace Internal.TableHandles {
14871530
{{string.Join("\n", tableAccessors.Select(v => v.tableAccessor))}}
14881531
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
Microsoft Visual Studio Solution File, Format Version 12.00
2+
# Visual Studio Version 17
3+
VisualStudioVersion = 17.5.2.0
4+
MinimumVisualStudioVersion = 10.0.40219.1
5+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Runtime", "Runtime.csproj", "{63C91D0B-0AE3-D1C8-1700-0E3CE3A0F7C2}"
6+
EndProject
7+
Global
8+
GlobalSection(SolutionConfigurationPlatforms) = preSolution
9+
Debug|Any CPU = Debug|Any CPU
10+
Release|Any CPU = Release|Any CPU
11+
EndGlobalSection
12+
GlobalSection(ProjectConfigurationPlatforms) = postSolution
13+
{63C91D0B-0AE3-D1C8-1700-0E3CE3A0F7C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
14+
{63C91D0B-0AE3-D1C8-1700-0E3CE3A0F7C2}.Debug|Any CPU.Build.0 = Debug|Any CPU
15+
{63C91D0B-0AE3-D1C8-1700-0E3CE3A0F7C2}.Release|Any CPU.ActiveCfg = Release|Any CPU
16+
{63C91D0B-0AE3-D1C8-1700-0E3CE3A0F7C2}.Release|Any CPU.Build.0 = Release|Any CPU
17+
EndGlobalSection
18+
GlobalSection(SolutionProperties) = preSolution
19+
HideSolutionNode = FALSE
20+
EndGlobalSection
21+
GlobalSection(ExtensibilityGlobals) = postSolution
22+
SolutionGuid = {503F8EC9-458A-466F-941E-DF180F6F3CE1}
23+
EndGlobalSection
24+
EndGlobal

0 commit comments

Comments
 (0)