Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -178,14 +178,14 @@ jobs:
env:
JAVA_HOME: ${{ env.JAVA_HOME_FOR_BUILD }}
with:
arguments: lib:assemble websocket:assemble -Penv=dev --info
arguments: lib:assemble websocket:assemble automerge-kotlin:assemble automerge-kotlin:dokkaGeneratePublicationHtml -Penv=dev --info

- name: Test with Java 21 runtime
uses: gradle/gradle-build-action@749f47bda3e44aa060e82d7b3ef7e40d953bd629
env:
JAVA_HOME: ${{ env.JAVA_HOME_FOR_BUILD }}
with:
arguments: lib:test websocket:test -Penv=dev --info
arguments: lib:test websocket:test automerge-kotlin:test -Penv=dev --info

- name: Test with Java 8 runtime (backward compatibility)
uses: gradle/gradle-build-action@749f47bda3e44aa060e82d7b3ef7e40d953bd629
Expand Down
2 changes: 2 additions & 0 deletions automerge-kotlin/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
bin
.classpath
89 changes: 89 additions & 0 deletions automerge-kotlin/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
plugins {
kotlin("jvm")
id("org.danilopianini.publish-on-central")
id("org.jetbrains.dokka")
}

// Use in-process compilation to avoid Kotlin daemon filesystem issues
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
compilerExecutionStrategy.set(org.jetbrains.kotlin.gradle.tasks.KotlinCompilerExecutionStrategy.IN_PROCESS)
}

java {
withJavadocJar()
withSourcesJar()
toolchain {
languageVersion.set(JavaLanguageVersion.of(21))
}
}

repositories {
mavenCentral()
}

dependencies {
implementation(project(":lib"))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")

testImplementation(platform("org.junit:junit-bom:5.11.4"))
testImplementation("org.junit.jupiter:junit-jupiter-api")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
testImplementation("org.slf4j:slf4j-simple:2.0.9")
}

publishOnCentral {
projectDescription.set("Kotlin extensions for Automerge")
projectLongName.set("Automerge Kotlin")
}

publishing {
publications {
withType<MavenPublication> {
artifactId = "automerge-kotlin"
}
}
}

val env = providers.gradleProperty("env").getOrElse("release")
val isDev = env == "dev"

if (isDev) {
tasks.register<Exec>("compileRustForTest") {
workingDir = File("../rust")
commandLine = listOf("cargo", "build")
}

val version = (project.extra.get("libVersionSuffix") as String)

tasks.register("createVersionedLibForTest") {
dependsOn("compileRustForTest")
val debugDir = file("../rust/target/debug")
doLast {
listOf("libautomerge_jni" to "so", "libautomerge_jni" to "dylib", "automerge_jni" to "dll").forEach { (base, ext) ->
val src = debugDir.resolve("$base.$ext")
if (src.exists()) {
src.copyTo(debugDir.resolve("${base}_$version.$ext"), overwrite = true)
}
}
}
}

tasks.withType<Test> {
dependsOn("createVersionedLibForTest")
systemProperty("java.library.path", file("../rust/target/debug").absolutePath)
}
}

tasks.test {
useJUnitPlatform()
}

dokka {
moduleName.set("Automerge Kotlin")
// Fail the doc build on unresolved references or other warnings so CI
// catches broken KDoc before release.
dokkaPublications.configureEach {
failOnWarning.set(true)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package org.automerge.kotlin

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.future.await
import org.automerge.AmValue
import org.automerge.Document
import org.automerge.MapEntry
import org.automerge.ObjectId
import org.automerge.repo.DocHandle

// ── Core observe ────────────────────────────────────────────────────

/**
* Observe a document query as a [StateFlow].
*
* This suspend function:
* 1. Evaluates [query] once via `handle.withDocument(query)` to get the initial value.
* 2. Collects [changeFlow] and re-evaluates [query] on each change.
* 3. Uses [distinctUntilChanged] to suppress duplicate emissions.
* 4. Returns a [StateFlow] scoped to [scope].
*
* **Error handling:** If a query re-evaluation throws, the StateFlow retains its
* previous value (the error is swallowed). This prevents a single bad read from
* terminating the observation.
*
* @param scope The [CoroutineScope] that controls the lifetime of the observation.
* @param query A function that reads from a [Document] and returns a value of type [T].
* @return A [StateFlow] of the query result.
*/
suspend fun <T> DocHandle.observe(
scope: CoroutineScope,
query: (Document) -> T,
): StateFlow<T> {
val initial = withDocument(query).await()
@Suppress("UNCHECKED_CAST")
return changeFlow()
.map {
try {
withDocument(query).await()
} catch (_: Exception) {
initial // On error, keep previous/initial value
}
}
.distinctUntilChanged()
.stateIn(scope, SharingStarted.Eagerly, initial)
}

/**
* Observe a document query as a [StateFlow] with an explicit initial value.
*
* This non-suspend variant returns immediately. The first real query evaluation
* is launched asynchronously in [scope]. Useful in `ViewModel.init {}` where
* you want to assign a `val` without launching a coroutine.
*
* @param scope The [CoroutineScope] that controls the lifetime of the observation.
* @param initialValue The value the StateFlow starts with before the first query.
* @param query A function that reads from a [Document] and returns a value of type [T].
* @return A [StateFlow] of the query result.
*/
fun <T> DocHandle.observe(
scope: CoroutineScope,
initialValue: T,
query: (Document) -> T,
): StateFlow<T> {
val state = kotlinx.coroutines.flow.MutableStateFlow(initialValue)

// Launch a coroutine that:
// 1. Evaluates the query immediately to get the current state
// 2. Then collects changeFlow and re-evaluates on each change
scope.launch {
// Initial evaluation
try {
state.value = withDocument(query).await()
} catch (_: Exception) {
// Keep initialValue on error
}
// Ongoing evaluation on changes
changeFlow().collect {
try {
val newValue = withDocument(query).await()
state.value = newValue
} catch (_: Exception) {
// Keep previous value on error
}
}
}

return state
}

// ── Convenience extensions ──────────────────────────────────────────

/** Observe a String property at [key] in map [obj]. */
fun DocHandle.observeString(
scope: CoroutineScope, obj: ObjectId, key: String,
): StateFlow<String?> = observe(scope, null) { doc -> doc.getString(obj, key) }

/** Observe a Long property at [key] in map [obj]. */
fun DocHandle.observeLong(
scope: CoroutineScope, obj: ObjectId, key: String,
): StateFlow<Long?> = observe(scope, null) { doc -> doc.getLong(obj, key) }

/** Observe a Double property at [key] in map [obj]. */
fun DocHandle.observeDouble(
scope: CoroutineScope, obj: ObjectId, key: String,
): StateFlow<Double?> = observe(scope, null) { doc -> doc.getDouble(obj, key) }

/** Observe a Boolean property at [key] in map [obj]. */
fun DocHandle.observeBoolean(
scope: CoroutineScope, obj: ObjectId, key: String,
): StateFlow<Boolean?> = observe(scope, null) { doc -> doc.getBoolean(obj, key) }

/** Observe the text content of a Text object. */
fun DocHandle.observeText(
scope: CoroutineScope, obj: ObjectId,
): StateFlow<String?> = observe(scope, null) { doc -> doc.text(obj).orElse(null) }

/** Observe the entries of a Map object. */
fun DocHandle.observeMapEntries(
scope: CoroutineScope, obj: ObjectId,
): StateFlow<List<MapEntry>> = observe(scope, emptyList()) { doc ->
doc.mapEntries(obj).orElse(null)?.toList() ?: emptyList()
}

/** Observe the items of a List object. */
fun DocHandle.observeListItems(
scope: CoroutineScope, obj: ObjectId,
): StateFlow<List<AmValue>> = observe(scope, emptyList()) { doc ->
doc.listItems(obj).orElse(null)?.toList() ?: emptyList()
}

/** Suspend-friendly wrapper around [DocHandle.withDocument]. */
suspend fun <T> DocHandle.withDocAsync(mutation: (Document) -> T): T =
withDocument(mutation).await()
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package org.automerge.kotlin

import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import org.automerge.repo.DocHandle
import org.automerge.repo.DocumentChanged

/**
* Creates a [Flow] that emits [DocumentChanged] events whenever this document changes.
*
* The flow is backed by a change listener registered on this [DocHandle].
* The listener is automatically removed when the flow collector is cancelled.
*
* Events fire for any change reason: local mutation, remote sync, or merge.
*/
fun DocHandle.changeFlow(): Flow<DocumentChanged> = callbackFlow {
val registration = addChangeListener { event ->
trySend(event)
}
awaitClose { registration.remove() }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package org.automerge.kotlin

import org.automerge.AmValue
import org.automerge.ObjectId
import org.automerge.Read
import java.util.Date

// ── Scalar reads from maps ──────────────────────────────────────────

/** Get a String value from a map, or null if absent or wrong type. */
fun Read.getString(obj: ObjectId, key: String): String? =
get(obj, key).orElse(null)?.let { (it as? AmValue.Str)?.value }

/** Get a Long value from a map (Int or UInt), or null if absent or wrong type. */
fun Read.getLong(obj: ObjectId, key: String): Long? =
get(obj, key).orElse(null)?.let {
when (it) {
is AmValue.Int -> it.value
is AmValue.UInt -> it.value
else -> null
}
}

/** Get a Double value from a map, or null if absent or wrong type. */
fun Read.getDouble(obj: ObjectId, key: String): Double? =
get(obj, key).orElse(null)?.let { (it as? AmValue.F64)?.value }

/** Get a Boolean value from a map, or null if absent or wrong type. */
fun Read.getBoolean(obj: ObjectId, key: String): Boolean? =
get(obj, key).orElse(null)?.let { (it as? AmValue.Bool)?.value }

/** Get a ByteArray value from a map, or null if absent or wrong type. */
fun Read.getBytes(obj: ObjectId, key: String): ByteArray? =
get(obj, key).orElse(null)?.let { (it as? AmValue.Bytes)?.value }

/** Get a Counter value from a map as Long, or null if absent or wrong type. */
fun Read.getCounter(obj: ObjectId, key: String): Long? =
get(obj, key).orElse(null)?.let { (it as? AmValue.Counter)?.value }

/** Get a Timestamp value from a map, or null if absent or wrong type. */
fun Read.getTimestamp(obj: ObjectId, key: String): Date? =
get(obj, key).orElse(null)?.let { (it as? AmValue.Timestamp)?.value }

// ── Scalar reads from lists ─────────────────────────────────────────

/** Get a String value from a list by index, or null if absent or wrong type. */
fun Read.getString(obj: ObjectId, idx: Long): String? =
get(obj, idx).orElse(null)?.let { (it as? AmValue.Str)?.value }

/** Get a Long value from a list by index (Int or UInt), or null if absent or wrong type. */
fun Read.getLong(obj: ObjectId, idx: Long): Long? =
get(obj, idx).orElse(null)?.let {
when (it) {
is AmValue.Int -> it.value
is AmValue.UInt -> it.value
else -> null
}
}

/** Get a Double value from a list by index, or null if absent or wrong type. */
fun Read.getDouble(obj: ObjectId, idx: Long): Double? =
get(obj, idx).orElse(null)?.let { (it as? AmValue.F64)?.value }

/** Get a Boolean value from a list by index, or null if absent or wrong type. */
fun Read.getBoolean(obj: ObjectId, idx: Long): Boolean? =
get(obj, idx).orElse(null)?.let { (it as? AmValue.Bool)?.value }

/** Get a ByteArray value from a list by index, or null if absent or wrong type. */
fun Read.getBytes(obj: ObjectId, idx: Long): ByteArray? =
get(obj, idx).orElse(null)?.let { (it as? AmValue.Bytes)?.value }

// ── Object reads (return ObjectId of nested map/list/text) ──────────

/** Get the ObjectId of a nested Map from a map key, or null if absent or wrong type. */
fun Read.getMapId(obj: ObjectId, key: String): ObjectId? =
get(obj, key).orElse(null)?.let { (it as? AmValue.Map)?.id }

/** Get the ObjectId of a nested List from a map key, or null if absent or wrong type. */
fun Read.getListId(obj: ObjectId, key: String): ObjectId? =
get(obj, key).orElse(null)?.let { (it as? AmValue.List)?.id }

/** Get the ObjectId of a nested Text from a map key, or null if absent or wrong type. */
fun Read.getTextId(obj: ObjectId, key: String): ObjectId? =
get(obj, key).orElse(null)?.let { (it as? AmValue.Text)?.id }
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package org.automerge.kotlin

import kotlinx.coroutines.future.await
import org.automerge.repo.AutomergeUrl
import org.automerge.repo.DocHandle
import org.automerge.repo.DocumentId
import org.automerge.repo.Repo

/** Suspend-friendly [Repo.find] that returns `null` instead of [java.util.Optional.empty]. */
suspend fun Repo.findDocument(documentId: DocumentId): DocHandle? =
find(documentId).await().orElse(null)

/** Suspend-friendly [Repo.find] that returns `null` instead of [java.util.Optional.empty]. */
suspend fun Repo.findDocument(url: AutomergeUrl): DocHandle? =
find(url).await().orElse(null)
Loading
Loading