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>()