
Multiplatform key-value store and local cache storage offering features like schema-less SQLite storage, event observation via Flow, cache expiration strategies (FIFO, LRU), list structures for paging, property delegation, and support for primitive and serializable values.
Kotlin Multiplatform Key-Value Store Local Cache Storage for Single Source of Truth.
@Serializable classesAdd Kottage as gradle dependency.
build.gradle.kts
// For Kotlin Multiplatform:
plugins {
kotlin("multiplatform")
}
kotlin {
sourceSets {
commonMain {
dependencies {
implementation("io.github.irgaly.kottage:kottage:1.11.0")
// ...
}
}
}
// ...
}build.gradle.kts
// For Kotlin/JVM or Kotlin/Android without Kotlin Multiplatform:
plugins {
id("com.android.application")
kotlin("android")
// kotlin("jvm") // for JVM Application
}
dependencies {
// You can use as JVM library directly
implementation("io.github.irgaly.kottage:kottage:1.11.0")
// ...
}Use Kottage as KVS cache or KVS storage.
First, get a Kottage instance. Even though you can use Kottage instance as a singleton, multiple Kottage instances creation is allowed. Kottage instances and methods are thread safe.
import io.github.irgaly.kottage.platform.contextOf
// directory path string for SQLite file
// For example:
// * Android File Directory: context.getFilesDir().path
// * Android Cache Directory: context.getCacheDir().path
val databaseDirectory: String = ...
val kottageEnvironment: KottageEnvironment = KottageEnvironment(
context = contextOf(context) // for Android, set a KottageContext with Android Context object
//context = KottageContext() // for other platforms, set an empty KottageContext
)
// Initialize with Kottage database information.
val kottage: Kottage = Kottage(
name = "kottage-store-name", // This will be database file name
directoryPath = databaseDirectory,
environment = kottageEnvironment,
scope = scope, // This kottage instance will be automatically close on this CoroutineScope completion
json = Json.Default // kotlinx.serialization's json object
)Then, use it as KVS Cache.
import kotlin.time.Duration.Companion.days
// Open Kottage database as cache mode
val cache: KottageStorage = kottage.cache("timeline_item_cache") {
// There are some options
strategy = KottageFifoStrategy(maxEntryCount = 1000) // default strategy in cache mode
//strategy = KottageFifoStrategy(maxCacheSize = 512 * 1024 * 1024) // FIFO strategy with cache sized based eviction (bytes)
//strategy = KottageFifoStrategy(maxEntryCount = 1000, maxCacheSize = 512 * 1024 * 1024) // FIFO strategy with item count and cache sized based eviction (bytes)
//strategy = KottageLruStrategy(maxEntryCount = 1000) // LRU cache strategy
//strategy = KottageLruStrategy(maxCacheSize = 512 * 1024 * 1024) // LRU strategy with cache sized based eviction (bytes)
//strategy = KottageLruStrategy(maxEntryCount = 1000, maxCacheSize = 512 * 1024 * 1024) // LRU strategy with item count and cache sized based eviction (bytes)
defaultExpireTime = 30.days // cache item expiration time in kotlin.time.Duration
}
// Kottage's data accessing methods (get, put...) are suspending function
// These items will be expired and automatically deleted after 30 days (defaultExpireTime) elapsed
cache.put("item1", "item1 value")
cache.put("item2", 42)
cache.put("item3", true)
val value1: String = cache.get<String>("item1")
val value2: Int = cache.get<Int>("item2")
val value3: Boolean = cache.get<Boolean>("item3")
cache.exists("item4") // => false
cache.getOrNull<String>("item4") // => null
// 30 days later... these items are expired
cache.get<String>("item1") // throws NoSuchElementException
cache.getOrNull<String>("item1") // => null
cache.exists("item1") // => falseUse it as KVS Storage with no expiration.
// Open Kottage database as storage mode
val storage: KottageStorage = kottage.storage("app_configs")
// Kottage's data accessing methods (get, put...) are suspending function
// These items has no expiration
storage.put("item1", "item1 value")
storage.put("item2", 42)
storage.put("item3", true)
val value1: String = storage.get<String>("item1")
val value2: Int = storage.get<Int>("item2")
val value3: Boolean = storage.get<Boolean>("item3")
storage.exists("item4") // => false
storage.getOrNull<String>("item4") // => nullKottageStorage provides property delegate.
val storage: KottageStorage = kottage.storage("app_configs")
val myConfig: String by storage.property { "default value" }
val myConfigNullable: String? by storage.nullableProperty()
myConfig.write("value")
val config: String = myConfig.read()For example, this is strictly typed data access class:
class AppConfiguration(kottage: Kottage) {
private val storage: KottageStorage = kottage.storage("app_configs")
val myConfig: String by storage.property { "default value" }
val myConfigNullable: String? by storage.nullableProperty()
}
val configuration: AppConfiguration = AppConfiguration(kottage)
configuration.myConfig.write("value")
val config: String = configuration.myConfig.read()Kottage has a List feature for make Paging UIs and for Single Source of Truth.
import io.github.irgaly.kottage.kottageListValue
val cache: KottageStorage = kottage.cache("timeline_item_cache")
val list: KottageList = cache.list("timeline_list")
// KottageList is an interface of KottageStorage that supports List operations.
// Add List Items
list.add("item_id_1", TimelineItem("item_id_1", ...))
list.addAll(
listOf(
kottageListValue("item_id_2", TimelineItem("item_id_2", ...)),
kottageListValue("item_id_3", TimelineItem("item_id_3", ...)),
kottageListValue("item_id_4", TimelineItem("item_id_4", ...)),
kottageListValue("item_id_5", TimelineItem("item_id_5", ...))
)
)
// The items are stored in "timeline_item_cache" KottageStorage
cache.exists("item_id_1") // => true
// You can update items directly
cache.put("item_id_1", TimelineItem("item_id_1", otherValue = ...))
// Get as Page
val page0: KottageListPage = list.getPageFrom(positionId = null, pageSize = 2)
val page1: KottageListPage = list.getPageFrom(positionId = (page0.nextPositionId), pageSize = 2)
val page2: KottageListPage = list.getPageFrom(positionId = (page1.nextPositionId), pageSize = 2)
page0.items // => List<KottageListEntry>
page0.items.map { it.value<TimelineItem>() }
// => [TimelineItem("item_id_1", otherValue = ...), TimelineItem("item_id_2", ...)]
page1.items.map { it.value<TimelineItem>() }
// => [TimelineItem("item_id_3", ...), TimelineItem("item_id_4", ...)]
page2.items.map { it.value<TimelineItem>() }
// => [TimelineItem("item_id_5", ...)]
page2.hasNext // => false
// KottageList is a Linked List.
val entry1: KottageListEntry? = list.getFirst()
val entry2: KottageListEntry? = list.get(checkNotNull(entry1.nextPositionId))
val entry3: KottageListEntry? = list.get(checkNotNull(entry2.nextPositionId))
// convenience method to get an entry by index
val entry4: KottageListEntry? = list.getByIndex(3)
entry1?.value<TimelineItem>() // => TimelineItem("item_id_1", otherValue = ...)Kottage can store and restore Serializable classes.
@Serializable
data class MyData(val myValue: Int)
val data: MyData = MyData(42)
val list: List<String> = listOf("item1", "item2") // List<String> is Serializable
val cache: KottageStorage = kottage.cache("my_data_cache")
cache.put("item1", data)
cache.put("item2", list)
val storedData: MyData = cache.get<MyData>("item1")
val storedList: List<String> = cache.get<List<String>>("item2")Store and restore works correctly with same type. It throws ClassCastException if restore with wrong types.
val cache: KottageStorage = kottage.cache("type_items")
cache.put("item1", 0) // Store as Number (= SQLite Number = Long, Int, Short, Byte or Boolean)
cache.put("item2", "strings") // Store as String
cache.get<String>("item1") // throws ClassCastException
cache.get<Int>("item2") // throws ClassCastExceptionSerializable types are stored as String. It throws SerializationException if restore with wrong types.
@Serializable
data class Data(val data: Int)
@Serializable
data class Data2(val data2: Int)
val cache: KottageStorage = kottage.cache("type_items")
cache.put("data", Data(42))
cache.get<String>("data") // => "{\"data\":42}"
cache.get<Data2>("data") // throws SerializationExceptionKottage supports observing events of item updates for implementing Single Source of Truth.
val cache: KottageStorage = kottage.cache("my_item_cache")
val now: Long = ... // Unix Time (UTC) in millis
launch {
cache.eventFlow(now).collect { event ->
// receive events from flow
val eventType: KottageEventType = event.eventType // eventType => KottageEventType.Create
val updatedValue: String = cache.get<String>(event.itemKey) // updatedValue => "value"
}
}
cache.put("key", "value")
// get events after time
val events: List<KottageEvent> = cache.getEvents(now)
val updatedValue: String = cache.get<String>(event.first().itemKey) // updatedValue => "value"An eventFlow (KottageEventFlow) can automatically resume from previous emitted event. For example, on Android platform, collect events while Lifecycle is at least STARTED.
val cache = kottage.cache("my_item_cache")
val now = ... // Unix Time (UTC) in millis
val eventFlow = cache.eventFlow(now)
...
override fun onCreate(...) { // for example: onCreate
...
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
eventFlow.collect { event ->
// eventFlow starts dispatching events from last emitted event on previous subscription.
}
}
}
}User defined encryption are supported. Only KVS value part is encrypted, while other part (KVS key, storage name...) remains plain data.
| part | stored data |
|---|---|
| KVS value | encrypted |
| KVS key | plain |
| Kottage name (SQL file name) | plain |
| Kottage Storage name | plain |
| Kottage List name | plain |
| KottageListMetaData | plain |
| KottageEvent | plain |
val storage: KottageStorage = kottage.storage("encrypted_storage") {
encoder = object : KottageEncoder {
override fun encode(value: ByteArray): ByteArray {
// Your encoding logic (plain ByteArray to encrypted ByteArray) here
return ...
}
override fun decode(encoded: ByteArray): ByteArray {
// Your decoding logic (encrypted ByteArray to plain ByteArray) here
return ...
}
}
}
// storage's values are encrypted
storage.put("long_value", 100L)
storage.put("string_value", "value")
val longValue: Long = storage.get("long_value") // => 100L
val stringValue: String = storage.get("long_value") // => "value"Double, Float, Long, Int, Short, Byte, Boolean
ByteArray
String
@Serializable classesKottage is a Kotlin Multiplatform library. Please feel free to report a issue if it doesn't work correctly on these platforms.
| Platform | Target | Status |
|---|---|---|
| Kotlin/JVM on Linux/macOS/Windows | jvm | ✅ Tested |
| Kotlin/JS on Linux/macOS/Windows | browser, nodejs | ✅ Tested browser on macOS Chrome, Safari |
| Kotlin/Android | android | ✅ Tested |
| Kotlin/Native iOS | iosArm64 iosX64(simulator) iosSimulatorArm64 |
✅ Tested (by iosSimulatorArm64 only) |
| Kotlin/Native watchOS | watchosArm64 watchosDeviceArm64 watchosX64(simulator) watchosSimulatorArm64 |
✅ (Tested as iosSimulatorArm64) |
| Kotlin/Native tvOS | tvosArm64 tvosX64(simulator) tvosSimulatorArm64 |
✅ (Tested as iosSimulatorArm64) |
| Kotlin/Native macOS | macosArm64 macosX64 |
✅ Tested (by macosArm64 only) |
| Kotlin/Native Linux | linuxX64 linuxArm64 |
✅ Tested (by linuxX64 only) |
| Kotlin/Native Windows | mingwX64 | ✅ Tested |
There is also Kottage for SwiftPM that is just for experimental build.
Kottage supports Kottage/JS browser and nodejs. Kottage on browser uses IndexedDB as persistent database instead of SQLite. Kottage on nodejs uses SQLite.
| Kotlin/JS type | Database |
|---|---|
| browser | IndexedDB |
| nodejs | SQLite |
Browsers will clear IndexedDB data when user's disk storage gets low disk space.
You can request browsers your IndexedDB's data not to be cleared by using StorageManager.persist()
.
See Web API documents for
more details.
Kotlin/JS's library contains both of implementations for browser and for nodejs, so additional Webpack config is required for browser to use Kottage.
When you run jsBrowserRun (jsBrowserDevelopmentRun or jsBrowserProductionRun), some Webpack Errors
occurs:
Compiled with problems:X
WARNING in ../../node_modules/better-sqlite3/lib/database.js 50:10-81
Critical dependency: the request of a dependency is an expression
ERROR in ../../node_modules/better-sqlite3/lib/database.js 2:11-24
Module not found: Error: Can't resolve 'fs' in '(projectdir)/build/js/node_modules/better-sqlite3/lib'
ERROR in ../../node_modules/better-sqlite3/lib/database.js 3:13-28
Module not found: Error: Can't resolve 'path' in '(projectdir)/build/js/node_modules/better-sqlite3/lib'
BREAKING CHANGE: webpack < 5 used to include polyfills for node.js core modules by default.
This is no longer the case. Verify if you need this module and configure a polyfill for it.
If you want to include a polyfill, you need to:
- add a fallback 'resolve.fallback: { "path": require.resolve("path-browserify") }'
- install 'path-browserify'
If you don't want to include a polyfill, you can use an empty module like this:
resolve.fallback: { "path": false }
...Compiled with problems:X
WARNING in ../../node_modules/better-sqlite3/lib/database.js 50:10-81
Critical dependency: the request of a dependency is an expression
ERROR in ../../node_modules/better-sqlite3/lib/database.js 2:11-24
Module not found: Error: Can't resolve 'fs' in '(projectdir)/build/js/node_modules/better-sqlite3/lib'
ERROR in ../../node_modules/better-sqlite3/lib/database.js 3:13-28
Module not found: Error: Can't resolve 'path' in '(projectdir)/build/js/node_modules/better-sqlite3/lib'
BREAKING CHANGE: webpack < 5 used to include polyfills for node.js core modules by default.
This is no longer the case. Verify if you need this module and configure a polyfill for it.
If you want to include a polyfill, you need to:
- add a fallback 'resolve.fallback: { "path": require.resolve("path-browserify") }'
- install 'path-browserify'
If you don't want to include a polyfill, you can use an empty module like this:
resolve.fallback: { "path": false }
ERROR in ../../node_modules/better-sqlite3/lib/methods/backup.js 2:11-24
Module not found: Error: Can't resolve 'fs' in '(projectdir)/build/js/node_modules/better-sqlite3/lib/methods'
ERROR in ../../node_modules/better-sqlite3/lib/methods/backup.js 3:13-28
Module not found: Error: Can't resolve 'path' in '(projectdir)/build/js/node_modules/better-sqlite3/lib/methods'
BREAKING CHANGE: webpack < 5 used to include polyfills for node.js core modules by default.
This is no longer the case. Verify if you need this module and configure a polyfill for it.
If you want to include a polyfill, you need to:
- add a fallback 'resolve.fallback: { "path": require.resolve("path-browserify") }'
- install 'path-browserify'
If you don't want to include a polyfill, you can use an empty module like this:
resolve.fallback: { "path": false }
ERROR in ../../node_modules/better-sqlite3/lib/methods/backup.js 4:22-37
Module not found: Error: Can't resolve 'util' in '(projectdir)/build/js/node_modules/better-sqlite3/lib/methods'
BREAKING CHANGE: webpack < 5 used to include polyfills for node.js core modules by default.
This is no longer the case. Verify if you need this module and configure a polyfill for it.
If you want to include a polyfill, you need to:
- add a fallback 'resolve.fallback: { "util": require.resolve("util/") }'
- install 'util'
If you don't want to include a polyfill, you can use an empty module like this:
resolve.fallback: { "util": false }
ERROR in ../../node_modules/bindings/bindings.js 5:9-22
Module not found: Error: Can't resolve 'fs' in '(projectdir)/build/js/node_modules/bindings'
ERROR in ../../node_modules/bindings/bindings.js 6:9-24
Module not found: Error: Can't resolve 'path' in '(projectdir)/build/js/node_modules/bindings'
BREAKING CHANGE: webpack < 5 used to include polyfills for node.js core modules by default.
This is no longer the case. Verify if you need this module and configure a polyfill for it.
If you want to include a polyfill, you need to:
- add a fallback 'resolve.fallback: { "path": require.resolve("path-browserify") }'
- install 'path-browserify'
If you don't want to include a polyfill, you can use an empty module like this:
resolve.fallback: { "path": false }
ERROR in ../../node_modules/file-uri-to-path/index.js 6:10-29
Module not found: Error: Can't resolve 'path' in '(projectdir)/build/js/node_modules/file-uri-to-path'
BREAKING CHANGE: webpack < 5 used to include polyfills for node.js core modules by default.
This is no longer the case. Verify if you need this module and configure a polyfill for it.
If you want to include a polyfill, you need to:
- add a fallback 'resolve.fallback: { "path": require.resolve("path-browserify") }'
- install 'path-browserify'
If you don't want to include a polyfill, you can use an empty module like this:
resolve.fallback: { "path": false }
Compiled with problems:X
ERROR in ./kotlin/kottage-project-core.js 72:11-24
Module not found: Error: Can't resolve 'fs' in '(projectdir)/build/js/packages/kottage-project-js-browser/kotlin'To suppress this errors, add Webpack config file to project directory. This config will ignore better-sqlite3 module that is not needed in browser application, and exclude packed files.
{youar application module path}/webpack.config.d/kottage.webpack.config.js:
config.resolve.fallback = {
...config.resolve.fallback,
fs: false,
os: false
}
config.externals = {
...config.externals,
"better-sqlite3": "better-sqlite3"
}Sample application project is available in sample/js-browser.
Kottage uses better-sqlite3 on nodejs.
better-sqlite3 requires sqlite3 FFI file better_sqlite3.node on runtime environment.
If there is no FFI file, Could not locate the bindings file error occurred:
CoroutinesInternalError: Fatal exception in coroutines machinery for AwaitContinuation(DispatchedContinuation[NodeDispatcher@1, [object Object]]){Completed}@2. Please read KDoc to 'handleFatalException' method and report this incident to maintainers
at AwaitContinuation.DispatchedTask.handleFatalException_56zdfo_k$ ((projectdir)/DispatchedTask.kt:144:22)
at AwaitContinuation.DispatchedTask.run_mw4iiu_k$ ((projectdir)/DispatchedTask.kt:115:13)
at ScheduledMessageQueue.MessageQueue.process_mza50i_k$ ((projectdir)/JSDispatcher.kt:153:25)
at (projectdir)/JSDispatcher.kt:19:48
at processTicksAndRejections (node:internal/process/task_queues:77:11) {
cause: Error: Could not locate the bindings file. Tried:
→ (projectdir)/build/js/node_modules/better-sqlite3/build/better_sqlite3.node
→ (projectdir)/build/js/node_modules/better-sqlite3/build/Debug/better_sqlite3.node
→ (projectdir)/build/js/node_modules/better-sqlite3/build/Release/better_sqlite3.node
→ (projectdir)/build/js/node_modules/better-sqlite3/out/Debug/better_sqlite3.node
...CoroutinesInternalError: Fatal exception in coroutines machinery for AwaitContinuation(DispatchedContinuation[NodeDispatcher@1, [object Object]]){Completed}@2. Please read KDoc to 'handleFatalException' method and report this incident to maintainers
at AwaitContinuation.DispatchedTask.handleFatalException_56zdfo_k$ ((projectdir)/DispatchedTask.kt:144:22)
at AwaitContinuation.DispatchedTask.run_mw4iiu_k$ ((projectdir)/DispatchedTask.kt:115:13)
at ScheduledMessageQueue.MessageQueue.process_mza50i_k$ ((projectdir)/JSDispatcher.kt:153:25)
at (projectdir)/JSDispatcher.kt:19:48
at processTicksAndRejections (node:internal/process/task_queues:77:11) {
cause: Error: Could not locate the bindings file. Tried:
→ (projectdir)/build/js/node_modules/better-sqlite3/build/better_sqlite3.node
→ (projectdir)/build/js/node_modules/better-sqlite3/build/Debug/better_sqlite3.node
→ (projectdir)/build/js/node_modules/better-sqlite3/build/Release/better_sqlite3.node
→ (projectdir)/build/js/node_modules/better-sqlite3/out/Debug/better_sqlite3.node
→ (projectdir)/build/js/node_modules/better-sqlite3/Debug/better_sqlite3.node
→ (projectdir)/build/js/node_modules/better-sqlite3/out/Release/better_sqlite3.node
→ (projectdir)/build/js/node_modules/better-sqlite3/Release/better_sqlite3.node
→ (projectdir)/build/js/node_modules/better-sqlite3/build/default/better_sqlite3.node
→ (projectdir)/build/js/node_modules/better-sqlite3/compiled/18.11.0/darwin/arm64/better_sqlite3.node
→ (projectdir)/build/js/node_modules/better-sqlite3/addon-build/release/install-root/better_sqlite3.node
→ (projectdir)/build/js/node_modules/better-sqlite3/addon-build/debug/install-root/better_sqlite3.node
→ (projectdir)/build/js/node_modules/better-sqlite3/addon-build/default/install-root/better_sqlite3.node
→ (projectdir)/build/js/node_modules/better-sqlite3/lib/binding/node-v108-darwin-arm64/better_sqlite3.node
at bindings ((projectdir)/build/js/node_modules/bindings/bindings.js:126:9)
at new Database ((projectdir)/build/js/node_modules/better-sqlite3/lib/database.js:48:64)
at Database ((projectdir)/build/js/node_modules/better-sqlite3/lib/database.js:11:10)
at $createDriverCOROUTINE$0.doResume_5yljmg_k$ ((projectdir)/DriverFactory.kt:35:13)
at DriverFactory.createDriver_qrqvgc_k$ ((projectdir)/DriverFactory.kt:15:20)
at createDriver ((projectdir)/DriverFactory.kt:32:12)
at createDriver$default ((projectdir)/DriverFactory.kt:27:9)
at SqliteDatabaseConnectionFactory$createDatabaseConnection$slambda.doResume_5yljmg_k$ ((projectdir)/SqliteDatabaseConnectionFactory.kt:28:15)
at SqliteDatabaseConnectionFactory$createDatabaseConnection$slambda.invoke_uw69q_k$ ((projectdir)/SqliteDatabaseConnectionFactory.kt:21:41)
at SqliteDatabaseConnection.l [as sqlDriverProvider_1] ((projectdir)/build/js/packages/kottage-project-js-nodejs/kotlin/kottage-project-kottage.js:28289:16) {
tries: [
'(projectdir)/build/js/node_modules/better-sqlite3/build/better_sqlite3.node',
'(projectdir)/build/js/node_modules/better-sqlite3/build/Debug/better_sqlite3.node',
'(projectdir)/build/js/node_modules/better-sqlite3/build/Release/better_sqlite3.node',
'(projectdir)/build/js/node_modules/better-sqlite3/out/Debug/better_sqlite3.node',
'(projectdir)/build/js/node_modules/better-sqlite3/Debug/better_sqlite3.node',
'(projectdir)/build/js/node_modules/better-sqlite3/out/Release/better_sqlite3.node',
'(projectdir)/build/js/node_modules/better-sqlite3/Release/better_sqlite3.node',
'(projectdir)/build/js/node_modules/better-sqlite3/build/default/better_sqlite3.node',
'(projectdir)/build/js/node_modules/better-sqlite3/compiled/18.11.0/darwin/arm64/better_sqlite3.node',
'(projectdir)/build/js/node_modules/better-sqlite3/addon-build/release/install-root/better_sqlite3.node',
'(projectdir)/build/js/node_modules/better-sqlite3/addon-build/debug/install-root/better_sqlite3.node',
'(projectdir)/build/js/node_modules/better-sqlite3/addon-build/default/install-root/better_sqlite3.node',
'(projectdir)/build/js/node_modules/better-sqlite3/lib/binding/node-v108-darwin-arm64/better_sqlite3.node'
]
}
}To prevent this error, you should build FFI file with a custom Gradle Task.
python3.installBetterSqlite3 task. This task will
make {rootProject}/build/js/node_modules/better-sqlite3/build/Release/better_sqlite3.node.{rootProject}/build.gradle.kts (sample build.gradle.kts is here)
import org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsEnvSpec
import org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootPlugin
import org.jetbrains.kotlin.gradle.targets.js.npm.tasks.KotlinNpmInstallTask
...
plugins.withType<NodeJsRootPlugin> {
extensions.configure<NodeJsEnvSpec> {
// Choose any version you want to use from https://nodejs.org/en/download/releases/
version = "22.0.0"
val installBetterSqlite3 by tasks.registering(Exec::class) {
val envSpec = this@configure
val node = envSpec.executable.get().replace(File.separator, "/")
val nodeDir = if (OperatingSystem.current().isWindows) {
File(node).parent.replace(File.separator, "/")
} else {
File(node).parentFile.parent
}
val nodeBinDir = File(node).parent.replace(File.separator, "/")
val npmCli = if (OperatingSystem.current().isWindows) {
"$nodeDir/node_modules/npm/bin/npm-cli.js"
} else {
"$nodeDir/lib/node_modules/npm/bin/npm-cli.js"
}
val npm = "\"$node\" \"$npmCli\""
val betterSqlite3 = layout.buildDirectory.dir("js/node_modules/better-sqlite3")
dependsOn(tasks.withType<KotlinNpmInstallTask>())
inputs.files(betterSqlite3.get().file("package.json"))
inputs.property("node-version", envSpec.version)
outputs.files(betterSqlite3.get().file("build/Release/better_sqlite3.node"))
outputs.cacheIf { true }
workingDir = betterSqlite3.get().asFile
commandLine = if (OperatingSystem.current().isWindows) {
listOf(
"sh",
"-c",
// use pwd command to convert C:/... -> /c/...
"PATH=\$(cd ${nodeBinDir};pwd):\$PATH $npm run install --verbose"
)
} else {
listOf(
"sh",
"-c",
"PATH=\"$nodeBinDir:\$PATH\" $npm run install --verbose"
)
}
}
}
}
...{nodejs project}/build.gradle.kts
plugins {
kotlin("multiplatform")
}
kotlin {
js(IR) {
...
}
...
}
...
tasks.withType<NodeJsExec>().configureEach {
dependsOn(rootProject.tasks.named("installBetterSqlite3"))
}
...jsNodeRun (jsNodeDevelopmentRun or jsNodeProductionRun) task. There are no FFI
errors.TBA: I'll write details of library here.
Kotlin Multiplatform Key-Value Store Local Cache Storage for Single Source of Truth.
@Serializable classesAdd Kottage as gradle dependency.
build.gradle.kts
// For Kotlin Multiplatform:
plugins {
kotlin("multiplatform")
}
kotlin {
sourceSets {
commonMain {
dependencies {
implementation("io.github.irgaly.kottage:kottage:1.11.0")
// ...
}
}
}
// ...
}build.gradle.kts
// For Kotlin/JVM or Kotlin/Android without Kotlin Multiplatform:
plugins {
id("com.android.application")
kotlin("android")
// kotlin("jvm") // for JVM Application
}
dependencies {
// You can use as JVM library directly
implementation("io.github.irgaly.kottage:kottage:1.11.0")
// ...
}Use Kottage as KVS cache or KVS storage.
First, get a Kottage instance. Even though you can use Kottage instance as a singleton, multiple Kottage instances creation is allowed. Kottage instances and methods are thread safe.
import io.github.irgaly.kottage.platform.contextOf
// directory path string for SQLite file
// For example:
// * Android File Directory: context.getFilesDir().path
// * Android Cache Directory: context.getCacheDir().path
val databaseDirectory: String = ...
val kottageEnvironment: KottageEnvironment = KottageEnvironment(
context = contextOf(context) // for Android, set a KottageContext with Android Context object
//context = KottageContext() // for other platforms, set an empty KottageContext
)
// Initialize with Kottage database information.
val kottage: Kottage = Kottage(
name = "kottage-store-name", // This will be database file name
directoryPath = databaseDirectory,
environment = kottageEnvironment,
scope = scope, // This kottage instance will be automatically close on this CoroutineScope completion
json = Json.Default // kotlinx.serialization's json object
)Then, use it as KVS Cache.
import kotlin.time.Duration.Companion.days
// Open Kottage database as cache mode
val cache: KottageStorage = kottage.cache("timeline_item_cache") {
// There are some options
strategy = KottageFifoStrategy(maxEntryCount = 1000) // default strategy in cache mode
//strategy = KottageFifoStrategy(maxCacheSize = 512 * 1024 * 1024) // FIFO strategy with cache sized based eviction (bytes)
//strategy = KottageFifoStrategy(maxEntryCount = 1000, maxCacheSize = 512 * 1024 * 1024) // FIFO strategy with item count and cache sized based eviction (bytes)
//strategy = KottageLruStrategy(maxEntryCount = 1000) // LRU cache strategy
//strategy = KottageLruStrategy(maxCacheSize = 512 * 1024 * 1024) // LRU strategy with cache sized based eviction (bytes)
//strategy = KottageLruStrategy(maxEntryCount = 1000, maxCacheSize = 512 * 1024 * 1024) // LRU strategy with item count and cache sized based eviction (bytes)
defaultExpireTime = 30.days // cache item expiration time in kotlin.time.Duration
}
// Kottage's data accessing methods (get, put...) are suspending function
// These items will be expired and automatically deleted after 30 days (defaultExpireTime) elapsed
cache.put("item1", "item1 value")
cache.put("item2", 42)
cache.put("item3", true)
val value1: String = cache.get<String>("item1")
val value2: Int = cache.get<Int>("item2")
val value3: Boolean = cache.get<Boolean>("item3")
cache.exists("item4") // => false
cache.getOrNull<String>("item4") // => null
// 30 days later... these items are expired
cache.get<String>("item1") // throws NoSuchElementException
cache.getOrNull<String>("item1") // => null
cache.exists("item1") // => falseUse it as KVS Storage with no expiration.
// Open Kottage database as storage mode
val storage: KottageStorage = kottage.storage("app_configs")
// Kottage's data accessing methods (get, put...) are suspending function
// These items has no expiration
storage.put("item1", "item1 value")
storage.put("item2", 42)
storage.put("item3", true)
val value1: String = storage.get<String>("item1")
val value2: Int = storage.get<Int>("item2")
val value3: Boolean = storage.get<Boolean>("item3")
storage.exists("item4") // => false
storage.getOrNull<String>("item4") // => nullKottageStorage provides property delegate.
val storage: KottageStorage = kottage.storage("app_configs")
val myConfig: String by storage.property { "default value" }
val myConfigNullable: String? by storage.nullableProperty()
myConfig.write("value")
val config: String = myConfig.read()For example, this is strictly typed data access class:
class AppConfiguration(kottage: Kottage) {
private val storage: KottageStorage = kottage.storage("app_configs")
val myConfig: String by storage.property { "default value" }
val myConfigNullable: String? by storage.nullableProperty()
}
val configuration: AppConfiguration = AppConfiguration(kottage)
configuration.myConfig.write("value")
val config: String = configuration.myConfig.read()Kottage has a List feature for make Paging UIs and for Single Source of Truth.
import io.github.irgaly.kottage.kottageListValue
val cache: KottageStorage = kottage.cache("timeline_item_cache")
val list: KottageList = cache.list("timeline_list")
// KottageList is an interface of KottageStorage that supports List operations.
// Add List Items
list.add("item_id_1", TimelineItem("item_id_1", ...))
list.addAll(
listOf(
kottageListValue("item_id_2", TimelineItem("item_id_2", ...)),
kottageListValue("item_id_3", TimelineItem("item_id_3", ...)),
kottageListValue("item_id_4", TimelineItem("item_id_4", ...)),
kottageListValue("item_id_5", TimelineItem("item_id_5", ...))
)
)
// The items are stored in "timeline_item_cache" KottageStorage
cache.exists("item_id_1") // => true
// You can update items directly
cache.put("item_id_1", TimelineItem("item_id_1", otherValue = ...))
// Get as Page
val page0: KottageListPage = list.getPageFrom(positionId = null, pageSize = 2)
val page1: KottageListPage = list.getPageFrom(positionId = (page0.nextPositionId), pageSize = 2)
val page2: KottageListPage = list.getPageFrom(positionId = (page1.nextPositionId), pageSize = 2)
page0.items // => List<KottageListEntry>
page0.items.map { it.value<TimelineItem>() }
// => [TimelineItem("item_id_1", otherValue = ...), TimelineItem("item_id_2", ...)]
page1.items.map { it.value<TimelineItem>() }
// => [TimelineItem("item_id_3", ...), TimelineItem("item_id_4", ...)]
page2.items.map { it.value<TimelineItem>() }
// => [TimelineItem("item_id_5", ...)]
page2.hasNext // => false
// KottageList is a Linked List.
val entry1: KottageListEntry? = list.getFirst()
val entry2: KottageListEntry? = list.get(checkNotNull(entry1.nextPositionId))
val entry3: KottageListEntry? = list.get(checkNotNull(entry2.nextPositionId))
// convenience method to get an entry by index
val entry4: KottageListEntry? = list.getByIndex(3)
entry1?.value<TimelineItem>() // => TimelineItem("item_id_1", otherValue = ...)Kottage can store and restore Serializable classes.
@Serializable
data class MyData(val myValue: Int)
val data: MyData = MyData(42)
val list: List<String> = listOf("item1", "item2") // List<String> is Serializable
val cache: KottageStorage = kottage.cache("my_data_cache")
cache.put("item1", data)
cache.put("item2", list)
val storedData: MyData = cache.get<MyData>("item1")
val storedList: List<String> = cache.get<List<String>>("item2")Store and restore works correctly with same type. It throws ClassCastException if restore with wrong types.
val cache: KottageStorage = kottage.cache("type_items")
cache.put("item1", 0) // Store as Number (= SQLite Number = Long, Int, Short, Byte or Boolean)
cache.put("item2", "strings") // Store as String
cache.get<String>("item1") // throws ClassCastException
cache.get<Int>("item2") // throws ClassCastExceptionSerializable types are stored as String. It throws SerializationException if restore with wrong types.
@Serializable
data class Data(val data: Int)
@Serializable
data class Data2(val data2: Int)
val cache: KottageStorage = kottage.cache("type_items")
cache.put("data", Data(42))
cache.get<String>("data") // => "{\"data\":42}"
cache.get<Data2>("data") // throws SerializationExceptionKottage supports observing events of item updates for implementing Single Source of Truth.
val cache: KottageStorage = kottage.cache("my_item_cache")
val now: Long = ... // Unix Time (UTC) in millis
launch {
cache.eventFlow(now).collect { event ->
// receive events from flow
val eventType: KottageEventType = event.eventType // eventType => KottageEventType.Create
val updatedValue: String = cache.get<String>(event.itemKey) // updatedValue => "value"
}
}
cache.put("key", "value")
// get events after time
val events: List<KottageEvent> = cache.getEvents(now)
val updatedValue: String = cache.get<String>(event.first().itemKey) // updatedValue => "value"An eventFlow (KottageEventFlow) can automatically resume from previous emitted event. For example, on Android platform, collect events while Lifecycle is at least STARTED.
val cache = kottage.cache("my_item_cache")
val now = ... // Unix Time (UTC) in millis
val eventFlow = cache.eventFlow(now)
...
override fun onCreate(...) { // for example: onCreate
...
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
eventFlow.collect { event ->
// eventFlow starts dispatching events from last emitted event on previous subscription.
}
}
}
}User defined encryption are supported. Only KVS value part is encrypted, while other part (KVS key, storage name...) remains plain data.
| part | stored data |
|---|---|
| KVS value | encrypted |
| KVS key | plain |
| Kottage name (SQL file name) | plain |
| Kottage Storage name | plain |
| Kottage List name | plain |
| KottageListMetaData | plain |
| KottageEvent | plain |
val storage: KottageStorage = kottage.storage("encrypted_storage") {
encoder = object : KottageEncoder {
override fun encode(value: ByteArray): ByteArray {
// Your encoding logic (plain ByteArray to encrypted ByteArray) here
return ...
}
override fun decode(encoded: ByteArray): ByteArray {
// Your decoding logic (encrypted ByteArray to plain ByteArray) here
return ...
}
}
}
// storage's values are encrypted
storage.put("long_value", 100L)
storage.put("string_value", "value")
val longValue: Long = storage.get("long_value") // => 100L
val stringValue: String = storage.get("long_value") // => "value"Double, Float, Long, Int, Short, Byte, Boolean
ByteArray
String
@Serializable classesKottage is a Kotlin Multiplatform library. Please feel free to report a issue if it doesn't work correctly on these platforms.
| Platform | Target | Status |
|---|---|---|
| Kotlin/JVM on Linux/macOS/Windows | jvm | ✅ Tested |
| Kotlin/JS on Linux/macOS/Windows | browser, nodejs | ✅ Tested browser on macOS Chrome, Safari |
| Kotlin/Android | android | ✅ Tested |
| Kotlin/Native iOS | iosArm64 iosX64(simulator) iosSimulatorArm64 |
✅ Tested (by iosSimulatorArm64 only) |
| Kotlin/Native watchOS | watchosArm64 watchosDeviceArm64 watchosX64(simulator) watchosSimulatorArm64 |
✅ (Tested as iosSimulatorArm64) |
| Kotlin/Native tvOS | tvosArm64 tvosX64(simulator) tvosSimulatorArm64 |
✅ (Tested as iosSimulatorArm64) |
| Kotlin/Native macOS | macosArm64 macosX64 |
✅ Tested (by macosArm64 only) |
| Kotlin/Native Linux | linuxX64 linuxArm64 |
✅ Tested (by linuxX64 only) |
| Kotlin/Native Windows | mingwX64 | ✅ Tested |
There is also Kottage for SwiftPM that is just for experimental build.
Kottage supports Kottage/JS browser and nodejs. Kottage on browser uses IndexedDB as persistent database instead of SQLite. Kottage on nodejs uses SQLite.
| Kotlin/JS type | Database |
|---|---|
| browser | IndexedDB |
| nodejs | SQLite |
Browsers will clear IndexedDB data when user's disk storage gets low disk space.
You can request browsers your IndexedDB's data not to be cleared by using StorageManager.persist()
.
See Web API documents for
more details.
Kotlin/JS's library contains both of implementations for browser and for nodejs, so additional Webpack config is required for browser to use Kottage.
When you run jsBrowserRun (jsBrowserDevelopmentRun or jsBrowserProductionRun), some Webpack Errors
occurs:
Compiled with problems:X
WARNING in ../../node_modules/better-sqlite3/lib/database.js 50:10-81
Critical dependency: the request of a dependency is an expression
ERROR in ../../node_modules/better-sqlite3/lib/database.js 2:11-24
Module not found: Error: Can't resolve 'fs' in '(projectdir)/build/js/node_modules/better-sqlite3/lib'
ERROR in ../../node_modules/better-sqlite3/lib/database.js 3:13-28
Module not found: Error: Can't resolve 'path' in '(projectdir)/build/js/node_modules/better-sqlite3/lib'
BREAKING CHANGE: webpack < 5 used to include polyfills for node.js core modules by default.
This is no longer the case. Verify if you need this module and configure a polyfill for it.
If you want to include a polyfill, you need to:
- add a fallback 'resolve.fallback: { "path": require.resolve("path-browserify") }'
- install 'path-browserify'
If you don't want to include a polyfill, you can use an empty module like this:
resolve.fallback: { "path": false }
...Compiled with problems:X
WARNING in ../../node_modules/better-sqlite3/lib/database.js 50:10-81
Critical dependency: the request of a dependency is an expression
ERROR in ../../node_modules/better-sqlite3/lib/database.js 2:11-24
Module not found: Error: Can't resolve 'fs' in '(projectdir)/build/js/node_modules/better-sqlite3/lib'
ERROR in ../../node_modules/better-sqlite3/lib/database.js 3:13-28
Module not found: Error: Can't resolve 'path' in '(projectdir)/build/js/node_modules/better-sqlite3/lib'
BREAKING CHANGE: webpack < 5 used to include polyfills for node.js core modules by default.
This is no longer the case. Verify if you need this module and configure a polyfill for it.
If you want to include a polyfill, you need to:
- add a fallback 'resolve.fallback: { "path": require.resolve("path-browserify") }'
- install 'path-browserify'
If you don't want to include a polyfill, you can use an empty module like this:
resolve.fallback: { "path": false }
ERROR in ../../node_modules/better-sqlite3/lib/methods/backup.js 2:11-24
Module not found: Error: Can't resolve 'fs' in '(projectdir)/build/js/node_modules/better-sqlite3/lib/methods'
ERROR in ../../node_modules/better-sqlite3/lib/methods/backup.js 3:13-28
Module not found: Error: Can't resolve 'path' in '(projectdir)/build/js/node_modules/better-sqlite3/lib/methods'
BREAKING CHANGE: webpack < 5 used to include polyfills for node.js core modules by default.
This is no longer the case. Verify if you need this module and configure a polyfill for it.
If you want to include a polyfill, you need to:
- add a fallback 'resolve.fallback: { "path": require.resolve("path-browserify") }'
- install 'path-browserify'
If you don't want to include a polyfill, you can use an empty module like this:
resolve.fallback: { "path": false }
ERROR in ../../node_modules/better-sqlite3/lib/methods/backup.js 4:22-37
Module not found: Error: Can't resolve 'util' in '(projectdir)/build/js/node_modules/better-sqlite3/lib/methods'
BREAKING CHANGE: webpack < 5 used to include polyfills for node.js core modules by default.
This is no longer the case. Verify if you need this module and configure a polyfill for it.
If you want to include a polyfill, you need to:
- add a fallback 'resolve.fallback: { "util": require.resolve("util/") }'
- install 'util'
If you don't want to include a polyfill, you can use an empty module like this:
resolve.fallback: { "util": false }
ERROR in ../../node_modules/bindings/bindings.js 5:9-22
Module not found: Error: Can't resolve 'fs' in '(projectdir)/build/js/node_modules/bindings'
ERROR in ../../node_modules/bindings/bindings.js 6:9-24
Module not found: Error: Can't resolve 'path' in '(projectdir)/build/js/node_modules/bindings'
BREAKING CHANGE: webpack < 5 used to include polyfills for node.js core modules by default.
This is no longer the case. Verify if you need this module and configure a polyfill for it.
If you want to include a polyfill, you need to:
- add a fallback 'resolve.fallback: { "path": require.resolve("path-browserify") }'
- install 'path-browserify'
If you don't want to include a polyfill, you can use an empty module like this:
resolve.fallback: { "path": false }
ERROR in ../../node_modules/file-uri-to-path/index.js 6:10-29
Module not found: Error: Can't resolve 'path' in '(projectdir)/build/js/node_modules/file-uri-to-path'
BREAKING CHANGE: webpack < 5 used to include polyfills for node.js core modules by default.
This is no longer the case. Verify if you need this module and configure a polyfill for it.
If you want to include a polyfill, you need to:
- add a fallback 'resolve.fallback: { "path": require.resolve("path-browserify") }'
- install 'path-browserify'
If you don't want to include a polyfill, you can use an empty module like this:
resolve.fallback: { "path": false }
Compiled with problems:X
ERROR in ./kotlin/kottage-project-core.js 72:11-24
Module not found: Error: Can't resolve 'fs' in '(projectdir)/build/js/packages/kottage-project-js-browser/kotlin'To suppress this errors, add Webpack config file to project directory. This config will ignore better-sqlite3 module that is not needed in browser application, and exclude packed files.
{youar application module path}/webpack.config.d/kottage.webpack.config.js:
config.resolve.fallback = {
...config.resolve.fallback,
fs: false,
os: false
}
config.externals = {
...config.externals,
"better-sqlite3": "better-sqlite3"
}Sample application project is available in sample/js-browser.
Kottage uses better-sqlite3 on nodejs.
better-sqlite3 requires sqlite3 FFI file better_sqlite3.node on runtime environment.
If there is no FFI file, Could not locate the bindings file error occurred:
CoroutinesInternalError: Fatal exception in coroutines machinery for AwaitContinuation(DispatchedContinuation[NodeDispatcher@1, [object Object]]){Completed}@2. Please read KDoc to 'handleFatalException' method and report this incident to maintainers
at AwaitContinuation.DispatchedTask.handleFatalException_56zdfo_k$ ((projectdir)/DispatchedTask.kt:144:22)
at AwaitContinuation.DispatchedTask.run_mw4iiu_k$ ((projectdir)/DispatchedTask.kt:115:13)
at ScheduledMessageQueue.MessageQueue.process_mza50i_k$ ((projectdir)/JSDispatcher.kt:153:25)
at (projectdir)/JSDispatcher.kt:19:48
at processTicksAndRejections (node:internal/process/task_queues:77:11) {
cause: Error: Could not locate the bindings file. Tried:
→ (projectdir)/build/js/node_modules/better-sqlite3/build/better_sqlite3.node
→ (projectdir)/build/js/node_modules/better-sqlite3/build/Debug/better_sqlite3.node
→ (projectdir)/build/js/node_modules/better-sqlite3/build/Release/better_sqlite3.node
→ (projectdir)/build/js/node_modules/better-sqlite3/out/Debug/better_sqlite3.node
...CoroutinesInternalError: Fatal exception in coroutines machinery for AwaitContinuation(DispatchedContinuation[NodeDispatcher@1, [object Object]]){Completed}@2. Please read KDoc to 'handleFatalException' method and report this incident to maintainers
at AwaitContinuation.DispatchedTask.handleFatalException_56zdfo_k$ ((projectdir)/DispatchedTask.kt:144:22)
at AwaitContinuation.DispatchedTask.run_mw4iiu_k$ ((projectdir)/DispatchedTask.kt:115:13)
at ScheduledMessageQueue.MessageQueue.process_mza50i_k$ ((projectdir)/JSDispatcher.kt:153:25)
at (projectdir)/JSDispatcher.kt:19:48
at processTicksAndRejections (node:internal/process/task_queues:77:11) {
cause: Error: Could not locate the bindings file. Tried:
→ (projectdir)/build/js/node_modules/better-sqlite3/build/better_sqlite3.node
→ (projectdir)/build/js/node_modules/better-sqlite3/build/Debug/better_sqlite3.node
→ (projectdir)/build/js/node_modules/better-sqlite3/build/Release/better_sqlite3.node
→ (projectdir)/build/js/node_modules/better-sqlite3/out/Debug/better_sqlite3.node
→ (projectdir)/build/js/node_modules/better-sqlite3/Debug/better_sqlite3.node
→ (projectdir)/build/js/node_modules/better-sqlite3/out/Release/better_sqlite3.node
→ (projectdir)/build/js/node_modules/better-sqlite3/Release/better_sqlite3.node
→ (projectdir)/build/js/node_modules/better-sqlite3/build/default/better_sqlite3.node
→ (projectdir)/build/js/node_modules/better-sqlite3/compiled/18.11.0/darwin/arm64/better_sqlite3.node
→ (projectdir)/build/js/node_modules/better-sqlite3/addon-build/release/install-root/better_sqlite3.node
→ (projectdir)/build/js/node_modules/better-sqlite3/addon-build/debug/install-root/better_sqlite3.node
→ (projectdir)/build/js/node_modules/better-sqlite3/addon-build/default/install-root/better_sqlite3.node
→ (projectdir)/build/js/node_modules/better-sqlite3/lib/binding/node-v108-darwin-arm64/better_sqlite3.node
at bindings ((projectdir)/build/js/node_modules/bindings/bindings.js:126:9)
at new Database ((projectdir)/build/js/node_modules/better-sqlite3/lib/database.js:48:64)
at Database ((projectdir)/build/js/node_modules/better-sqlite3/lib/database.js:11:10)
at $createDriverCOROUTINE$0.doResume_5yljmg_k$ ((projectdir)/DriverFactory.kt:35:13)
at DriverFactory.createDriver_qrqvgc_k$ ((projectdir)/DriverFactory.kt:15:20)
at createDriver ((projectdir)/DriverFactory.kt:32:12)
at createDriver$default ((projectdir)/DriverFactory.kt:27:9)
at SqliteDatabaseConnectionFactory$createDatabaseConnection$slambda.doResume_5yljmg_k$ ((projectdir)/SqliteDatabaseConnectionFactory.kt:28:15)
at SqliteDatabaseConnectionFactory$createDatabaseConnection$slambda.invoke_uw69q_k$ ((projectdir)/SqliteDatabaseConnectionFactory.kt:21:41)
at SqliteDatabaseConnection.l [as sqlDriverProvider_1] ((projectdir)/build/js/packages/kottage-project-js-nodejs/kotlin/kottage-project-kottage.js:28289:16) {
tries: [
'(projectdir)/build/js/node_modules/better-sqlite3/build/better_sqlite3.node',
'(projectdir)/build/js/node_modules/better-sqlite3/build/Debug/better_sqlite3.node',
'(projectdir)/build/js/node_modules/better-sqlite3/build/Release/better_sqlite3.node',
'(projectdir)/build/js/node_modules/better-sqlite3/out/Debug/better_sqlite3.node',
'(projectdir)/build/js/node_modules/better-sqlite3/Debug/better_sqlite3.node',
'(projectdir)/build/js/node_modules/better-sqlite3/out/Release/better_sqlite3.node',
'(projectdir)/build/js/node_modules/better-sqlite3/Release/better_sqlite3.node',
'(projectdir)/build/js/node_modules/better-sqlite3/build/default/better_sqlite3.node',
'(projectdir)/build/js/node_modules/better-sqlite3/compiled/18.11.0/darwin/arm64/better_sqlite3.node',
'(projectdir)/build/js/node_modules/better-sqlite3/addon-build/release/install-root/better_sqlite3.node',
'(projectdir)/build/js/node_modules/better-sqlite3/addon-build/debug/install-root/better_sqlite3.node',
'(projectdir)/build/js/node_modules/better-sqlite3/addon-build/default/install-root/better_sqlite3.node',
'(projectdir)/build/js/node_modules/better-sqlite3/lib/binding/node-v108-darwin-arm64/better_sqlite3.node'
]
}
}To prevent this error, you should build FFI file with a custom Gradle Task.
python3.installBetterSqlite3 task. This task will
make {rootProject}/build/js/node_modules/better-sqlite3/build/Release/better_sqlite3.node.{rootProject}/build.gradle.kts (sample build.gradle.kts is here)
import org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsEnvSpec
import org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootPlugin
import org.jetbrains.kotlin.gradle.targets.js.npm.tasks.KotlinNpmInstallTask
...
plugins.withType<NodeJsRootPlugin> {
extensions.configure<NodeJsEnvSpec> {
// Choose any version you want to use from https://nodejs.org/en/download/releases/
version = "22.0.0"
val installBetterSqlite3 by tasks.registering(Exec::class) {
val envSpec = this@configure
val node = envSpec.executable.get().replace(File.separator, "/")
val nodeDir = if (OperatingSystem.current().isWindows) {
File(node).parent.replace(File.separator, "/")
} else {
File(node).parentFile.parent
}
val nodeBinDir = File(node).parent.replace(File.separator, "/")
val npmCli = if (OperatingSystem.current().isWindows) {
"$nodeDir/node_modules/npm/bin/npm-cli.js"
} else {
"$nodeDir/lib/node_modules/npm/bin/npm-cli.js"
}
val npm = "\"$node\" \"$npmCli\""
val betterSqlite3 = layout.buildDirectory.dir("js/node_modules/better-sqlite3")
dependsOn(tasks.withType<KotlinNpmInstallTask>())
inputs.files(betterSqlite3.get().file("package.json"))
inputs.property("node-version", envSpec.version)
outputs.files(betterSqlite3.get().file("build/Release/better_sqlite3.node"))
outputs.cacheIf { true }
workingDir = betterSqlite3.get().asFile
commandLine = if (OperatingSystem.current().isWindows) {
listOf(
"sh",
"-c",
// use pwd command to convert C:/... -> /c/...
"PATH=\$(cd ${nodeBinDir};pwd):\$PATH $npm run install --verbose"
)
} else {
listOf(
"sh",
"-c",
"PATH=\"$nodeBinDir:\$PATH\" $npm run install --verbose"
)
}
}
}
}
...{nodejs project}/build.gradle.kts
plugins {
kotlin("multiplatform")
}
kotlin {
js(IR) {
...
}
...
}
...
tasks.withType<NodeJsExec>().configureEach {
dependsOn(rootProject.tasks.named("installBetterSqlite3"))
}
...jsNodeRun (jsNodeDevelopmentRun or jsNodeProductionRun) task. There are no FFI
errors.TBA: I'll write details of library here.