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.
128 changes: 128 additions & 0 deletions sdks/kotlin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# 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")
}
}
}
```

## Performance

The SDK has been benchmarked against the reference Rust benchmark client using the keynote-2 fund transfer workload (10 connections, 16384 max in-flight reducers, 100k accounts, Zipf distribution):

| Client | Avg TPS (Apple Silicon) |
|--------|------------------------|
| Rust (v1 protocol) | ~72,000 |
| Kotlin (v2 protocol) | ~88,000 |

See `Keynote2BenchmarkTest.kt` for the full benchmark and methodology.

## Roadmap

### Phase 1 — Code Generation & Typed Access

The highest-impact gap. Currently rows are raw BSATN bytes. Code generation will produce:

- **Typed table classes** — Generated data classes per table with proper field types, `equals`/`hashCode`, and BSATN serialization. Access rows as `Player(id=42, name="Alice")` instead of raw `ByteArray`.
- **Typed reducer calls** — Generated functions like `conn.reducers.addPlayer("Alice")` instead of manually encoding BSATN args.
- **Typed event callbacks** — `table.onInsert { player: Player -> ... }` instead of raw byte callbacks.

This requires adding a Kotlin backend to the `spacetime generate` CLI command (alongside the existing C#, TypeScript, and Rust backends in `crates/codegen/`).

### Phase 2 — Event System & Procedure Support

- **Rich event context** — Add `ReducerEventContext` and `SubscriptionEventContext` to callbacks, providing metadata about who triggered the event and transaction details (matching C#/Rust SDKs).
- **Procedure calls** — Support for `CallProcedure` (non-transactional server-side functions), completing API parity with the C# and Rust SDKs.

### Phase 3 — Observability

- **Structured logging** — Pluggable logging interface with configurable log levels (Debug/Info/Warn/Error). Default implementation for SLF4J on JVM and OSLog on iOS.
- **Connection metrics** — Request latency tracking, message counts, and byte throughput. Expose via Micrometer on JVM for Prometheus/Grafana integration.

### Phase 4 — Framework Integrations

- **Jetpack Compose** — `rememberSpacetimeDB()` composable, `collectAsState()` extensions for table subscriptions, lifecycle-aware connection management.
- **Kotlin Flow** — `Flow<List<T>>` adapters for table subscriptions, enabling reactive pipelines with `map`/`filter`/`combine`.
- **SwiftUI** — `@Observable` wrappers via KMP interop for native iOS integration.

### Phase 5 — Platform Expansion

- **Android target** — Dedicated Android source set with lifecycle integration, ProGuard rules, and a sample app.
- **Maven Central publish** — Automated release pipeline with version management.
- **Light mode & confirmed reads** — Advanced connection options to reduce bandwidth or increase consistency guarantees.

### Feature Parity Status

| Feature | Kotlin | C# | TypeScript | Rust |
|---------|--------|-----|-----------|------|
| BSATN Protocol | done | done | done | done |
| WebSocket Transport | done | done | done | done |
| Reconnection | done | done | done | done |
| Compression | done | done | done | done |
| Keep-Alive | done | done | done | done |
| Row Cache | done | done | done | done |
| Subscriptions | done | done | done | done |
| One-off Queries | done | done | done | done |
| Reducer Calls | done | done | done | done |
| Procedure Calls | planned | done | done | done |
| Code Generation | planned | done | done | done |
| Event System | partial | done | done | done |
| Logging | planned | done | done | done |
| Metrics | planned | done | done | done |
| Framework Integration | planned | done (Unity) | done (React/Vue) | — |

## 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