Storable Hierarchy
Any Kotlin data class can become a database entity. Vault wraps it with metadata (IDs, revisions)
using the Storable<T> hierarchy — your data stays clean, the database details live in the
wrapper.
The core idea
Your data class is just data. Database metadata (_id, _key, _rev) lives in
the wrapper, not in your class:
// Your data — no database concerns
@Vault
data class Person(val name: String, val age: Int)
// After inserting into the database:
val stored: Stored<Person> = repo.insert(Person("Alice", 30))
stored() // Person(name="Alice", age=30) — your data
stored._id // "persons/abc123" — database ID
stored._key // "abc123" — document key
stored._rev // "_abc123def" — revision for optimistic locking The Storable hierarchy
Storable<T> is the sealed base class. Three concrete types represent different entity states:
| Type | When to use | Has value? | Has ID? |
|---|---|---|---|
New<T> | Entity not yet persisted | Yes | Empty until inserted |
Stored<T> | Entity loaded from or saved to the database | Yes | Yes (_id, _key, _rev) |
Ref<T> | Lazy entity reference (resolved on demand via suspend) | Via resolve() | Yes |
All Storable subtypes provide suspend fun resolve(): T and
suspend operator fun invoke(): T for accessing the wrapped value.
For Stored and New, resolution is instant (they also expose val value: T).
For Ref, resolution may suspend to load the entity from the database.
// Stored and New keep a public .value property for direct access:
stored.value // instant, no suspension
// But the uniform way across all Storable types is:
stored.resolve() // suspend, works on Stored, New, AND Ref
stored() // shorthand for resolve()
// Access fields via the uniform API
stored().name // preferred
stored.resolve().name
// Ref resolution
val author = post.author.resolve() // suspend, loads from DB
val author = post.author() // shorthand New<T> — entities before persistence
A New<T> wraps a domain value with empty metadata. It is passed to repository insert methods
and becomes a Stored<T> once persisted.
// Wrap your data for explicit insertion
val newPerson = New(Person("Alice", 30))
// Or just pass the value directly — insert() wraps it for you
repo.insert(Person("Alice", 30))
// With an explicit key
repo.insert("alice-key", Person("Alice", 30)) Stored<T> — the main entity wrapper
Stored<T> is what you work with most. It wraps your data with database metadata:
val stored: Stored<Person> = repo.findById("persons/abc123")!!
// Access data
stored().name // "Alice"
stored().age // 30
// Access metadata
stored._id // "persons/abc123"
stored._key // "abc123"
stored._rev // revision string
stored.collection // "persons"
// Modify the value — creates a new Stored with the same metadata
val updated = stored.modify { it.copy(age = 31) }
// updated._id is still "persons/abc123"
// Or use withValue for a direct replacement
val replaced = stored.withValue(Person("Bob", 25)) Modify and save pattern
// Load → modify → save
val person = repo.findById(id)!!
val updated = person.modify { it.copy(name = "Alice Smith") }
repo.save(updated) Ref<T> — lazy entity references
A Ref<T> points to another entity by ID. The referenced value is resolved lazily
on first resolve() or invoke() call and then cached in a thread-safe manner:
@Vault
data class Comment(
val text: String,
val author: Ref<Person>, // Reference to a Person
)
val comment: Stored<Comment> = repo.findById(commentId)!!
comment().author._id // "persons/abc123" — available immediately
comment().author.resolve() // Person(name="Alice", age=30) — resolves on demand
comment().author() // shorthand for resolve()
// Create refs explicitly
val eager = Ref.eager(person, id, key, rev) // already-loaded value
val lazy = Ref.lazy(id) { loadFromDb(id) } // deferred resolution When serialized to the database, Ref<T> is stored as just the ID string. When deserialized,
a lazy resolver is attached that loads the entity from the cache or database on first access.
Equality for Ref is based on _id only. Two Refs with the same ID are equal
regardless of their resolution state.
Converting between types
val stored: Stored<Person> = repo.findById(id)!!
// Stored → Ref (wraps the already-loaded value)
val ref: Ref<Person> = stored.asRef
// Any Storable → Stored
val backToStored: Stored<Person> = ref.asStored
// Resolve any Storable to its value
val person: Person = ref.resolve() // suspend — may load from DB
val same: Person = ref() // shorthand for resolve() Identity comparisons
Vault provides infix functions for comparing entity identity by database ID:
val a: Stored<Person> = repo.findById("id1")!!
val b: Stored<Person> = repo.findById("id2")!!
a hasSameIdAs b // false — different IDs
a hasOtherIdThan b // true
val list = listOf(a, b)
a hasIdIn list // true Type-safe casting
When you store a sealed hierarchy, you can safely downcast the wrapper to a more specific type:
sealed class Animal {
data class Dog(val name: String) : Animal()
data class Cat(val name: String) : Animal()
}
val stored: Stored<Animal> = repo.findById(id)!!
// Safe downcast — returns null if the type doesn't match
val dog: Stored<Dog>? = stored.castTyped<Dog>()
val cat: Stored<Cat>? = stored.castTyped<Cat>()