Indexes & Hooks

Define indexes with a type-safe DSL and hook into entity lifecycle events.

Persistent indexes

Override buildIndexes() in your repository to define indexes. Field paths are type-safe — the same KSP-generated accessors used in queries:

class PersonsRepo(driver: KarangoDriver) : EntityRepository<Person>(
    name = "persons", storedType = kType(), driver = driver
) {
    override fun KarangoIndexBuilder<Person>.buildIndexes() {
        // Single-field index
        persistentIndex {
            field { name }
        }

        // Compound index
        persistentIndex {
            field { age }
            field { name }
        }

        // Named index with options
        persistentIndex {
            name("idx_email_unique")
            field { email }
            options { unique(true) }
        }
    }
}

Indexes are automatically created when the repository is ensured. If an index with the same name exists but different fields, Karango drops and recreates it.

TTL indexes

For documents that should expire automatically:

@Vault
data class Session(
    val userId: String,
    val token: String,
    val expiresAt: Long,  // epoch seconds
)

class SessionsRepo(driver: KarangoDriver) : EntityRepository<Session>(
    name = "sessions", storedType = kType(), driver = driver
) {
    override fun KarangoIndexBuilder<Session>.buildIndexes() {
        ttlIndex {
            field { expiresAt }
            options { expireAfter(0) }  // expire at the exact timestamp
        }
    }
}

Nested field indexes

@Vault
data class Address(val city: String, val zip: String)

@Vault
data class Person(val name: String, val address: Address)

override fun KarangoIndexBuilder<Person>.buildIndexes() {
    persistentIndex {
        field { address.city }
        field { address.zip }
    }
}

Lifecycle hooks

Hooks run on insert, save, and remove operations. The most common use case: automatic timestamps.

Timestamped entities

@Vault
data class Article(
    val title: String,
    val content: String,
    override val createdAt: MpInstant = MpInstant.Epoch,
    override val updatedAt: MpInstant = MpInstant.Epoch,
) : Timestamped {
    override fun withCreatedAt(instant: MpInstant) = copy(createdAt = instant)
    override fun withUpdatedAt(instant: MpInstant) = copy(updatedAt = instant)
}

class ArticlesRepo(
    driver: KarangoDriver,
    timestamped: TimestampedHook,
) : EntityRepository<Article>(
    name = "articles",
    storedType = kType(),
    driver = driver,
    hooks = Hooks.of<Article>(timestamped.onBeforeSave()),
)

Now createdAt is set on first insert and updatedAt is updated on every save — automatically.

Custom hooks

class AuditHook : Hooks.OnAfterSave<Article> {
    override suspend fun <X : Article> onAfterSave(
        repo: Repository<Article>,
        stored: Stored<X>,
    ) {
        println("Article saved: ${stored._id}")
    }
}

class DeleteHook : Hooks.OnAfterDelete<Article> {
    override suspend fun <X : Article> onAfterDelete(
        repo: Repository<Article>,
        deleted: Stored<X>,
    ) {
        println("Article deleted: ${deleted._id}")
    }
}

// Register hooks
hooks = Hooks.of<Article>()
    .plus(AuditHook())
    .plus(DeleteHook())

OnBeforeSave — transforming data

class NormalizeHook : Hooks.OnBeforeSave<Article> {
    override fun <X : Article> onBeforeSave(
        repo: Repository<Article>,
        storable: Storable<Article>,
    ): Storable<X> {
        @Suppress("UNCHECKED_CAST")
        return storable.modify { article ->
            article.copy(title = article.title.trim())
        } as Storable<X>
    }
}

OnBeforeSave can modify the entity before it's persisted — use it for normalization, validation, or computed fields.

Hook execution order

  • insert(): OnBeforeSave → persist → OnAfterSave
  • save(): OnBeforeSave → persist → OnAfterSave
  • remove(): delete → OnAfterDelete
  • batchInsert(): OnBeforeSave (each) → persist all → OnAfterSave (each)