Skip to main content
Version: v0.25.0 (Latest)

Key-Value Store

The IDK provides a cross-platform key-value store (KvStore) for persisting application data with support for namespacing, TTL (time-to-live), and multiple storage backends.

Overview

The key-value store handles structured application data with:

  • Namespaced storage with type-safe codecs for serialization
  • TTL support for automatic expiration of entries
  • Scope binding for multi-tenant partitioning
  • Multiple backends including in-memory and persistent storage
  • Result-based error handling via IdkResult

Core Concepts

Namespaces

All KvStore operations require a namespace. A namespace serves two purposes: it isolates keys so that different features can use the same key names without collisions, and it binds a typed codec so values are automatically serialized and deserialized. Think of a namespace as a strongly-typed bucket within the store.

import com.sphereon.data.store.kv.*
import kotlinx.serialization.Serializable

@Serializable
data class SessionData(
val userId: String,
val token: String,
val createdAt: Long
)

// Create a namespace with JSON codec
val sessionNamespace = KvNamespace(
name = "session.data",
codec = KotlinxSerializationJsonKvCodec(
json = Json { ignoreUnknownKeys = true },
serializer = SessionData.serializer()
)
)

Obtaining the Store

Access the KvStore through the KvStoreService:

// Get the KvStoreService from session
val kvStoreService = session.graph.kvStoreService

// Get a store by ID
val store: KvStore = kvStoreService.getStore("my-store")

// List available store IDs
val storeIds: Array<String> = kvStoreService.getStoreIds()

// Get store configuration
val config: KvStoreConfig = kvStoreService.getStoreConfig("my-store")

Basic Operations

The basic lifecycle is straightforward: put to store a value, get to retrieve it, delete to remove it. Every operation returns an IdkResult, so you always handle success and failure explicitly rather than catching exceptions. You can also use exists to check for a key without deserializing the value, and touch to extend a TTL without rewriting the data.

Storing Values

import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.seconds

// Store with TTL
val result = store.put(
namespace = sessionNamespace,
key = "user-123",
value = SessionData(
userId = "user-123",
token = "abc123",
createdAt = System.currentTimeMillis()
),
ttl = 1.hours
)

if (result.isOk) {
val metadata = result.value.metadata
println("Stored at: ${metadata.createdAtEpochMillis}")
println("Expires at: ${metadata.expiresAtEpochMillis}")
} else {
println("Failed to store: ${result.error}")
}

// Store with infinite TTL (never expires)
store.put(
namespace = settingsNamespace,
key = "app-config",
value = appConfig,
ttl = Duration.INFINITE
)

Retrieving Values

// Get value only
val result = store.get(sessionNamespace, "user-123")

if (result.isOk) {
val sessionData: SessionData? = result.value
if (sessionData != null) {
println("Token: ${sessionData.token}")
} else {
println("Key not found")
}
} else {
println("Error: ${result.error}")
}

// Get entry with metadata
val entryResult = store.getEntry(sessionNamespace, "user-123")

if (entryResult.isOk) {
val entry: KvEntry<SessionData>? = entryResult.value
if (entry != null) {
println("Value: ${entry.value}")
println("Created: ${entry.metadata.createdAtEpochMillis}")
println("Expires: ${entry.metadata.expiresAtEpochMillis}")
}
} else {
println("Error: ${entryResult.error}")
}

Checking Existence

val existsResult = store.exists(sessionNamespace, "user-123")

if (existsResult.isOk) {
if (existsResult.value) {
println("Key exists")
} else {
println("Key not found")
}
} else {
println("Error: ${existsResult.error}")
}

Deleting Values

val deleteResult = store.delete(sessionNamespace, "user-123")

if (deleteResult.isOk) {
if (deleteResult.value) {
println("Deleted successfully")
} else {
println("Key did not exist")
}
} else {
println("Error: ${deleteResult.error}")
}

Extending TTL

When an entry's TTL expires, it becomes invisible to get and exists calls, but it may not be physically removed from storage immediately. Use cleanupExpired() to reclaim space, or rely on the backend's own eviction if it supports it. The touch operation lets you bump an entry's expiration without re-storing the value, which is useful for session keep-alive patterns.

// Extend TTL without modifying the value
val touchResult = store.touch(
namespace = sessionNamespace,
key = "user-123",
ttl = 2.hours
)

if (touchResult.isOk) {
if (touchResult.value) {
println("TTL extended")
} else {
println("Key not found")
}
} else {
println("Error: ${touchResult.error}")
}

Listing Operations

Stores that implement KvStoreListing support key enumeration:

// Check if store supports listing
if (store is KvStoreListing) {
// List all keys in a namespace
val keysResult = store.listKeys(sessionNamespace)

if (keysResult.isOk) {
keysResult.value.forEach { key ->
println("Key: $key")
}
} else {
println("Error: ${keysResult.error}")
}

// Get all values in a namespace
val allResult = store.getAll(sessionNamespace)

if (allResult.isOk) {
allResult.value.forEach { (key, value) ->
println("$key: $value")
}
} else {
println("Error: ${allResult.error}")
}

// Get all entries with metadata
val entriesResult = store.getAllEntries(sessionNamespace)
}

Cleanup Expired Entries

// Clean up expired entries in a specific namespace
val cleanupResult = store.cleanupExpired(sessionNamespace)

if (cleanupResult.isOk) {
println("Cleaned up ${cleanupResult.value} expired entries")
} else {
println("Error: ${cleanupResult.error}")
}

// Clean up all namespaces
val fullCleanupResult = store.cleanupExpired(namespace = null)

Store Configuration

Each store is identified by an ID and configured with a scope binding and backend. The scope binding determines how data is partitioned (see below), and the backend determines where data lives.

Stores are configured with KvStoreConfig:

val config = KvStoreConfig(
id = "my-store",
scopeBinding = KvStoreScopeBinding.TENANT, // Data partitioned by tenant
backendId = "memory", // In-memory backend
enabled = true,
order = 0,
defaultConfigValues = mapOf(
"custom.setting" to "value"
)
)

Scope Bindings

Scope bindings control data isolation in multi-tenant deployments. When a store is bound to TENANT, each tenant gets its own logical partition, so tenant A can never read tenant B's data even if they use the same key names. SESSION scoping is useful for transient per-session state that should not leak across sessions.

Scope BindingDescription
APPSingle partition for entire application
TENANTPartitioned by tenant ID
PRINCIPAL_TENANTPartitioned by principal and tenant
SESSIONPartitioned by session ID

Storage Backends

Pick the backend based on your durability requirements. The in-memory backend is fast and good for caches or test environments where data loss on restart is acceptable. The Kottage backend persists data to disk using a multiplatform storage library, so it survives application restarts and works across Android, iOS, and JVM targets.

In-Memory Backend

The in-memory backend stores data in memory. Data is lost when the application terminates.

val config = KvStoreConfig(
id = "cache-store",
backendId = "memory",
scopeBinding = KvStoreScopeBinding.SESSION
)

Kottage Backend

The Kottage backend provides persistent storage using the Kottage multiplatform library:

val config = KvStoreConfig(
id = "persistent-store",
backendId = "kottage",
scopeBinding = KvStoreScopeBinding.TENANT
)

Configuration via Properties

Configure stores using properties:

# Define a store
kv.stores.sessions.type=memory
kv.stores.sessions.scopebinding=SESSION
kv.stores.sessions.enabled=true
kv.stores.sessions.order=0

# Persistent store
kv.stores.credentials.type=kottage
kv.stores.credentials.scopebinding=TENANT
kv.stores.credentials.enabled=true

Data Models

KvEntry

data class KvEntry<V>(
val value: V,
val metadata: KvEntryMetadata
)

KvEntryMetadata

data class KvEntryMetadata(
val createdAtEpochMillis: Long,
val expiresAtEpochMillis: Long
)

KvPutResult

data class KvPutResult(
val metadata: KvEntryMetadata
)

Best Practices

Use meaningful namespace names to organize data logically and prevent key collisions across features.

Set appropriate TTLs for temporary data like session tokens and cached responses to prevent stale data accumulation.

Choose the right scope binding based on data isolation requirements: APP for shared data, TENANT for tenant-specific data, SESSION for session-scoped data.

Handle errors with IdkResult - all operations return result types that must be checked for success or failure.

Implement cleanup routines by periodically calling cleanupExpired() if your backend doesn't automatically purge expired entries.