Write an unset CultRecordRef as nil and read it back unset

The formatter wrote default(CultRecordRef<T>) as nil but read nil back as
"", so re-upserting a loaded record changed its bytes (0xC0 became 0xA0).

One in-memory unset value: CultRecordKey.Value is never null, so
default(CultRecordRef<T>) equals a ref built from "". One wire form: an
empty key writes nil, and nil or a legacy "" reads back as default.
Load then save is byte-stable; a store holding "" converges to nil on its
next save.

Rust, TypeScript and Python have no reference type: a reference member is a
string in the consumer's own payload, so there is no sibling codec to align.
The store-composition contract now names nil as the one unset wire form.

The new test loads a record with an unset ref through a fresh cache,
re-upserts and flushes, and requires identical payload bytes; it fails on
the old formatter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-14 21:01:07 +02:00
commit 452f928bb7
5 changed files with 75 additions and 7 deletions

View file

@ -3,16 +3,19 @@ using MessagePack.Formatters;
namespace GameCult.Caching;
// An unset reference (the empty key) is nil on the wire and default(CultRecordRef<T>) in memory; a "" read from older
// stores is the same unset reference, so load then save is byte-stable.
public sealed class CultRecordRefFormatter<T> : IMessagePackFormatter<CultRecordRef<T>>
{
public void Serialize(ref MessagePackWriter writer, CultRecordRef<T> value, MessagePackSerializerOptions options)
{
options.Resolver.GetFormatterWithVerify<string>().Serialize(ref writer, value.Key.Value, options);
if (value.Key.Value.Length == 0) writer.WriteNil();
else writer.Write(value.Key.Value);
}
public CultRecordRef<T> Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
{
var key = options.Resolver.GetFormatterWithVerify<string>().Deserialize(ref reader, options) ?? string.Empty;
return new CultRecordRef<T>(new CultRecordKey(key));
var key = reader.TryReadNil() ? null : reader.ReadString();
return string.IsNullOrEmpty(key) ? default : new CultRecordRef<T>(new CultRecordKey(key!));
}
}

View file

@ -236,6 +236,17 @@ another runtime need not obey the `storedAt` rule.
- Deserialization runs under untrusted-data security. `CultRecordRef<T>` is a
permitted dictionary key, compared by its key string.
## Record references
A record reference is its target's key string. An unset reference, the empty
key, is **nil** on the wire, never `""`. A reader takes nil and `""` as the same
unset reference and writes it back as nil, so loading and re-saving an unchanged
record is byte-stable. In C# the unset reference is `default(CultRecordRef<T>)`,
equal to one built from `""`; `CultRecordKey.Value` is never null. Rust,
TypeScript and Python have no reference type: a reference member is a string in
the consumer's own payload value, so a consumer writes an unset one as its
language's null (`null`, `None`, `Option::None`), not as an empty string.
## Value-type encoding
Vector-like value types (Unity.Mathematics, consumer structs) encode as

View file

@ -57,12 +57,15 @@ namespace GameCult.Caching
public readonly struct CultRecordKey : IEquatable<CultRecordKey>
{
private readonly string? _value;
public CultRecordKey(string value)
{
Value = value ?? string.Empty;
_value = value;
}
public string Value { get; }
// Never null: default(CultRecordKey) and new CultRecordKey("") are one empty key.
public string Value => _value ?? string.Empty;
public override string ToString() => Value;

View file

@ -305,7 +305,7 @@ namespace GameCult.Caching
return string.IsNullOrWhiteSpace(name) ? record.Key.Value : name!;
}
// The key a CultRecordRef<T> holds; null and "" are both the empty key.
// The key a CultRecordRef<T> holds; "" when unset.
public static string RecordKey(object? recordRef) => (recordRef as ICultRecordRef)?.Key.Value ?? string.Empty;
public CultInspectorShape ShapeOf(Type type)
@ -464,7 +464,7 @@ namespace GameCult.Caching
}
}
// A key is taken when another serializes the same (a CultRecordRef<T> by its key string, null and "" alike) or when
// A key is taken when another serializes the same (a CultRecordRef<T> by its key string) or when
// the rebuilt dictionary type's default comparer calls them equal (0.0 and -0.0 serialize apart but are one double key).
private bool Taken(Type documentType, CultInspectorShape dictionary, object key, IEnumerable<object?> keys)
{

View file

@ -38,6 +38,48 @@ namespace GameCult.Caching.Tests
Assert.That(Convert.ToHexString(payload), Is.EqualTo(InteropNotePayloadHex));
}
[Test]
public async Task UnsetRefReloadsAndResavesByteIdentical()
{
var path = Path.Combine(Path.GetTempPath(), $"cultlib-unsetref-{Guid.NewGuid():N}.cc");
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(UnsetRefHolder) });
byte[] Payload() => CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)).Records.Single().Payload;
try
{
using (var seed = new CultCache(registry))
{
seed.AddBackingStore(new SingleFileMessagePackBackingStore(path));
await seed.UpsertAsync(typeof(UnsetRefHolder), new UnsetRefHolder { Name = "holder", Set = new CultRecordRef<UnsetRefHolder>(new CultRecordKey("other")) },
new CultRecordKey("holder"));
await seed.FlushAsync();
}
var written = Payload();
CultRecordRef<UnsetRefHolder> unset;
using (var reopened = new CultCache(registry))
{
reopened.AddBackingStore(new SingleFileMessagePackBackingStore(path));
await reopened.PullAllBackingStoresAsync();
var loaded = reopened.Get<UnsetRefHolder>(new CultRecordKey("holder"))!;
unset = loaded.Unset;
await reopened.UpsertAsync(typeof(UnsetRefHolder), loaded, new CultRecordKey("holder"));
await reopened.FlushAsync();
}
Assert.That(Payload(), Is.EqualTo(written), "an unset reference loads and re-saves byte-identical");
Assert.That(unset, Is.EqualTo(default(CultRecordRef<UnsetRefHolder>)));
Assert.That(unset.Key, Is.EqualTo(new CultRecordKey("")), "default and \"\" are one empty key");
var legacy = MessagePackSerializer.Serialize("", CultDocumentMessagePackSerialization.Options);
var read = MessagePackSerializer.Deserialize<CultRecordRef<UnsetRefHolder>>(legacy, CultDocumentMessagePackSerialization.Options);
Assert.That(MessagePackSerializer.Serialize(read, CultDocumentMessagePackSerialization.Options), Is.EqualTo(new[] { MessagePackCode.Nil }),
"a \"\" reference reads as unset and writes nil");
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Test]
public async Task RefKeyedDictionaryRoundTrips()
{
@ -171,5 +213,14 @@ namespace GameCult.Caching.Tests
[Key(0)] [CultName] public string Name = string.Empty;
[Key(1)] public Dictionary<CultRecordRef<RefKeyedHolder>, float> Weights = new();
}
[CultDocument("tests.unset_ref_holder", "tests.unset_ref_holder.v1")]
[MessagePackObject]
public sealed class UnsetRefHolder
{
[Key(0)] [CultName] public string Name = string.Empty;
[Key(1)] public CultRecordRef<UnsetRefHolder> Unset;
[Key(2)] public CultRecordRef<UnsetRefHolder> Set;
}
}
}