Skip to content

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.

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.

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) from uint to float.
  • 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.

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.

When a writer evolves a kind’s schema, readers using the older schema are not broken. The SDK handles this automatically.

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.

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 ReadFrom method reads from fixed offsets. New fields appended at the end are ignored.
  • Array kinds: The server includes layout hints (fixed_zone_size and array_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.

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.

  • 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 ResolveKindAsync for 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 use ResolveKindAsync.