Module cache-on-hand

Cache On Hand

Cache On Hand

A set of solutions for operating on a global cache. Heavily inspired by:

Platforms: JVM, Android, iOS (x64/arm64/simulator), WASM-JS

Goals

CacheOnHand provides the following:

  • Modularization that allows for taking ONLY what you need

  • No enforcement of UI framework - the barebones are compose agnostic

  • A familiar API for users coming from a react query, or SWR like framework

  • Transactional optimistic cache updates, that can rollback on error. Allowing for a snappy UI experience

  • Tools operating on your cache as a Query, Mutation, InfiniteQuery, or Flow

  • Typed refetch() and optimisticUpdater() functions that exist at definition (not at use), and are usable anywhere

Future work

  • Persisting outside the local cache

  • Cache viewer plugin

  • Integration with data fetching frameworks

Modules

Cache On Hand is split into three modules that build on each other. Use only what you need:

Module Description Use when...
cacheonhand/README.md Thread-safe reactive cache with TTL You just need a reactive in-memory cache
cacheonhand-attendants/README.md Query, mutation, flow, and infinite query operations You want managed data fetching with state tracking
cacheonhand-compose/README.md Compose Multiplatform wrappers You're building Compose UI

Each module transitively includes its dependencies — adding cacheonhand-compose gives you everything.

Installation

// build.gradle.kts
dependencies {
// Pick one — each includes its dependencies
implementation("io.github.notoriouscorgi:cacheonhand:<version>") // cache only
implementation("io.github.notoriouscorgi:cacheonhand-attendants:<version>") // cache + operations
implementation("io.github.notoriouscorgi:cacheonhand-compose:<version>") // cache + operations + compose
}

Quick Example

// Define a cache key
data class GetUserInput(val userId: String) : CacheableInput.QueryInput {
override val identifier = "GET /api/users/$userId"
}

// Create a shared cache
val cache = OnHandCache()

// Define a query factory
val getUserFactory = queryFactoryOf<GetUserInput, User, ApiException>(
cache = cache,
) {
withContext(Dispatchers.IO) { input ->
api.getUser(input.userId)
}
}

// Query your data
val queryAndResult = getUserFactory()

// Start listening to results
queryAndResult.result.collect { ... }

// Fetch your query
viewModelScope.launch {
queryAndResult.fetch(GetUserInput(1))
}


// Wrap for Compose
val rememberGetUser = composeQueryFactoryOf(getUserFactory)

// Use in a composable
@Composable
fun UserScreen(userId: String) {
val result = rememberGetUser(input = GetUserInput(userId))

when (result.fetchState) {
FetchState.LOADING -> CircularProgressIndicator()
FetchState.SUCCESS -> Text("Hello, ${result.data?.name}")
FetchState.ERROR -> Text("Error: ${result.error?.message}")
FetchState.IDLE -> {}
}
}

Refetch on Mutation

Refresh query data after a mutation by calling refetch in onSuccess:

val mutation = updateUserFactory.create()

mutation.mutate(
queryInput = UpdateUserInput("123", "New Name"),
onSuccess = { _ ->
getUserFactory.refetch(GetUserInput("123"))
// All active query instances observing this key update automatically
},
)

// Use launch() for fire-and-forget refetches (e.g., refreshing data not currently on screen)
mutation.mutate(
queryInput = UpdateUserInput("123", "New Name"),
onSuccess = { _ ->
scope.launch { getUserFactory.refetch(GetUserInput("123")) }
},
)

Features

  • Queries — fetch data with automatic caching, TTL, and refetch

  • Mutations — write operations with optimistic updates and automatic rollback

  • Flows — subscribe to reactive sources (SSE, WebSockets) with cached emissions

  • Infinite Queries — paginated data with forward/backward navigation

  • Optimistic Updates — instant UI updates that revert on failure

  • Stale-While-Revalidate — show cached data while refetching in the background

  • TTL Eviction — automatic cache entry expiry

  • Thread Safety — per-key mutex locking with deadlock prevention

  • Compose Integration — composable hooks with lifecycle-aware scoping

  • Type-Safe Errors — generic TError parameter instead of raw Throwable

  • Factory Pattern — define once, use everywhere

Rules

These assumptions apply across all modules:

  • Cache keys must be data classes — the cache uses HashMap internally. Regular classes without proper equals/ hashCode will silently create duplicate entries. Always use data class for your CacheableInput implementations.

  • Operations run on Dispatchers.Default — use withContext(Dispatchers.IO) inside your query/mutation/flow lambda for network or disk calls.

  • Don't use launch/async without awaiting inside factory lambdas — the operation must complete sequentially so state transitions (LOADING -> SUCCESS/ERROR) are correct. However, launch/async is fine inside onSuccess/ onError callbacks — those run after state transitions are complete.

  • refetch() throws on failure — unlike fetch() which catches errors and sets ERROR state, refetch() propagates exceptions to the caller. Wrap in try-catch or scope.launch.

  • Null data is not an error — a query or flow returning null sets state to SUCCESS with null data, not ERROR. This is intentional for distinguishing "no data exists" from "fetch failed".

Development

Build

./gradlew build

Run Tests

# All modules
./gradlew cacheonhand:jvmTest cacheonhand-attendants:jvmTest cacheonhand-compose:jvmTest

# Single module
./gradlew cacheonhand:jvmTest
./gradlew cacheonhand-attendants:jvmTest
./gradlew cacheonhand-compose:jvmTest

Release

Create a release and publish to Maven Central:

./scripts/release.sh         # auto-increments minor version (0.1.0 → 0.2.0)
./scripts/release.sh 1.0.0 # explicit version

Or trigger directly from GitHub Actions (Actions -> Publish -> Run workflow) with an optional version input.

The workflow runs tests first, then publishes all three modules to Maven Central.

Generate Docs

Generates API documentation from source and READMEs into docs/ for GitHub Pages:

./gradlew :dokkaGenerate

License

Licensed under the Apache License, Version 2.0

All modules:

Link copied to clipboard

A thread-safe, reactive in-memory cache with TTL support for Kotlin Multiplatform. If you only want transactional optimistic updates, use this alone.

Link copied to clipboard

Structured "attendants" to your underlying cache; Attendants provide the core operations for Kotlin Multiplatform — queries, mutations, flows, and infinite queries built on top of cacheonhand.

Link copied to clipboard

Compose Multiplatform convenience wrappers for Cache On Hand. Turns query, mutation, flow, and infinite query factories into composable hooks with automatic lifecycle management.