04 — Schema
Code: src/Typhon.Engine/Schema/ + sibling project src/Typhon.Schema.Definition/
Typhon is schema-first. Every component you store has a declared structure — fields, types, offsets, indexes — and that structure is persisted alongside the data. When you reopen a database, the engine reads the persisted schema, compares it to the runtime structs you've registered, and either accepts the match, evolves the layout in place, or refuses to open. There is no untyped storage and no "schemaless" fast path.
This document covers what a component schema looks like, where it lives on disk, how registration works at startup, and how the engine handles schema evolution (added/removed/widened/migrated fields) on reopen.
Component registration flow:
RegisterComponentByType<T>() → DatabaseDefinitions get-or-create the DBComponentDefinition → construct the ComponentTable → create its segments (Component / RevTable / indexes) → store in the engine's table map.
1. Overview — schema-first by design
Three principles drive the design:
- Every component is declared. The struct's C# definition (
[Component],[Field],[Index],[SpatialIndex],[ForeignKey]) is the runtime source of truth. The engine reflects over the struct at registration time to build aDBComponentDefinition. - Every component is persisted. The schema itself is stored in the database — as system components (
ComponentR1,FieldR1,ArchetypeR1,SchemaHistoryR1) — using the sameComponentTablemachinery that user components use. Schema is data, just system data. - Schema evolution is eager, not lazy. When you reopen a database with a changed C# definition, the engine resolves the difference before WAL replay begins. By the time application code is allowed to write, the on-disk layout already matches the runtime layout. There is no per-read fixup, no shadow field map at query time.
The user-facing types — FieldType, [Component], [Field], String64, Variant, spatial primitives — live in a sibling project Typhon.Schema.Definition so that codegen, tooling, and external consumers can reference the schema vocabulary without pulling in the full engine. See 01-foundation §9 for the full list.
2. Component / field model
FieldType enum
Typhon.Schema.Definition/FieldType.cs
The closed set of types Typhon understands for a component field. It's a [Flags] enum, but only two flag bits are mixed with the base values — Unsigned (256) and DoubleFloat (512) — so each combination still names exactly one storage kind.
| Group | Members |
|---|---|
| Boolean / numeric | Boolean, Byte, Short, Int, Long, Float, Char |
Unsigned (= Unsigned \| base) |
UByte, UShort, UInt, ULong |
Double-precision (= DoubleFloat \| base) |
Double (= DoubleFloat \| Float), Point2D/3D/4D, QuaternionD, AABB2D/3D, BSphere2D/3D |
| Strings | String64 (fixed 64 B), String1024 (fixed 1024 B), String (variable — 32 B inline + VSB overflow), Variant (tagged-union, stored as String64) |
| Points / quaternions (float) | Point2F, Point3F, Point4F, QuaternionF |
| Spatial boxes & spheres (float) | AABB2F, AABB3F, BSphere2F, BSphere3F |
| Spatial boxes & spheres (double) | AABB2D, AABB3D, BSphere2D, BSphere3D |
| References | Component (8-byte reference), Collection (= ComponentCollection<T>, 4-byte handle) |
| Modifier flags | Unsigned = 256, DoubleFloat = 512 |
Modifier bits keep the relationship explicit: Double is DoubleFloat | Float, AABB3D is DoubleFloat | AABB3F. The widening engine (§4) uses these relationships directly.
DatabaseSchemaExtensions.FromType<T>() maps a CLR type to (FieldType, underlyingType). FieldSizeInComp() returns the on-component byte width — e.g. Point3F → 12, AABB3D → 48, Variant → 64 (it's a String64 under the hood). Variable-width String reports 32 (the inline portion); the overflow lives in a variable-sized buffer segment.
DBComponentDefinition and Field
Schema/public/DBComponentDefinition.cs
The runtime descriptor for one revision of one component. Built by DatabaseDefinitions.CreateFromAccessor<T>() via reflection over the struct's public fields.
public class DBComponentDefinition
{
public string Name { get; }
public int Revision { get; }
public bool AllowMultiple { get; }
public StorageMode StorageMode { get; } // Versioned / SingleVersion / Transient
public IReadOnlyDictionary<string, Field> FieldsByName { get; }
public int MaxFieldId { get; }
public int ComponentStorageSize { get; } // bytes of payload
public int ComponentStorageOverhead { get; } // entityPK + AllowMultiple index entries
public int ComponentStorageTotalSize => ComponentStorageSize + ComponentStorageOverhead;
public int EntityPKOverheadSize { get; } // 8 for SV/Transient, 0 for Versioned
public int IndicesCount { get; }
public int MultipleIndicesCount { get; }
public Field SpatialField { get; } // null if none
}
FullName is "<Name>:R<Revision>" — name + revision identify a schema slot uniquely. Same name + different revision = different schema slot.
Field carries everything a writer or reader needs at runtime:
public class Field
{
public int FieldId { get; } // stable across reopens, resolved by FieldIdResolver
public string Name { get; }
public FieldType Type { get; }
public FieldType UnderlyingType { get; }
public int OffsetInComponentStorage { get; }
public int FieldSize { get; } // == Type.FieldSizeInComp()
public Type DotNetType { get; }
public bool IsStatic { get; set; }
public bool HasIndex { get; set; }
public bool IndexAllowMultiple { get; set; }
public int ArrayLength { get; set; } // > 0 if [n]-element fixed array
public bool HasSpatialIndex { get; set; }
public SpatialFieldType SpatialFieldType { get; set; }
public float SpatialMargin / SpatialCellSize { get; set; }
public SpatialMode SpatialMode { get; set; } // Static / Dynamic
public uint SpatialCategory { get; set; } // archetype-level mask, default uint.MaxValue
public bool IsForeignKey { get; set; }
public Type ForeignKeyTargetType { get; set; }
}
Build() validates field uniqueness (no duplicate FieldId / Name / OffsetInComponentStorage), computes MaxFieldId, populates a flat Field[] keyed by ID for O(1) lookup, and validates that indexed fields use index-compatible types (DoesFieldTypeSupportIndex — anything from Byte through String64, including modified variants).
Attributes
Typhon.Schema.Definition/Attributes.cs — applied to the struct or its fields:
| Attribute | Target | Effect |
|---|---|---|
[Component(name, revision, allowMultiple = false)] + StorageMode = ... + PreviousName = ... |
struct | Marks a struct as a component. Name keys the schema slot, Revision is the migration version, StorageMode chooses Versioned (default) / SingleVersion / Transient. |
[Field(FieldId = N, Name = "...", PreviousName = "...")] |
field | Override the auto-assigned FieldId or current/previous field name. Used to keep FieldIds stable across renames. |
[Index(AllowMultiple = false, OnParentDelete = CascadeAction.None)] |
field | Build a B+Tree index on this field. AllowMultiple allows non-unique keys (multi-value index). |
[SpatialIndex(margin, cellSize, Mode = Dynamic, Category = uint.MaxValue)] |
field (AABB / BSphere) | Build a spatial index (R-Tree). At most one per component. Not allowed on Transient. |
[ForeignKey(typeof(TargetComponent))] |
long field |
Marks the field as an FK reference to another component's PK. |
[Archetype(revision = 1, alias = null)] + Name = ... + PreviousName = ... |
class | Marks an ECS archetype. Identity is the CLR type name (or Name = "…"); the engine auto-assigns a per-process catalog id + a per-DB routing id — no numeric id is set in source. PreviousName keeps identity stable across a rename, matched on reopen (see 06-ecs). |
3. Schema persistence — the system components
Ecs/public/DatabaseEngine.cs:17–137
Schemas are persisted as system components — ordinary ComponentTable entries managed by the engine itself. Four schemas are defined; the R1 suffix means revision 1 of the system schema (the schema-of-the-schema). If the system schema ever needs to change in a non-backward-compatible way, an R2 set will be added alongside.
ComponentR1 — one per registered user component
[Component(SchemaName, 1)]
public struct ComponentR1
{
public const string SchemaName = "Typhon.Schema.Component";
public String64 Name; // component schema name (e.g., "Position")
public String64 POCOType; // CLR type name
public int CompSize; // ComponentStorageSize
public int CompOverhead; // ComponentStorageOverhead
public int ComponentSPI; // root page index of ComponentSegment
public int VersionSPI; // root page index of CompRevSegment (revision chain)
public int DefaultIndexSPI; // root page index of the L64 default index segment
public int String64IndexSPI; // root page index of the String64 index segment
public int TailIndexSPI; // tail multi-value index segment
public ComponentCollection<FieldR1> Fields;
public int SchemaRevision;
public int FieldCount;
public byte StorageMode;
}
FieldR1 — one per declared field on each component
public struct FieldR1
{
public const string SchemaName = "Typhon.Schema.Field";
public String64 Name;
public int FieldId;
public FieldType Type;
public FieldType UnderlyingType;
public uint IndexSPI;
public bool IsStatic;
public bool HasIndex;
public bool IndexAllowMultiple;
public int ArrayLength;
public int OffsetInComponentStorage;
public int SizeInComponentStorage;
}
The FieldR1 entries for a given ComponentR1 are stored in its Fields ComponentCollection<FieldR1> — a variable-sized-buffer reference whose bytes live in a shared per-stride VSBS segment. The bootstrap dictionary records the root page of that segment under BK_CollectionFieldR1.
ArchetypeR1 — one per registered archetype
public struct ArchetypeR1
{
public String64 Name;
public ushort ArchetypeId; // per-DB archetype routing id (16-bit, engine-assigned, re-matched by Name on reopen)
public ushort ParentArchetypeId; // 0xFFFF = no parent
public byte ComponentCount;
public int Revision; // archetype schema revision
public ComponentCollection<String64> ComponentNames;
public int EntityMapSPI; // EntityMap root page (0 = rebuild)
public int ClusterSegmentSPI; // cluster storage root (0 = none)
public long NextEntityKey; // resume counter on reopen
}
SchemaHistoryR1 — audit trail
public struct SchemaHistoryR1
{
public long Timestamp; // DateTime.UtcNow.Ticks
public String64 ComponentName;
public int FromRevision, ToRevision;
public int FieldsAdded, FieldsRemoved, FieldsTypeChanged;
public int EntitiesMigrated;
public int ElapsedMilliseconds;
public SchemaChangeKind Kind; // Compatible / Migration / SystemUpgrade
}
One row per schema change event. Read at any time via dbe.GetSchemaHistory().
Bootstrap — where the engine starts reading
DatabaseEngine keeps a small set of keys in the bootstrap dictionary (page 0 of the database):
| Key | Meaning |
|---|---|
BK_SystemSchemaRevision |
0 = empty DB → bootstrap fresh, ≥1 = load existing |
BK_SysComponentR1 |
SPIs (Component / Version / DefaultIndex / String64Index) for the ComponentR1 table |
BK_SysSchemaHistory |
Same four SPIs for the SchemaHistoryR1 table |
BK_CollectionFieldR1 |
Root page of the FieldR1 ComponentCollection segment |
BK_UserSchemaVersion |
Counter, incremented on every user-component schema change |
BK_NextFreeTSN |
TSN counter persisted on shutdown; MVCC visibility on reopen |
BK_LastTickFenceLSN |
Last tick-fence record written by the WAL |
On reopen, LoadSystemSchemaR1 reads bootstrap, reconstructs the system tables from their SPIs, walks ComponentR1.ComponentSegment to populate _persistedComponents + _persistedFieldsByComponent, and only then does user component registration begin. Schema bootstrap completes before WAL recovery (see 11-durability) — recovery needs the schema to know how to deserialize WAL records.
SystemCrud — the bare-bones helper
Schema/internals/SystemCrud.cs
System schema entries are written through a stripped-down CRUD helper: no MVCC, no WAL, no conflict detection, no revision tracking. The chunkId allocated by ComponentSegment.AllocateChunk is the stable identifier. Why so minimal? Schema mutations happen during bootstrap (under an exclusive registration lock) or during eager migration (the database isn't open for traffic yet) — none of the MVCC / WAL machinery applies. The full ComponentTable plumbing kicks in for user data only.
4. Schema evolution
Schema/internals/SchemaEvolutionEngine.cs, SchemaValidator.cs
When RegisterComponentFromAccessor<T>() runs against a DB that already has a ComponentR1 for that name, the engine computes a SchemaDiff between persisted and runtime, classifies it, and acts.
Diff classification
SchemaValidator.ComputeDiff() walks persisted FieldR1s (keyed by FieldId) against the runtime FieldsByName. Each surviving / added / removed field becomes a FieldChange with a CompatibilityLevel:
| Level | Meaning |
|---|---|
Identical |
Bit-for-bit match — nothing to do. |
InformationOnly |
Rename only (FieldId stable via [Field(PreviousName=...)]). |
Compatible |
Field add / remove / offset reorder / index add — no migration needed or a pure copy + zero-fill migration. |
CompatibleWidening |
Type widened along a documented lossless pair (Byte→Int, Float→Double, Point3F→Point3D, String64→String1024, …). Field-map migration with ApplyWidening. |
Breaking |
Type changed in a non-widening way, or array length changed. Requires a user-supplied migration function. |
The overall SchemaDiff.Level is the max severity across all FieldChanges. Index changes are always at most Compatible.
Eager migration on reopen
If NeedsMigration(diff, oldStride, newStride) returns true — stride changed, offset changed, or a widening occurred — the engine runs SchemaEvolutionEngine.Migrate (compatible path) or MigrateWithFunction (breaking path) immediately, before any user transaction can run.
The migration pipeline:
- Analyze — build a
FieldMapEntry[]linking each surviving field's old offset/type/size to its new offset/type/size. - Allocate segments — new component segment at the new stride; new revision segment at the standard
CompRevChunkSize. - Migrate entities — walk every occupied chunkId in the old component segment, reserve the same chunkId in the new segment (preserves all index references and revision-chain pointers), zero-fill, copy the overhead area, then copy/widen each surviving field via
ApplyWideningorBuffer.MemoryCopy. New fields stay zero from the page init. Removed fields are simply dropped. - Migrate revision chains — HEAD only — for each old revision-chain root, locate the most recent revision element, write it as the sole entry in the new chain. Pre-migration history is discarded — a deliberate tradeoff: migration cost stays bounded, and the lost history pre-dates a breaking change so it would be unreadable under the new schema anyway. After migration, MVCC starts a new chain.
- Flush —
changeSet.SaveChanges()+MMF.FlushToDisk()before touching SPIs. - Delete old segments — best-effort cleanup. A crash here leaves orphan pages but no correctness issue: the new SPIs are durable and the old ones are unreferenced.
For breaking changes, MigrateWithFunction runs the user's MigrationChain per-entity. Multi-step chains (e.g., R1→R2→R3) use a stackalloc'd double-buffer for small components or ArrayPool buffers for >1024 B.
MigrationFailure — per-entity diagnostic
Errors/public/SchemaMigrationException.cs
A breaking migration may have entities whose data the user function refuses (e.g., negative value where the new type is unsigned). Per-entity exceptions are caught and accumulated; old segments stay untouched if any failure occurs.
public readonly struct MigrationFailure
{
public int ChunkId { get; init; } // logical entity id
public string OldDataHex { get; init; } // up to 64 bytes of the old component data, hex-encoded
public Exception Exception { get; init; }
}
If failures.Count > 0, the engine throws SchemaMigrationException(name, failures) and rolls back: the new segment is deleted, the old SPIs remain bound. Fix the migration function, restart.
Validation modes & exceptions
The SchemaValidationMode enum is set per registration call (default Enforce):
| Mode | Behavior on breaking diff |
|---|---|
Enforce |
Look for a registered migration chain; if none, throw SchemaValidationException(diff). |
Skip |
Bypass validation entirely. UNSAFE — corruption is the caller's problem. |
Three exceptions live in 14-errors:
| Exception | When |
|---|---|
SchemaValidationException |
Breaking changes with no registered migration chain (and Enforce mode). Carries the full SchemaDiff. |
SchemaMigrationException |
One or more entities failed during a user-driven migration. Carries IReadOnlyList<MigrationFailure>. |
SchemaDowngradeException |
Persisted revision > runtime revision — the DB was written by a newer app version. Refuses to open. |
SchemaDowngrade reuses the TyphonErrorCode.SchemaValidation error code intentionally.
Migration progress
A long migration over millions of entities is observable. RaiseMigrationProgress fires MigrationProgressEventArgs with the current MigrationPhase (Analyzing / AllocatingSegments / MigratingEntities / RecreatingRevisionChain / BuildingNewIndexes / UpdatingMetadata / Flushing / Complete), entity counters, and percent complete. Subscribe via DatabaseEngine.MigrationProgress.
5. Registration flow
The two public entry points on DatabaseEngine:
RegisterComponentFromAccessor<T>()
Ecs/public/DatabaseEngine.cs:3527
public bool RegisterComponentFromAccessor<T>(
ChangeSet changeSet = null,
SchemaValidationMode schemaValidation = SchemaValidationMode.Enforce
) where T : unmanaged
Compile-time-typed registration. The path:
- Look up persisted
FieldR1[]for this component name. If found, build aFieldIdResolver. - Reflect over
TviaDBD.CreateFromAccessor<T>(resolver)— produces aDBComponentDefinition.FieldIdResolverassigns each runtime field a stable FieldId. Priority: explicit[Field(FieldId=N)]→ name match in persisted →PreviousNamematch → fresh ID =max(persisted) + 1. Records renames; flags removed fields onComplete().
- If no persisted record exists → create path: allocate component + revision segments, persist
ComponentR1+FieldR1[], incrementBK_UserSchemaVersion. - If persisted → evolve path:
- Compare
persistedRevisionvstargetRevision(from[Component(Revision=N)]).persisted > runtime→SchemaDowngradeException. - Compute
SchemaDiff. - If
HasBreakingChangesandmode == Enforce: look up a migration chain (BFS shortest path throughMigrationRegistry). No chain →SchemaValidationException. Chain found → runMigrateWithFunction. - Else if not identical and compatible/widening → run
Migrate(field-map). - Populate any newly-added indexes by scanning entities.
- Persist updated
ComponentR1+FieldR1[]; record aSchemaHistoryR1row; incrementBK_UserSchemaVersion.
- Compare
Returns true on first registration; false if the component was already registered in this session (idempotent re-registration).
RegisterComponentByType(Type)
Ecs/public/DatabaseEngine.cs:3502
The non-generic overload for runtime-discovered types — e.g. the Workbench loading a *.schema.dll into a collectible AssemblyLoadContext. Internally MakeGenericMethods RegisterComponentFromAccessor<T> and unwraps TargetInvocationException via ExceptionDispatchInfo so SchemaValidationException / SchemaDowngradeException / SchemaMigrationException surface with their original stack traces, not wrapped.
public bool RegisterComponentByType(
Type componentType,
ChangeSet changeSet = null,
SchemaValidationMode schemaValidation = SchemaValidationMode.Enforce);
Throws ArgumentException if componentType is a reference type or an open generic — the unmanaged constraint is verified at specialization time by the CLR.
Note on the diagram: Older docs and the embedded SVG still use the name
RegisterComponent<T>()for the entry point. The actual API surface has beenRegisterComponentFromAccessor<T>/RegisterComponentByTypefor some time. Don't go looking forRegisterComponent<T>in current code.
StorageMode is fixed per (name, revision)
StorageMode comes solely from the component's [Component] attribute — there is no per-registration override. On reopen, re-declaring a persisted component at the same revision with a different StorageMode throws InvalidOperationException (definition.Revision == persisted.Comp.SchemaRevision && declared != persisted): reinterpreting persisted bytes under a different storage discipline would be silent corruption. Changing how a component is stored requires a new [Component] revision. (Full data migration across a mode change on a revision bump is not yet wired — tracked in #546.)
6. Diff & versioning
SchemaDiff shape
public class SchemaDiff
{
public string ComponentName { get; }
public int PersistedRevision { get; }
public List<FieldChange> FieldChanges { get; } // each carries Kind + Level + before/after data
public List<IndexChange> IndexChanges { get; }
public CompatibilityLevel Level { get; } // max across all changes
public bool IsIdentical { get; }
public bool HasBreakingChanges { get; }
public bool HasCompatibleChanges { get; }
public string Summary { get; } // "+2 added, ~1 widened, 3 reordered"
}
Each FieldChange carries a FieldChangeKind (Added, Removed, TypeChanged, TypeWidened, Renamed, OffsetChanged, SizeChanged, IndexAdded, IndexRemoved, IndexTypeChanged) plus the old/new type, offset, and size. The diff's FormatDetailedMessage() is what you see in a thrown SchemaValidationException — both breaking and compatible sections, with the exact remediation hint at the bottom.
Revision identity & format revision
Three distinct version numbers:
| Counter | Where | Semantics |
|---|---|---|
ComponentAttribute.Revision |
per-component, in C# | App-defined migration version. R1 → R2 → R3 advances when the developer changes the struct. |
BK_SystemSchemaRevision |
bootstrap | The version of the system schema itself (ComponentR1/FieldR1/etc. layout). Currently 1. |
BK_UserSchemaVersion |
bootstrap | Monotonic counter, incremented on every persisted user-schema change. Used by tooling (Workbench) to detect "schema-different-than-last-time". |
PagedMMF.DatabaseFormatRevision |
const, written to RootFileHeader on create |
The on-disk container format revision (page layout, segment header layout, WAL chunk format). Currently 1. The engine refuses to open a DB whose DatabaseFormatRevision it doesn't know. |
Format-revision compatibility is binary: a given engine build understands exactly one format revision. A bump means write a migration tool, not a try-anyway path. Component-level migration is for user-component evolution within an unchanged container format.
Offline inspection — DatabaseSchema.Inspect & ValidateEvolution
Schema/public/DatabaseSchema.cs
Two static utilities open a temporary engine read-only:
DatabaseSchema.Inspect(path)— returns aDatabaseSchemaReportwith name, format revision, system & user schema versions, and per-componentComponentSchemaReport(fields, indexes, entity counts).DatabaseSchema.ValidateEvolution(path, configure)— dry-run. The caller registers types and migrations against anISchemaRegistrar; the engine computes diffs and verifies migration paths exist, without modifying the DB. ReturnsEvolutionValidationResultwith per-component diff results and errors. Use this in CI to catch missing migration functions before a release ships.
See also
- 01-foundation §9 — sibling
Typhon.Schema.Definitiontypes (String64,Variant,PackedDateTime, spatial primitives,StorageMode) - 06-ecs — archetypes are the runtime consumers of
DBComponentDefinitions - 11-durability — schema bootstrap completes before WAL recovery begins; recovery needs schema to deserialize records
- 14-errors —
SchemaValidationException,SchemaMigrationException,SchemaDowngradeException