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.
- Android/Kotlin
- iOS/Swift
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()
)
)
import SphereonDataStore
struct SessionData: Codable {
let userId: String
let token: String
let createdAt: Int64
}
// Create a namespace with JSON codec
let sessionNamespace = KvNamespace(
name: "session.data",
codec: KotlinxSerializationJsonKvCodec(
json: Json.Default,
serializer: SessionData.serializer()
)
)
Obtaining the Store
Access the KvStore through the KvStoreService:
- Android/Kotlin
- iOS/Swift
// 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")
// Get the KvStoreService from session
let kvStoreService = session.graph.kvStoreService
// Get a store by ID
let store: KvStore = kvStoreService.getStore(storeId: "my-store")
// List available store IDs
let storeIds: [String] = kvStoreService.getStoreIds()
// Get store configuration
let config: KvStoreConfig = kvStoreService.getStoreConfig(storeId: "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
- Android/Kotlin
- iOS/Swift
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
)
// Store with TTL
let result = try await store.put(
namespace: sessionNamespace,
key: "user-123",
value: SessionData(
userId: "user-123",
token: "abc123",
createdAt: Int64(Date().timeIntervalSince1970 * 1000)
),
ttl: Duration.hours(1)
)
if result.isOk {
let metadata = result.value.metadata
print("Stored at: \(metadata.createdAtEpochMillis)")
print("Expires at: \(metadata.expiresAtEpochMillis)")
} else {
print("Failed to store: \(result.error)")
}
// Store with infinite TTL (never expires)
try await store.put(
namespace: settingsNamespace,
key: "app-config",
value: appConfig,
ttl: Duration.INFINITE
)
Retrieving Values
- Android/Kotlin
- iOS/Swift
// 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}")
}
// Get value only
let result = try await store.get(namespace: sessionNamespace, key: "user-123")
if result.isOk {
if let sessionData = result.value {
print("Token: \(sessionData.token)")
} else {
print("Key not found")
}
} else {
print("Error: \(result.error)")
}
// Get entry with metadata
let entryResult = try await store.getEntry(namespace: sessionNamespace, key: "user-123")
if entryResult.isOk {
if let entry = entryResult.value {
print("Value: \(entry.value)")
print("Created: \(entry.metadata.createdAtEpochMillis)")
print("Expires: \(entry.metadata.expiresAtEpochMillis)")
}
} else {
print("Error: \(entryResult.error)")
}
Checking Existence
- Android/Kotlin
- iOS/Swift
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}")
}
let existsResult = try await store.exists(namespace: sessionNamespace, key: "user-123")
if existsResult.isOk {
if existsResult.value {
print("Key exists")
} else {
print("Key not found")
}
} else {
print("Error: \(existsResult.error)")
}
Deleting Values
- Android/Kotlin
- iOS/Swift
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}")
}
let deleteResult = try await store.delete(namespace: sessionNamespace, key: "user-123")
if deleteResult.isOk {
if deleteResult.value {
print("Deleted successfully")
} else {
print("Key did not exist")
}
} else {
print("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.
- Android/Kotlin
- iOS/Swift
// 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}")
}
// Extend TTL without modifying the value
let touchResult = try await store.touch(
namespace: sessionNamespace,
key: "user-123",
ttl: Duration.hours(2)
)
if touchResult.isOk {
if touchResult.value {
print("TTL extended")
} else {
print("Key not found")
}
} else {
print("Error: \(touchResult.error)")
}
Listing Operations
Stores that implement KvStoreListing support key enumeration:
- Android/Kotlin
- iOS/Swift
// 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)
}
// Check if store supports listing
if let listingStore = store as? KvStoreListing {
// List all keys in a namespace
let keysResult = try await listingStore.listKeys(namespace: sessionNamespace)
if keysResult.isOk {
for key in keysResult.value {
print("Key: \(key)")
}
} else {
print("Error: \(keysResult.error)")
}
// Get all values in a namespace
let allResult = try await listingStore.getAll(namespace: sessionNamespace)
}
Cleanup Expired Entries
- Android/Kotlin
- iOS/Swift
// 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)
// Clean up expired entries in a specific namespace
let cleanupResult = try await store.cleanupExpired(namespace: sessionNamespace)
if cleanupResult.isOk {
print("Cleaned up \(cleanupResult.value) expired entries")
} else {
print("Error: \(cleanupResult.error)")
}
// Clean up all namespaces
let fullCleanupResult = try await store.cleanupExpired(namespace: nil)
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:
- Android/Kotlin
- iOS/Swift
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"
)
)
let config = KvStoreConfig(
id: "my-store",
scopeBinding: .tenant, // Data partitioned by tenant
backendId: "memory", // In-memory backend
enabled: true,
order: 0,
defaultConfigValues: [
"custom.setting": "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 Binding | Description |
|---|---|
APP | Single partition for entire application |
TENANT | Partitioned by tenant ID |
PRINCIPAL_TENANT | Partitioned by principal and tenant |
SESSION | Partitioned 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.