Unique (Single-Value) Secondary Index
One key maps to exactly one entity — the B+Tree value is a chunk-id directly, no buffer indirection, no per-entity overhead.
Status: ✅ Implemented · Visibility: Public · Level: 🔵 Core · Category: Indexing
🎯 What it solves
Fields that are inherently 1:1 with the entity that owns them — a player ID, an item SKU, a session token — need a lookup whose stored value is the entity reference, not a pointer to a set that happens to contain one entry. Modeling such a field as unique gets exactly that representation, with no indirection and no per-entity bookkeeping cost paid for a "set" that can never hold more than one member.
⚙️ How it works (in brief)
For a unique field, the B+Tree value at each key is the entity's component chunk-id, stored directly in the leaf
— there is no separate HEAD buffer and no hidden ElementId added to the component's storage layout. On commit,
Add inserts a brand-new key, Move atomically relocates an existing key in a single tree traversal when the
indexed field's value changes (write-locking at most two leaves — the old key's and the new key's), and Remove
deletes the key outright on entity deletion. A second entity attempting to claim an already-mapped key is
rejected at commit, since the value slot has room for exactly one chunk-id.
💻 Usage
[Component("Game.Player", 1)]
struct Player
{
[Index] // unique — AllowMultiple defaults to false
public int PlayerId;
public String64 Name;
}
[Archetype]
partial class PlayerArchetype : Archetype<PlayerArchetype>
{
public static readonly Comp<Player> P = Register<Player>();
}
// Cold path — resolve once, reuse on the hot path
var idIndex = dbe.GetIndexRef<Player, int>(p => p.PlayerId);
using (var tx = dbe.CreateQuickTransaction())
{
tx.Spawn<PlayerArchetype>(PlayerArchetype.P.Set(new Player { PlayerId = 42, Name = "Nova" }));
tx.Commit();
}
// Point lookup — minKey == maxKey; the value found is the entity's own chunk-id, no indirection to resolve
using (var tx = dbe.CreateQuickTransaction())
{
using var hit = tx.EnumerateIndex<Player, int>(idIndex, 42, 42);
foreach (var entry in hit)
{
// entry.EntityPK, entry.Key, entry.Component
}
}
⚠️ Guarantees & limits
- Zero storage overhead beyond the field itself — no hidden
ElementId, and no TAIL history segment is allocated on account of this index. Moveis the commit-time operation for a value change: one descent, at most two leaf write-locks, never a separate remove-then-insert pair.- A duplicate key on create or update throws
UniqueConstraintViolationException(TyphonErrorCode.UniqueConstraintViolation, 4001; non-transient) atCommit(), not atSpawn/Writetime — the key is only resolved against the B+Tree at commit. Transaction.EnumerateIndexis the only read path; there is no separateTryGet-style point-lookup entry point in the public API — passminKey == maxKeyfor a point lookup.- Switching a field to
AllowMultiplelater is a schema change: it adds the 4-byteElementIdoverhead to every existing component instance and reroutes commit-path operations fromMove/RemovetoMoveValue/RemoveValue.
🧪 Tests
- BtreeTests —
ForwardInsertionTest/ReverseInsertionTest/CheckTree/CheckRemovefamily: Add/Remove correctness for the single-value B+Tree across all key widths, noElementIdoverhead - BulkEnumerateTests —
SecondaryIndex_UniqueField: engine-level round trip throughGetIndexRef+Transaction.EnumerateIndex
🔗 Related
- Parent feature: Secondary Index Storage Modes
- Sibling: Multi-value secondary index (AllowMultiple)