CultLib/src/GameCult.Caching/CultDocumentContracts.cs
Meta Vens 452f928bb7 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>
2026-09-14 21:01:07 +02:00

114 lines
3.3 KiB
C#

using System;
namespace GameCult.Caching
{
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public sealed class CultDocumentAttribute : Attribute
{
public CultDocumentAttribute(string schemaName, string schemaVersion)
{
SchemaName = string.IsNullOrWhiteSpace(schemaName)
? throw new ArgumentException("SchemaName must be non-empty.", nameof(schemaName))
: schemaName;
SchemaVersion = string.IsNullOrWhiteSpace(schemaVersion)
? throw new ArgumentException("SchemaVersion must be non-empty.", nameof(schemaVersion))
: schemaVersion;
}
public string SchemaName { get; }
public string SchemaVersion { get; }
}
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public sealed class CultGlobalAttribute : Attribute
{
}
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true)]
public sealed class CultNameAttribute : Attribute
{
}
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true, AllowMultiple = false)]
public sealed class CultIndexAttribute : Attribute
{
public CultIndexAttribute(string? alias = null)
{
Alias = alias;
}
public string? Alias { get; }
}
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true)]
public sealed class CultReferenceAttribute : Attribute
{
public CultReferenceAttribute(Type? targetType = null, bool many = false)
{
TargetType = targetType;
Many = many;
}
public Type? TargetType { get; }
public bool Many { get; }
}
public readonly struct CultRecordKey : IEquatable<CultRecordKey>
{
private readonly string? _value;
public CultRecordKey(string value)
{
_value = value;
}
// Never null: default(CultRecordKey) and new CultRecordKey("") are one empty key.
public string Value => _value ?? string.Empty;
public override string ToString() => Value;
public bool Equals(CultRecordKey other) => string.Equals(Value, other.Value, StringComparison.Ordinal);
public override bool Equals(object? obj) => obj is CultRecordKey other && Equals(other);
public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Value);
}
public readonly struct CultRecordHandle<T>
{
public CultRecordHandle(CultRecordKey key)
{
Key = key;
}
public CultRecordKey Key { get; }
public override string ToString() => Key.ToString();
}
// The key of any CultRecordRef<T>, for code that holds one without knowing T.
public interface ICultRecordRef
{
CultRecordKey Key { get; }
}
public readonly struct CultRecordRef<T> : ICultRecordRef
{
public CultRecordRef(CultRecordKey key)
{
Key = key;
}
public CultRecordKey Key { get; }
public T? Resolve(CultCache cache)
{
if (cache == null) throw new ArgumentNullException(nameof(cache));
return cache.Get(Key) is T typed ? typed : default;
}
public override string ToString() => Key.ToString();
}
}