Skip to content
Open
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
136 changes: 136 additions & 0 deletions sdks/kotlin/DEVELOP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# Kotlin SDK — Developer Guide

Internal documentation for contributors working on the SpacetimeDB Kotlin SDK.

## Project Structure

```
src/
commonMain/ Shared Kotlin code (all targets)
com/clockworklabs/spacetimedb/
SpacetimeDBClient.kt DbConnection, DbConnectionBuilder
Identity.kt Identity, ConnectionId, Address, Timestamp
ClientCache.kt Client-side row cache (TableCache, ByteArrayWrapper)
TableHandle.kt Per-table callback registration
SubscriptionHandle.kt Subscription lifecycle
SubscriptionBuilder.kt Fluent subscription API
ReconnectPolicy.kt Exponential backoff configuration
Compression.kt expect declarations for decompression
bsatn/
BsatnReader.kt Binary deserialization
BsatnWriter.kt Binary serialization
BsatnRowList.kt Row list decoding
protocol/
ServerMessage.kt Server → Client message decoding
ClientMessage.kt Client → Server message encoding
ProtocolTypes.kt QuerySetId, QueryRows, TableUpdateRows, etc.
websocket/
WebSocketTransport.kt WebSocket lifecycle, ping/pong, reconnection
jvmMain/ JVM-specific (Gzip via java.util.zip, Brotli via org.brotli)
iosMain/ iOS-specific (Gzip via platform.zlib)
commonTest/ Shared tests
jvmTest/ JVM-only tests (compression round-trips)
```

## Architecture

### Connection Lifecycle

```
DbConnectionBuilder.build()
→ DbConnection constructor
→ WebSocketTransport.connect()
→ connectSession() opens WebSocket
→ processSendQueue() (coroutine: outbound messages)
→ processIncoming() (coroutine: inbound frames)
→ runKeepAlive() (coroutine: 30s idle ping/pong)
```

On unexpected disconnect with a `ReconnectPolicy`, the transport enters a
`RECONNECTING` state and calls `attemptReconnect()` which retries with
exponential backoff up to `maxRetries` times.

### Wire Protocol

Uses the `v2.bsatn.spacetimedb` WebSocket subprotocol. All messages are BSATN
(Binary SpacetimeDB Algebraic Type Notation) — a tag-length-value encoding
defined in `crates/client-api-messages/src/websocket/v2.rs`.

**Server messages** are preceded by a compression byte:
- `0x00` — uncompressed
- `0x01` — Brotli
- `0x02` — Gzip

The SDK requests Gzip compression via the `compression=Gzip` query parameter.

### Client Cache

`ClientCache` maintains a map of `TableCache` instances, one per table. Each
`TableCache` stores rows keyed by content (`ByteArrayWrapper`) with reference
counting. This allows overlapping subscriptions to share rows without duplicates.

Transaction updates produce `TableOperation` events (Insert, Delete, Update,
EventInsert) which drive the `TableHandle` callback system.

### Threading Model

- `WebSocketTransport` runs on a `CoroutineScope(SupervisorJob() + Dispatchers.Default)`.
- All `handleMessage` processing is serialized behind a `Mutex` to prevent
concurrent cache mutation.
- `atomicfu` atomics are used for transport-level flags (`idle`, `wantPong`,
`intentionalDisconnect`) that are read/written across coroutines.

### Platform-Specific Code

Uses Kotlin `expect`/`actual` for decompression:

| Platform | Gzip | Brotli |
|----------|------|--------|
| JVM | `java.util.zip.GZIPInputStream` | `org.brotli.dec.BrotliInputStream` |
| iOS | `platform.zlib` (wbits=31) | Not supported (SDK defaults to Gzip) |

## Building

```bash
# Run all JVM tests
./gradlew jvmTest

# Compile JVM
./gradlew compileKotlinJvm

# Compile iOS (verifies expect/actual)
./gradlew compileKotlinIosArm64

# All targets
./gradlew build
```

## Test Suite

| File | Coverage |
|------|----------|
| `BsatnTest.kt` | Reader/Writer round-trips for all primitive types |
| `ProtocolTest.kt` | ServerMessage and ClientMessage encode/decode |
| `ClientCacheTest.kt` | Cache operations, ref counting, transaction updates |
| `OneOffQueryTest.kt` | OneOffQueryResult decode (Ok and Err variants) |
| `CompressionTest.kt` | Gzip round-trip, empty/large payloads (JVM only) |
| `ReconnectPolicyTest.kt` | Backoff calculation, parameter validation |

## Design Decisions

1. **Manual ping/pong** instead of Ktor's `pingIntervalMillis` — OkHttp engine
doesn't support Ktor's built-in ping, so we implement idle detection
ourselves (matching the Rust SDK's 30s pattern).

2. **ByteArray row storage** — Rows are stored as raw BSATN bytes rather than
deserialized objects. This keeps the core SDK schema-agnostic; code
generation (future) will layer typed access on top.

3. **Compression negotiation** — The SDK advertises `compression=Gzip` in the
connection URI. Brotli is supported on JVM but not iOS; Gzip provides
universal coverage.

4. **No Brotli on iOS** — Apple's Compression framework supports Brotli
(`COMPRESSION_BROTLI`) but it's not directly available via Kotlin/Native's
`platform.compression` interop. Since the SDK requests Gzip, this is a
non-issue in practice.
63 changes: 63 additions & 0 deletions sdks/kotlin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# SpacetimeDB Kotlin SDK

## Overview

The Kotlin Multiplatform (KMP) client SDK for [SpacetimeDB](https://spacetimedb.com). Targets **JVM** and **iOS** (arm64, simulator-arm64, x64), enabling native SpacetimeDB clients from Kotlin, Java, and Swift (via KMP interop).

## Features

- BSATN binary protocol (`v2.bsatn.spacetimedb`)
- Subscriptions with SQL query support
- One-off queries (suspend and callback variants)
- Reducer invocation with result callbacks
- Automatic reconnection with exponential backoff
- Ping/pong keep-alive (30s idle timeout)
- Gzip and Brotli message decompression
- Client-side row cache with ref-counted rows

## Quick Start

```kotlin
val conn = DbConnection.builder()
.withUri("ws://localhost:3000")
.withModuleName("my_module")
.onConnect { conn, identity, token ->
println("Connected as $identity")

// Subscribe to table changes
conn.subscriptionBuilder()
.onApplied { println("Subscription active") }
.subscribe("SELECT * FROM users")

// Observe a table
conn.table("users").onInsert { row ->
println("New user row: ${row.size} bytes")
}
}
.onDisconnect { _, error ->
println("Disconnected: ${error?.message ?: "clean"}")
}
.build()
```

## Installation

Add to your `build.gradle.kts`:

```kotlin
kotlin {
sourceSets {
commonMain.dependencies {
implementation("com.clockworklabs:spacetimedb-sdk:0.1.0")
}
}
}
```

## Documentation

For the SpacetimeDB platform documentation, see [spacetimedb.com/docs](https://spacetimedb.com/docs).

## Internal Developer Documentation

See [`DEVELOP.md`](./DEVELOP.md).
42 changes: 42 additions & 0 deletions sdks/kotlin/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
plugins {
kotlin("multiplatform") version "2.1.0"
}

group = "com.clockworklabs"
version = "0.1.0"

kotlin {
jvm()
iosArm64()
iosSimulatorArm64()
iosX64()

applyDefaultHierarchyTemplate()

sourceSets {
commonMain.dependencies {
implementation("io.ktor:ktor-client-core:3.0.3")
implementation("io.ktor:ktor-client-websockets:3.0.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
implementation("org.jetbrains.kotlinx:atomicfu:0.23.2")
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
}
jvmMain.dependencies {
implementation("io.ktor:ktor-client-okhttp:3.0.3")
implementation("org.brotli:dec:0.1.2")
}
iosMain.dependencies {
implementation("io.ktor:ktor-client-darwin:3.0.3")
}
}
}

tasks.withType<Test> {
testLogging {
showStandardStreams = true
}
maxHeapSize = "1g"
}
3 changes: 3 additions & 0 deletions sdks/kotlin/gradle.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kotlin.code.style=official
kotlin.mpp.stability.nowarn=true
org.gradle.jvmargs=-Xmx2g
Binary file added sdks/kotlin/gradle/wrapper/gradle-wrapper.jar
Binary file not shown.
7 changes: 7 additions & 0 deletions sdks/kotlin/gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Loading