diff --git a/sdks/csharp/src/CompressionHelpers.cs b/sdks/csharp/src/CompressionHelpers.cs
new file mode 100644
index 00000000000..56e35124dce
--- /dev/null
+++ b/sdks/csharp/src/CompressionHelpers.cs
@@ -0,0 +1,148 @@
+using System;
+using System.IO;
+using System.IO.Compression;
+using SpacetimeDB.ClientApi;
+
+namespace SpacetimeDB
+{
+ internal class CompressionHelpers
+ {
+ ///
+ /// Compression algorithms supported for data processing.
+ /// Used to specify the compression method for serializing and deserializing messages
+ /// between the client and SpacetimeDB server. The selected algorithm determines
+ /// how data such as query updates and server messages are compressed or decompressed.
+ ///
+ internal enum CompressionAlgos : byte
+ {
+ None = 0,
+ Brotli = 1,
+ Gzip = 2,
+ }
+
+ ///
+ /// Creates a for decompressing the provided stream.
+ ///
+ /// The input stream containing Brotli-compressed data.
+ /// A set to decompression mode.
+ internal static BrotliStream BrotliReader(Stream stream)
+ {
+ return new BrotliStream(stream, CompressionMode.Decompress);
+ }
+
+ ///
+ /// Creates a for decompressing the provided stream.
+ ///
+ /// The input stream containing GZip-compressed data.
+ /// A set to decompression mode.
+ internal static GZipStream GzipReader(Stream stream)
+ {
+ return new GZipStream(stream, CompressionMode.Decompress);
+ }
+
+ ///
+ /// Decompresses and decodes a serialized from a byte array,
+ /// automatically handling the specified compression algorithm (None, Brotli, or Gzip).
+ /// Ensures efficient decompression by reading the entire stream at once to avoid
+ /// performance issues with certain stream implementations.
+ /// Throws if an unknown compression type is encountered.
+ ///
+ /// The compressed and encoded server message as a byte array.
+ /// The deserialized object.
+ internal static ServerMessage DecompressDecodeMessage(byte[] bytes)
+ {
+ using var stream = new MemoryStream(bytes);
+
+ // The stream will never be empty. It will at least contain the compression algo.
+ var compression = (CompressionAlgos)stream.ReadByte();
+ // Conditionally decompress and decode.
+ Stream decompressedStream = compression switch
+ {
+ CompressionAlgos.None => stream,
+ CompressionAlgos.Brotli => BrotliReader(stream),
+ CompressionAlgos.Gzip => GzipReader(stream),
+ _ => throw new InvalidOperationException("Unknown compression type"),
+ };
+
+ // TODO: consider pooling these.
+ // DO NOT TRY TO TAKE THIS OUT. The BrotliStream ReadByte() implementation allocates an array
+ // PER BYTE READ. You have to do it all at once to avoid that problem.
+ MemoryStream memoryStream = new MemoryStream();
+ decompressedStream.CopyTo(memoryStream);
+ memoryStream.Seek(0, SeekOrigin.Begin);
+ return new ServerMessage.BSATN().Read(new BinaryReader(memoryStream));
+ }
+
+
+ ///
+ /// Decompresses and decodes a into a object,
+ /// automatically handling uncompressed, Brotli, or Gzip-encoded data. Ensures efficient decompression by
+ /// reading the entire stream at once to avoid performance issues with certain stream implementations.
+ /// Throws if the compression type is unrecognized.
+ ///
+ /// The compressed or uncompressed query update.
+ /// The deserialized object.
+ internal static QueryUpdate DecompressDecodeQueryUpdate(CompressableQueryUpdate update)
+ {
+ Stream decompressedStream;
+
+ switch (update)
+ {
+ case CompressableQueryUpdate.Uncompressed(var qu):
+ return qu;
+
+ case CompressableQueryUpdate.Brotli(var bytes):
+ decompressedStream = CompressionHelpers.BrotliReader(new MemoryStream(bytes.ToArray()));
+ break;
+
+ case CompressableQueryUpdate.Gzip(var bytes):
+ decompressedStream = CompressionHelpers.GzipReader(new MemoryStream(bytes.ToArray()));
+ break;
+
+ default:
+ throw new InvalidOperationException();
+ }
+
+ // TODO: consider pooling these.
+ // DO NOT TRY TO TAKE THIS OUT. The BrotliStream ReadByte() implementation allocates an array
+ // PER BYTE READ. You have to do it all at once to avoid that problem.
+ MemoryStream memoryStream = new MemoryStream();
+ decompressedStream.CopyTo(memoryStream);
+ memoryStream.Seek(0, SeekOrigin.Begin);
+ return new QueryUpdate.BSATN().Read(new BinaryReader(memoryStream));
+ }
+
+ ///
+ /// Prepare to read a BsatnRowList.
+ ///
+ /// This could return an IEnumerable, but we return the reader and row count directly to avoid an allocation.
+ /// It is legitimate to repeatedly call IStructuralReadWrite.ReadrowCount times on the resulting
+ /// BinaryReader:
+ /// Our decoding infrastructure guarantees that reading a value consumes the correct number of bytes
+ /// from the BinaryReader. (This is easy because BSATN doesn't have padding.)
+ ///
+ /// Previously here we were using LINQ to do what we're now doing with a custsom reader.
+ ///
+ /// Why are we no longer using LINQ?
+ ///
+ /// The calls in question, namely `Skip().Take()`, were fast under the Mono runtime,
+ /// but *much* slower when compiled AOT with IL2CPP.
+ /// Apparently Mono's JIT is smart enough to optimize away these LINQ ops,
+ /// resulting in a linear scan of the `BsatnRowList`.
+ /// Unfortunately IL2CPP could not, resulting in a quadratic scan.
+ /// See: https://github.com/clockworklabs/com.clockworklabs.spacetimedbsdk/pull/306
+ ///
+ ///
+ /// A reader for the rows of the list and a count of rows.
+ internal static (BinaryReader reader, int rowCount) ParseRowList(BsatnRowList list) =>
+ (
+ new BinaryReader(new ListStream(list.RowsData)),
+ list.SizeHint switch
+ {
+ RowSizeHint.FixedSize(var size) => list.RowsData.Count / size,
+ RowSizeHint.RowOffsets(var offsets) => offsets.Count,
+ _ => throw new NotImplementedException()
+ }
+ );
+ }
+}
\ No newline at end of file
diff --git a/sdks/csharp/src/CompressionHelpers.cs.meta b/sdks/csharp/src/CompressionHelpers.cs.meta
new file mode 100644
index 00000000000..7d6340607aa
--- /dev/null
+++ b/sdks/csharp/src/CompressionHelpers.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 17bd26235850b7b4c9ab624df17d4dd2
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/sdks/csharp/src/SpacetimeDBClient.cs b/sdks/csharp/src/SpacetimeDBClient.cs
index 1f2785b4124..1dac02ccf60 100644
--- a/sdks/csharp/src/SpacetimeDBClient.cs
+++ b/sdks/csharp/src/SpacetimeDBClient.cs
@@ -2,17 +2,12 @@
using System.Collections;
using System.Collections.Concurrent;
using System.Collections.Generic;
-using System.IO;
-using System.IO.Compression;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using SpacetimeDB.BSATN;
-using SpacetimeDB.Internal;
using SpacetimeDB.ClientApi;
using Thread = System.Threading.Thread;
-using System.Diagnostics;
-
namespace SpacetimeDB
{
@@ -221,33 +216,6 @@ internal struct UnparsedMessage
public uint parseQueueTrackerId;
}
- internal struct ParsedDatabaseUpdate
- {
- // Map: table handles -> (primary key -> IStructuralReadWrite).
- // If a particular table has no primary key, the "primary key" is just the row itself.
- // This is valid because any [SpacetimeDB.Type] automatically has a correct Equals and HashSet implementation.
- public Dictionary> Updates;
-
- // Can't override the default constructor. Make sure you use this one!
- public static ParsedDatabaseUpdate New()
- {
- ParsedDatabaseUpdate result;
- result.Updates = new();
- return result;
- }
-
- public MultiDictionaryDelta