Schema Evolution
ConvergeDB supports limited but safe schema evolution. The rule is simple: you can add new fields, but you cannot change or remove existing ones.
What you can do
Section titled “What you can do”Append new fields
Section titled “Append new fields”Add new fields to a kind by giving them new ordinals. Existing entities get zero-default values for the new fields.
// Original schema[ConvergenceEntity("Player")]public partial struct Player{ [Field(0)] public ulong Id { get; set; } [Field(1)] public uint Health { get; set; } [Field(2)] public float PosX { get; set; } [Field(3)] public float PosY { get; set; }
public ReadOnlyMemory<byte> EntityId { get; init; }}
// Updated schema: Level added at Field(4)[ConvergenceEntity("Player")]public partial struct Player{ [Field(0)] public ulong Id { get; set; } [Field(1)] public uint Health { get; set; } [Field(2)] public float PosX { get; set; } [Field(3)] public float PosY { get; set; } [Field(4)] public uint Level { get; set; } // new field
public ReadOnlyMemory<byte> EntityId { get; init; }}When you call RegisterKindAsync<Player>() with the updated schema, the server detects the new field and automatically migrates the kind. Existing entities have Level = 0 until explicitly updated.
What you cannot do
Section titled “What you cannot do”The following schema changes will cause RegisterKindAsync to throw an error:
- Remove a field. Once a field ordinal is assigned, it must remain in the schema.
- Change a field’s type. For example, changing
Field(1)fromuinttofloat. - Reorder fields. The ordinals must stay the same. The order of properties in the C# struct does not matter; only the
[Field(N)]ordinal matters.
How migration works internally
Section titled “How migration works internally”Each entity row carries a schema version byte. When the server encounters an entity written with an older schema version (during WAL recovery, reads, or flush), it zero-extends the row to include the new fields. This is called lazy migration: entities are not rewritten in bulk, but upgraded on access.
Reader compatibility
Section titled “Reader compatibility”When a writer evolves a kind’s schema, readers using the older schema are not broken. The SDK handles this automatically.
ResolveKindAsync for readers
Section titled “ResolveKindAsync for readers”Read-only services that subscribe to a kind owned by another writer should use ResolveKindAsync<T>() instead of RegisterKindAsync<T>():
// Reader: resolve (read-only, no mutation)KindHandle<Player> players = await client.ResolveKindAsync<Player>();
// Writer: register (creates or migrates schema)KindHandle<Player> players = await client.RegisterKindAsync<Player>();ResolveKindAsync sends a GET_KIND request to the server, which checks that the reader’s compiled fields are a valid prefix of the server’s current schema. If the server has additional fields the reader does not know about, the resolution succeeds. The reader simply does not see the new fields.
If the reader’s schema is incompatible (field type mismatch, field name mismatch), ResolveKindAsync throws an error.
How readers handle longer field data
Section titled “How readers handle longer field data”After a schema migration, the server’s STATE notifications carry field_data sized for the new schema. The SDK handles this transparently:
- Scalar-only kinds: The reader’s generated
ReadFrommethod reads from fixed offsets. New fields appended at the end are ignored. - Array kinds: The server includes layout hints (
fixed_zone_sizeandarray_dir_size) in every STATE notification. The SDK uses these to correctly locate the array directory and data zones even when new fields have shifted them.
The ChangeNotification exposes a SchemaVersion property so readers can detect when the server’s schema has drifted from their compiled version.
What old writers experience
Section titled “What old writers experience”Writers using an outdated schema will have their ASSERTs silently dropped by the server, because the field data no longer passes validation against the current schema. Writers must update their schema and call RegisterKindAsync with the new fields.
Practical advice
Section titled “Practical advice”- Plan your schema with room to grow. The maximum is 64 fields per kind, but starting with 5 and adding more over time is perfectly fine.
- Use stable, meaningful ordinals. Do not skip ordinals unless you have a reason. Gaps in ordinals are allowed but waste presence-mask bits.
- Use
ResolveKindAsyncfor read-only services. This avoids accidental schema mutation and clearly communicates intent. If a reader’s schema is outdated, it still works. If it’s incompatible, the error is immediate and clear. - Writers own the schema. Only the service that writes to a kind should call
RegisterKindAsync. Reader services should useResolveKindAsync.