Store
A tiny Kotlin multiplatform library that assists in saving and restoring objects to and from disk using kotlinx.coroutines, kotlinx.serialisation and okio. Inspired by RxStore
Features
- π Read-write locks; with a mutex FIFO lock
- πΎ In-memory caching; read once from disk and reuse
- π¬ Default values; no file? no problem!
- π Migration support; moving shop? take your data with you
- π Multiplatform!
At a glance
// Take any serializable model
@Serializable data class Pet(val name: String, val age: Int)
// Create a store
val store: KStore<Pet> = storeOf(filePath = "path/to/my_cats.json")
// Get, set, update or delete values
val mylo: Pet? = store.get()
store.set(mylo)
store.update { pet: Pet? ->
pet?.copy(age = pet.age + 1)
}
store.delete()
// Observe for updates
val pets: Flow<Pet?> = store.updates
Adding to your project
KStore is published on Maven Central
repositories {
mavenCentral()
// or for snapshot builds
maven("https://s01.oss.sonatype.org/content/repositories/snapshots/")
}
Include the dependency in commonMain
. Latest version
sourceSets {
val commonMain by getting {
dependencies {
implementation("io.github.xxfast:kstore:<version>")
}
}
}
Platform configurations
KStore provides factory methods to create your platform specific store. There's two variants
1. kstore-file
kstore-file
This includes factory methods to create a store that read/writes to a file.
This is suitable for android
, ios
, desktop
and js { nodejs() }
targets where we have a file system
Include the dependency in androidMain
, iosMain
, desktopMain
or jsMain
(only for nodejs()
).
sourceSets {
// You may define this on commonMain if your js targets nodejs.
val commonMain by getting {
dependencies {
implementation("io.github.xxfast:kstore-file:<version>")
}
}
}
Then create a store
val store: KStore<Pet> = storeOf(filePath = "path/to/my_cats.json")
For full configurations, see here
2. kstore-storage
kstore-storage
This includes factory methods to create a store that read/writes to a key-value storage provider
This is suitable for js { browser() }
target where we have storage providers (e.g:- localStorage, sessionStorage)
Include the dependency in jsMain
(only for browser()
).
sourceSets {
// only for js { browser() }
val jsMain by getting { dependencies { implementation("io.github.xxfast:kstore-storage:<version>") } }
}
Then create a store
val store: KStore<Pet> = storeOf(key = "my_cats")
Full usage
Given that you have a @Serializable
model
@Serializable data class Pet(val name: String, val age: Int) // Any serializable
val mylo = Pet(name = "Mylo", age = 1)
Get value
Get a value once
val mylo: Pet? = store.get()
Or observe for changes
val pets: Flow<Pet?> = store.updates
Set value
store.set(mylo)
Update a value
store.update { pet: Pet? ->
pet?.copy(age = pet.age + 1)
}
Note: this maintains a single mutex lock transaction, unlike get()
and a subsequent set()
Delete/Reset value
store.delete()
You can also reset a value back to its default (if set, see here)
store.reset()
Create a list store
Create a list store
KStore provides you with some convenient extensions to manage stores that contain lists.
listStoreOf
is the same as storeOf
, but defaults to empty list instead of null
val listStore: KStore<List<Pet>> = listStoreOf("path/to/file")
Get values
![](https://user-images.githubusercontent.com/13775137/188902401-121fd1a2-c506-4982-82dd-c8c4404c81a0.png)
val pets: List<Cat> = listStore.getOrEmpty()
val pet: Cat = store.get(0)
or observe values
val pets: Flow<List<Cat>> = listStore.updatesOrEmpty
Add or remove elements
![](https://user-images.githubusercontent.com/13775137/188902401-121fd1a2-c506-4982-82dd-c8c4404c81a0.png)
listStore.plus(cat)
listStore.minus(cat)
Map elements
![](https://user-images.githubusercontent.com/13775137/188902401-121fd1a2-c506-4982-82dd-c8c4404c81a0.png)
listStore.map { cat -> cat.copy(cat.age = cat.age + 1) }
listStore.mapIndexed { index, cat -> cat.copy(cat.age = index) }
Configurations
Everything you want is in the factory method
private val store: KStore<Pet> = storeOf(
// see πGetting the path
key/filePath = pathTo("id"),
// Returns this value if the file is not found. Defaults to null
default = null,
// Maintain a cache. If set to false, it always reads from disk
enableCache = true,
// Optional, see π Migrating stores
version = 0,
migration = { version, jsonElement -> default },
// Serializer to use. Defaults serializer ignores unknown keys and encodes the defaults
serializer = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}, // optional
// Optional, storage provider to use (Only for kstore-storage)
storage = localStorage,
)
πGetting the path
Getting a path to a file is different for each platform and you will need to define how this works for each platform
expect fun pathTo(id: String): String
On Android
Getting a path on android involves invoking from filesDir
from Context
.
actual fun pathTo(id: String): String = "${context.filesDir.path}/$id.json"
On iOS & other Apple platforms
To get a path on iOS, you can use NSHomeDirectory
.
actual fun pathTo(id: String): String = "${NSHomeDirectory()}/$id.json"
On Desktop (JVM)
This depends on where you want to save your files, but generally you should save your files in a user data directory. Recommending to use harawata's appdirs to get the platform specific app dir
actual fun pathTo(id: String): String {
// implementation("net.harawata:appdirs:1.2.1")
val appDir: String = AppDirsFactory.getInstance().getUserDataDir(PACKAGE_NAME, VERSION, ORGANISATION)
return "$appDir/$id.json"
}
On Browser
This is straight-forward on a browser, since we are storing on localStorage/sessionStorage, we just need a key name
actual fun pathTo(name: String): String = name
On NodeJS
TODO()
π Migrating stores
You can use the existing fields to derive the new fields without needing to write your own migrations
@Serializable data class CatV1(val name: String, val lives: Int = 9)
@Serializable data class CatV2(val name: String, val lives: Int = 9, val age: Int = 9 - lives)
What about binary incompatible changes?
Binary incompatible changes
If the new models are binary incompatible you will need to specify how to migrate the models from version to version
@Serializable data class CatV1(val name: String, val lives: Int = 9, val cuteness: Int)
@Serializable data class CatV2(val name: String, val lives: Int = 9, val age: Int = 9 - lives, val kawaiiness: Long)
@Serializable data class CatV3(val name: String, val lives: Int = 9, val age: Int = 9 - lives, val isCute: Boolean)
private val storeV3: KStore<CatV3> = storeOf(filePath = filePath, version = 3) { version, jsonElement ->
when (version) {
1 -> jsonElement?.jsonObject?.let {
val name = it["name"]!!.jsonPrimitive.content
val lives = it["lives"]!!.jsonPrimitive.int
val age = it["age"]?.jsonPrimitive?.int ?: (9 - lives)
val isCute = it["cuteness"]!!.jsonPrimitive.int.toLong() > 1
CatV3(name, lives, age, isCute)
}
2 -> jsonElement?.jsonObject?.let {
val name = it["name"]!!.jsonPrimitive.content
val lives = it["lives"]!!.jsonPrimitive.int
val age = it["age"]?.jsonPrimitive?.int ?: (9 - lives)
val isCute = it["kawaiiness"]!!.jsonPrimitive.long > 1
CatV3(name, lives, age, isCute)
}
else -> null
}
}
Licence
Copyright 2023 Isuru Rajapakse
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.