Repositories

Repository<T> is the interface that both Karango and Monko implement. It defines CRUD operations, lifecycle hooks, and entity cache integration.

The Repository<T> interface

Every repository is parameterized by the entity type it stores. The interface provides a standard set of operations that work the same way regardless of the underlying database:

interface Repository<T : Any> {
    val name: String                           // collection/table name
    val storedType: TypeRef<T>                 // type information for deserialization

    suspend fun findAll(): Cursor<Stored<T>>
    suspend fun findById(id: String?): Stored<T>?

    suspend fun <X : T> insert(new: X): Stored<X>
    suspend fun <X : T> insert(key: String?, new: X): Stored<X>
    suspend fun <X : T> insert(new: New<X>): Stored<X>

    suspend fun <X : T> save(stored: Stored<X>): Stored<X>
    suspend fun <X : T> save(storable: Storable<X>): Stored<X>

    suspend fun <X : T> remove(entity: Stored<X>): RemoveResult
    suspend fun remove(idOrKey: String): RemoveResult
    suspend fun removeAll(): RemoveResult
}

CRUD operations

Insert

@Vault
data class Person(val name: String, val age: Int)

// Insert a value directly — wraps it in New<T> automatically
val stored: Stored<Person> = repo.insert(Person("Alice", 30))

// Insert with an explicit key
val stored2: Stored<Person> = repo.insert("alice-key", Person("Alice", 30))

// Insert with a modification before storing
val stored3: Stored<Person> = repo.insert(Person("Alice", 30)) { person ->
    person.copy(age = person.age + 1)
}

// Try inserting — returns null on failure instead of throwing
val maybe: Stored<Person>? = repo.tryInsert(Person("Alice", 30))

Find

// Find by ID — returns null if not found
val person: Stored<Person>? = repo.findById("persons/abc123")

// Find all — returns a Cursor
val all: Cursor<Stored<Person>> = repo.findAll()

// Collect all into a list
val list: List<Stored<Person>> = repo.findAll().toList()

Save

Save accepts both Stored<T> and Storable<T>. When given a New<T>, it delegates to insert. When given a Ref<T>, it resolves the value first:

// Save a modified entity
val updated = stored.modify { it.copy(name = "Alice Smith") }
val saved: Stored<Person> = repo.save(updated)

// Save with inline modification
val saved2: Stored<Person> = repo.save(stored) { person ->
    person.copy(age = 31)
}

// Save only if modified — avoids unnecessary writes
val saved3: Stored<Person> = repo.saveIfModified(stored) { person ->
    person.copy(age = 31)
}

Remove

// Remove by entity
repo.remove(stored)

// Remove by ID or key
repo.remove("persons/abc123")

// Remove all documents
repo.removeAll()

Lifecycle hooks

Repositories support three hook types that run at specific points in the entity lifecycle. Hooks are composable — you can attach multiple hooks, and they run in order.

Hook When it runs Suspend?
OnBeforeSave<T> Before insert or save. Can modify the entity. No
OnAfterSave<T> After insert or save completes. Yes
OnAfterDelete<T> After a remove completes. Yes

OnBeforeSave runs synchronously and returns the (potentially modified) entity. OnAfterSave and OnAfterDelete run asynchronously in the background after the database operation completes.

// Built-in hook: automatic timestamps
class TimestampedHook<T : Timestamped> : Repository.Hooks.OnBeforeSave<T> {
    override fun <X : T> onBeforeSave(
        repo: Repository<T>,
        storable: Storable<T>,
    ): Storable<X> {
        // Sets createdAt on first insert, updatedAt on every save
        ...
    }
}

// Composing hooks
val hooks = Repository.Hooks.of(
    TimestampedHook<MyEntity>(),
    MyCustomHook(),
)

EntityCache

The EntityCache deduplicates entity lookups during deserialization. When a cursor deserializes entities, it can store them in the cache. Subsequent Ref<T> resolutions check the cache before hitting the database.

interface EntityCache {
    fun clear()
    fun <T> put(id: String, value: T): T
    fun <T> getOrPut(id: String, provider: () -> T?): T?
    suspend fun <T> getOrPutAsync(id: String, provider: suspend () -> T?): T?
}

Vault ships two implementations:

  • DefaultEntityCache — thread-safe cache backed by ConcurrentHashMap, with synchronized sync access and Mutex-based suspend access
  • NullEntityCache — no-op implementation that always calls through to the provider

In practice, you rarely interact with the EntityCache directly. It is managed by the cursor and used automatically during Ref resolution.

Repository setup

Repositories can ensure their own infrastructure (collections, indexes) exists:

// Ensure the collection and indexes exist
repo.ensure()

// Or separately
repo.ensureRepository()  // create the collection if missing
repo.ensureIndexes()     // create defined indexes

// Validate index state
val info: IndexesInfo = repo.validateIndexes()
info.healthyIndexes   // indexes that exist as defined
info.missingIndexes   // defined but not yet created
info.excessIndexes    // exist but not defined

// Recreate all indexes (drop + re-create)
repo.recreateIndexes()

Next steps

Ready to connect to a database? Choose your backend: