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:
parent
0f2c1f04ef
commit
452f928bb7
5 changed files with 75 additions and 7 deletions
|
|
@ -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!));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue