
Offline-first local data store syncing with remote servers — immediate local persistence, queued offline requests, automatic reconciliation, 3‑way merge conflict resolution, placeholder ID handling, and retryable sync.
keep your client up even when the network is down
Offline-first Kotlin Multiplatform SDK that keeps a local data store in sync with a remote server. App code performs data operations through a single API regardless of connectivity — changes persist locally immediately, queue for server transport when offline, and reconcile automatically when connectivity returns. Includes conflict resolution, automatic retries, and periodic sync-down from the server.
This README is optimized for AI agents. For humans, we created something more human optimized: an interactive html walkthrough (ironically, it's agent generated). For an interactive walkthrough covering why buoyient, how it works, and setup guidance, see: explore buoyient >>>
Read the guides in docs/ before writing code. They contain complete templates, required field tables, and common patterns. Use them instead of copying from the example services in this repo (your endpoints, auth, and field names will be different).
| Guide | When to use |
|---|---|
docs/setup.md |
Adding buoyient to an Android app: dependencies, initialization, service registration. Start here for Android. |
docs/setup-ios.md |
Adding buoyient to an iOS/SwiftUI app: SPM/XCFramework, initialization, background sync. Start here for iOS. |
docs/creating-a-service.md |
Creating a SyncableObjectService: data model, ServerProcessingConfig, service class, registration. |
docs/integration-testing.md |
Automated tests with TestServiceEnvironment, MockEndpointRouter, and the :testing module. |
docs/mock-mode.md |
Runtime mock mode for manual testing without a real backend. |
The templates/ directory contains copy-ready starter files for the model, request tag, server config, service, and integration test.
Agent instruction files (CLAUDE.md, CODEX.md, .cursorrules, .github/copilot-instructions.md) are also bundled in the published JAR under META-INF/.
@Serializable data model implementing SyncableObject<T>
ServiceRequestTag enumServerProcessingConfig<T> (sync-fetch + sync-up + headers)SyncableObjectService<T, Tag> with domain operations@IntoSet or Buoyient.registerServices())TestServiceEnvironment
Full walkthrough with templates: docs/creating-a-service.md
| Module | Artifact | Purpose |
|---|---|---|
:syncable-objects |
com.elvdev.buoyient:syncable-objects |
Core sync engine (KMP: Android, iOS, JVM) |
:hilt |
com.elvdev.buoyient:syncable-objects-hilt |
Optional Hilt integration — auto-registers services via @IntoSet multibinding (Android only) |
:mock-infra |
com.elvdev.buoyient:syncable-objects-mock-infra |
Shared mock infrastructure — mock HTTP routing, stateful server store, test doubles (KMP) |
:mock-mode |
com.elvdev.buoyient:syncable-objects-mock-mode |
Mock mode builder for running apps against fake server responses (KMP) |
:testing |
com.elvdev.buoyient:syncable-objects-testing |
Test utilities — in-memory DB, test harness, sync helpers (KMP) |
| Package | Purpose |
|---|---|
com.elvdev.buoyient (top level) |
Primary classes: SyncableObject, SyncableObjectService, ServiceRequestTag, Service
|
com.elvdev.buoyient.globalconfigs |
Project-level config: Buoyient, GlobalHeaderProvider, DatabaseProvider, HttpClientOverride, DatabaseOverride
|
com.elvdev.buoyient.serviceconfigs |
Per-service config: ServerProcessingConfig, SyncFetchConfig, SyncUpConfig, SyncUpResult, ConnectivityChecker, EncryptionProvider, PendingRequestQueueStrategy, SyncableObjectRebaseHandler
|
com.elvdev.buoyient.datatypes |
Data types: HttpRequest, SyncableObjectServiceResponse, SyncableObjectServiceRequestState, GetResponse, ResolveConflictResult, CreateRequestBuilder, UpdateRequestBuilder, VoidRequestBuilder, ResponseUnpacker, SquashRequestMerger
|
com.elvdev.buoyient.utils |
Utilities: SyncCodec, BuoyientLog, BuoyientLogger
|
Internal packages (managers, sync) are not part of the public API.
| Class | Purpose |
|---|---|
SyncableObject<O> |
Interface for your domain model (@Serializable data class) |
SyncableObjectService<O, T> |
Base class for services — create(), update(), void(), get() and flow variants createWithFlow(), updateWithFlow(), voidWithFlow()
|
ServiceRequestTag |
Interface for request type enums — passed to every operation |
Buoyient |
Service registration, global header config, on-demand sync via syncNow()
|
GlobalHeaderProvider |
Dynamic global headers (e.g., auth tokens) — set via Buoyient.globalHeaderProvider
|
ServerProcessingConfig<O> |
Tells the sync engine how to talk to your API |
SyncFetchConfig<O> |
Configures periodic sync-down (GET or POST) |
SyncUpConfig<O> |
Sync-up retry logic and response parsing via fromResponseBody()
|
SyncUpResult<O> |
Sealed return type: Success(data), Failed.Retry(), or Failed.RemovePendingRequest()
|
PendingRequestQueueStrategy |
Queue (default, one entry per op) or Squash (collapses consecutive offline edits) |
SyncableObjectRebaseHandler<O> |
3-way merge conflict detection and resolution |
HttpRequest |
HTTP request builder with placeholder resolution for offline requests |
SyncableObjectServiceResponse<O> |
Sealed response type for all service operations |
SyncableObjectServiceRequestState<O> |
Sealed state for flow-based operations: Loading or Result(response)
|
CreateRequestBuilder / UpdateRequestBuilder / VoidRequestBuilder
|
Functional interfaces for building requests |
ResponseUnpacker |
Functional interface for extracting objects from server responses |
| Method | Purpose |
|---|---|
create(data, requestTag, request, unpackSyncData) |
Persist new object locally and send to server (or queue offline) |
update(data, requestTag, request, unpackSyncData) |
Apply changes to existing object |
void(data, requestTag, request, unpackSyncData) |
Mark object as voided, remove pending requests |
get(clientId) |
Fetch from server if online, fallback to local store |
getAllFromLocalStore(limit) |
All items from local DB |
getFromLocalStore(syncStatus, includeVoided, limit) |
SQL-level filter by sync status / voided flag |
getFromLocalStore(predicate, limit) |
In-memory filter via lambda |
All get methods have AsFlow variants |
Observe changes reactively |
syncDownFromServer() |
Trigger sync-down for this service |
Buoyient.syncNow(completion?) |
Trigger immediate sync-up pass across all services |
| Class | Override | Purpose |
|---|---|---|
SyncableObjectService |
create(), update(), void(), get()
|
Define your public API |
SyncableObjectRebaseHandler |
rebaseDataForPendingRequest() |
Custom 3-way merge logic |
SyncableObjectRebaseHandler |
handleMergeConflict() |
Custom conflict resolution |
SyncUpConfig |
acceptUploadResponseAsProcessed() |
Custom success criteria |
SyncUpConfig |
fromResponseBody() |
Response deserialization for sync-up (returns SyncUpResult) |
These are the rules that cause the most agent errors when missed:
serviceName must be unique per service — it's the SQLite partition key.
Constructor takes only 3 required args: serializer (KSerializer<O>), serverProcessingConfig, and serviceName. Internal dependencies are constructed automatically — do not pass them. Optional params: connectivityChecker, encryptionProvider, queueStrategy, rebaseHandler.
Data models must be @Serializable and implement withSyncStatus(). Mark syncStatus as @Transient — buoyient manages it separately.
Use placeholder helpers for offline requests:
HttpRequest.serverIdOrPlaceholder(serverId) — returns real value when non-null, or {serverId} placeholder resolved at sync timeHttpRequest.versionOrPlaceholder(version) — same for versionHttpRequest.crossServiceServerIdPlaceholder(serviceName, clientId) — reference another service's server ID in offline requestsEvery operation requires a ServiceRequestTag and uses functional interfaces: CreateRequestBuilder, UpdateRequestBuilder, VoidRequestBuilder, ResponseUnpacker.
SyncUpConfig.fromResponseBody(requestTag, responseBody) returns SyncUpResult<O>: Success(data), Failed.Retry() (re-queue), or Failed.RemovePendingRequest() (drop from queue).
SyncableObject companion constants use _KEY suffix: SERVER_ID_KEY, CLIENT_ID_KEY, VERSION_KEY.
Registration for background sync: use Hilt @IntoSet multibinding with :hilt, or Buoyient.registerServices() / Buoyient.registerServiceProvider() without Hilt.
Queue strategy: Use Squash when the API uses PUT/replace semantics and intermediate offline states don't matter. Use Queue (default) when request order matters or each write has side effects. See docs/creating-a-service.md § "Pending request queue strategy".
Cross-service dependencies: Use HttpRequest.crossServiceServerIdPlaceholder(serviceName, clientId) when one service's request references another service's server ID. See docs/creating-a-service.md § "Cross-service dependencies".
Mock mode and integration testing: Use Buoyient.httpClient and Buoyient.database for global overrides, or TestServiceEnvironment which sets them automatically.
Do not copy Square-specific values from the example services in this repo. Use the consuming app's endpoints, auth, field names, and base URLs.
testImplementation("com.elvdev.buoyient:testing:<version>")TestServiceEnvironment provides a fully wired harness — mock HTTP, in-memory DB, controllable connectivity:
@Test
fun `create item online returns server response`() = runBlocking {
val env = TestServiceEnvironment()
env.mockRouter.onPost("https://api.example.com/items") { request ->
MockResponse(201, buildJsonObject {
put("item", buildJsonObject {
put("id", "srv-1")
put("reference_id", request.body["reference_id"]!!)
put("title", request.body["title"]!!)
put("version", 1)
})
})
}
// Service constructor: only required + connectivityChecker for test control
val service = TodoService(
connectivityChecker = env.connectivityChecker,
)
val result = service.addTodo("Buy milk")
assertTrue(result is SyncableObjectServiceResponse.Finished.NetworkResponseReceived)
assertEquals(1, env.mockRouter.requestLog.size)
service.close()
}Toggle offline: env.connectivityChecker.online = false
Full testing guide: docs/integration-testing.md | Mock mode guide: docs/mock-mode.md
| Class | Purpose |
|---|---|
TestServiceEnvironment |
All-in-one harness: mock server, in-memory DB, test doubles |
MockEndpointRouter |
Register mock HTTP handlers by method + URL pattern; inspect requestLog
|
MockResponse / RecordedRequest
|
Define responses and inspect captured requests |
MockConnectionException |
Throw from handler to simulate network failure |
MockServerStore |
Stateful mock server with named collections |
MockServerCollection |
Per-collection CRUD, seed, mutate, inspect |
registerCrudHandlers() |
Auto-wire CRUD handlers backed by a collection |
registerSyncDownHandler() |
Auto-wire sync-down with timestamp filtering |
TestConnectivityChecker |
Mutable online flag for online/offline control |
IncrementingIdGenerator |
Deterministic sequential IDs (test-id-1, test-id-2, ...) |
PrintSyncLogger / NoOpSyncLogger
|
Stdout or silent logging |
SyncableObjectRebaseHandler.{serverId} and {version} placeholders resolved at sync time for safe offline chaining.ServiceRequestTag enums for per-operation response handling.| Platform | Status | Stack |
|---|---|---|
| Android | Supported (API 27+) | Ktor OkHttp, SQLDelight Android driver, WorkManager, ConnectivityManager, androidx.startup
|
| iOS | Supported (iOS 15+) | Ktor Darwin, SQLDelight Native driver, BGTaskScheduler, NWPathMonitor, SKIE for Swift APIs |
| JVM | Supported (tests & server-side) | Ktor CIO, SQLDelight JDBC driver |
buoyient is distributed to iOS projects as an XCFramework via Swift Package Manager:
# Build the framework
./gradlew :syncable-objects:assembleBuoyientReleaseXCFrameworkThe framework includes SKIE integration, which automatically provides Swift-native APIs: suspend -> async/await, sealed class -> Swift enum, Flow -> AsyncSequence.
See docs/setup-ios.md for the complete iOS setup guide.
./gradlew :syncable-objects:build
./gradlew :hilt:build
./gradlew :testing:buildBuild iOS XCFramework:
./gradlew :syncable-objects:assembleBuoyientReleaseXCFrameworkRun tests:
./gradlew :syncable-objects:jvmTest
./gradlew :mock-infra:jvmTest
./gradlew :mock-mode:jvmTest
./gradlew :testing:jvmTestPublish to local Maven:
./gradlew :syncable-objects:publishToMavenLocal
./gradlew :hilt:publishToMavenLocal
./gradlew :testing:publishToMavenLocalkeep your client up even when the network is down
Offline-first Kotlin Multiplatform SDK that keeps a local data store in sync with a remote server. App code performs data operations through a single API regardless of connectivity — changes persist locally immediately, queue for server transport when offline, and reconcile automatically when connectivity returns. Includes conflict resolution, automatic retries, and periodic sync-down from the server.
This README is optimized for AI agents. For humans, we created something more human optimized: an interactive html walkthrough (ironically, it's agent generated). For an interactive walkthrough covering why buoyient, how it works, and setup guidance, see: explore buoyient >>>
Read the guides in docs/ before writing code. They contain complete templates, required field tables, and common patterns. Use them instead of copying from the example services in this repo (your endpoints, auth, and field names will be different).
| Guide | When to use |
|---|---|
docs/setup.md |
Adding buoyient to an Android app: dependencies, initialization, service registration. Start here for Android. |
docs/setup-ios.md |
Adding buoyient to an iOS/SwiftUI app: SPM/XCFramework, initialization, background sync. Start here for iOS. |
docs/creating-a-service.md |
Creating a SyncableObjectService: data model, ServerProcessingConfig, service class, registration. |
docs/integration-testing.md |
Automated tests with TestServiceEnvironment, MockEndpointRouter, and the :testing module. |
docs/mock-mode.md |
Runtime mock mode for manual testing without a real backend. |
The templates/ directory contains copy-ready starter files for the model, request tag, server config, service, and integration test.
Agent instruction files (CLAUDE.md, CODEX.md, .cursorrules, .github/copilot-instructions.md) are also bundled in the published JAR under META-INF/.
@Serializable data model implementing SyncableObject<T>
ServiceRequestTag enumServerProcessingConfig<T> (sync-fetch + sync-up + headers)SyncableObjectService<T, Tag> with domain operations@IntoSet or Buoyient.registerServices())TestServiceEnvironment
Full walkthrough with templates: docs/creating-a-service.md
| Module | Artifact | Purpose |
|---|---|---|
:syncable-objects |
com.elvdev.buoyient:syncable-objects |
Core sync engine (KMP: Android, iOS, JVM) |
:hilt |
com.elvdev.buoyient:syncable-objects-hilt |
Optional Hilt integration — auto-registers services via @IntoSet multibinding (Android only) |
:mock-infra |
com.elvdev.buoyient:syncable-objects-mock-infra |
Shared mock infrastructure — mock HTTP routing, stateful server store, test doubles (KMP) |
:mock-mode |
com.elvdev.buoyient:syncable-objects-mock-mode |
Mock mode builder for running apps against fake server responses (KMP) |
:testing |
com.elvdev.buoyient:syncable-objects-testing |
Test utilities — in-memory DB, test harness, sync helpers (KMP) |
| Package | Purpose |
|---|---|
com.elvdev.buoyient (top level) |
Primary classes: SyncableObject, SyncableObjectService, ServiceRequestTag, Service
|
com.elvdev.buoyient.globalconfigs |
Project-level config: Buoyient, GlobalHeaderProvider, DatabaseProvider, HttpClientOverride, DatabaseOverride
|
com.elvdev.buoyient.serviceconfigs |
Per-service config: ServerProcessingConfig, SyncFetchConfig, SyncUpConfig, SyncUpResult, ConnectivityChecker, EncryptionProvider, PendingRequestQueueStrategy, SyncableObjectRebaseHandler
|
com.elvdev.buoyient.datatypes |
Data types: HttpRequest, SyncableObjectServiceResponse, SyncableObjectServiceRequestState, GetResponse, ResolveConflictResult, CreateRequestBuilder, UpdateRequestBuilder, VoidRequestBuilder, ResponseUnpacker, SquashRequestMerger
|
com.elvdev.buoyient.utils |
Utilities: SyncCodec, BuoyientLog, BuoyientLogger
|
Internal packages (managers, sync) are not part of the public API.
| Class | Purpose |
|---|---|
SyncableObject<O> |
Interface for your domain model (@Serializable data class) |
SyncableObjectService<O, T> |
Base class for services — create(), update(), void(), get() and flow variants createWithFlow(), updateWithFlow(), voidWithFlow()
|
ServiceRequestTag |
Interface for request type enums — passed to every operation |
Buoyient |
Service registration, global header config, on-demand sync via syncNow()
|
GlobalHeaderProvider |
Dynamic global headers (e.g., auth tokens) — set via Buoyient.globalHeaderProvider
|
ServerProcessingConfig<O> |
Tells the sync engine how to talk to your API |
SyncFetchConfig<O> |
Configures periodic sync-down (GET or POST) |
SyncUpConfig<O> |
Sync-up retry logic and response parsing via fromResponseBody()
|
SyncUpResult<O> |
Sealed return type: Success(data), Failed.Retry(), or Failed.RemovePendingRequest()
|
PendingRequestQueueStrategy |
Queue (default, one entry per op) or Squash (collapses consecutive offline edits) |
SyncableObjectRebaseHandler<O> |
3-way merge conflict detection and resolution |
HttpRequest |
HTTP request builder with placeholder resolution for offline requests |
SyncableObjectServiceResponse<O> |
Sealed response type for all service operations |
SyncableObjectServiceRequestState<O> |
Sealed state for flow-based operations: Loading or Result(response)
|
CreateRequestBuilder / UpdateRequestBuilder / VoidRequestBuilder
|
Functional interfaces for building requests |
ResponseUnpacker |
Functional interface for extracting objects from server responses |
| Method | Purpose |
|---|---|
create(data, requestTag, request, unpackSyncData) |
Persist new object locally and send to server (or queue offline) |
update(data, requestTag, request, unpackSyncData) |
Apply changes to existing object |
void(data, requestTag, request, unpackSyncData) |
Mark object as voided, remove pending requests |
get(clientId) |
Fetch from server if online, fallback to local store |
getAllFromLocalStore(limit) |
All items from local DB |
getFromLocalStore(syncStatus, includeVoided, limit) |
SQL-level filter by sync status / voided flag |
getFromLocalStore(predicate, limit) |
In-memory filter via lambda |
All get methods have AsFlow variants |
Observe changes reactively |
syncDownFromServer() |
Trigger sync-down for this service |
Buoyient.syncNow(completion?) |
Trigger immediate sync-up pass across all services |
| Class | Override | Purpose |
|---|---|---|
SyncableObjectService |
create(), update(), void(), get()
|
Define your public API |
SyncableObjectRebaseHandler |
rebaseDataForPendingRequest() |
Custom 3-way merge logic |
SyncableObjectRebaseHandler |
handleMergeConflict() |
Custom conflict resolution |
SyncUpConfig |
acceptUploadResponseAsProcessed() |
Custom success criteria |
SyncUpConfig |
fromResponseBody() |
Response deserialization for sync-up (returns SyncUpResult) |
These are the rules that cause the most agent errors when missed:
serviceName must be unique per service — it's the SQLite partition key.
Constructor takes only 3 required args: serializer (KSerializer<O>), serverProcessingConfig, and serviceName. Internal dependencies are constructed automatically — do not pass them. Optional params: connectivityChecker, encryptionProvider, queueStrategy, rebaseHandler.
Data models must be @Serializable and implement withSyncStatus(). Mark syncStatus as @Transient — buoyient manages it separately.
Use placeholder helpers for offline requests:
HttpRequest.serverIdOrPlaceholder(serverId) — returns real value when non-null, or {serverId} placeholder resolved at sync timeHttpRequest.versionOrPlaceholder(version) — same for versionHttpRequest.crossServiceServerIdPlaceholder(serviceName, clientId) — reference another service's server ID in offline requestsEvery operation requires a ServiceRequestTag and uses functional interfaces: CreateRequestBuilder, UpdateRequestBuilder, VoidRequestBuilder, ResponseUnpacker.
SyncUpConfig.fromResponseBody(requestTag, responseBody) returns SyncUpResult<O>: Success(data), Failed.Retry() (re-queue), or Failed.RemovePendingRequest() (drop from queue).
SyncableObject companion constants use _KEY suffix: SERVER_ID_KEY, CLIENT_ID_KEY, VERSION_KEY.
Registration for background sync: use Hilt @IntoSet multibinding with :hilt, or Buoyient.registerServices() / Buoyient.registerServiceProvider() without Hilt.
Queue strategy: Use Squash when the API uses PUT/replace semantics and intermediate offline states don't matter. Use Queue (default) when request order matters or each write has side effects. See docs/creating-a-service.md § "Pending request queue strategy".
Cross-service dependencies: Use HttpRequest.crossServiceServerIdPlaceholder(serviceName, clientId) when one service's request references another service's server ID. See docs/creating-a-service.md § "Cross-service dependencies".
Mock mode and integration testing: Use Buoyient.httpClient and Buoyient.database for global overrides, or TestServiceEnvironment which sets them automatically.
Do not copy Square-specific values from the example services in this repo. Use the consuming app's endpoints, auth, field names, and base URLs.
testImplementation("com.elvdev.buoyient:testing:<version>")TestServiceEnvironment provides a fully wired harness — mock HTTP, in-memory DB, controllable connectivity:
@Test
fun `create item online returns server response`() = runBlocking {
val env = TestServiceEnvironment()
env.mockRouter.onPost("https://api.example.com/items") { request ->
MockResponse(201, buildJsonObject {
put("item", buildJsonObject {
put("id", "srv-1")
put("reference_id", request.body["reference_id"]!!)
put("title", request.body["title"]!!)
put("version", 1)
})
})
}
// Service constructor: only required + connectivityChecker for test control
val service = TodoService(
connectivityChecker = env.connectivityChecker,
)
val result = service.addTodo("Buy milk")
assertTrue(result is SyncableObjectServiceResponse.Finished.NetworkResponseReceived)
assertEquals(1, env.mockRouter.requestLog.size)
service.close()
}Toggle offline: env.connectivityChecker.online = false
Full testing guide: docs/integration-testing.md | Mock mode guide: docs/mock-mode.md
| Class | Purpose |
|---|---|
TestServiceEnvironment |
All-in-one harness: mock server, in-memory DB, test doubles |
MockEndpointRouter |
Register mock HTTP handlers by method + URL pattern; inspect requestLog
|
MockResponse / RecordedRequest
|
Define responses and inspect captured requests |
MockConnectionException |
Throw from handler to simulate network failure |
MockServerStore |
Stateful mock server with named collections |
MockServerCollection |
Per-collection CRUD, seed, mutate, inspect |
registerCrudHandlers() |
Auto-wire CRUD handlers backed by a collection |
registerSyncDownHandler() |
Auto-wire sync-down with timestamp filtering |
TestConnectivityChecker |
Mutable online flag for online/offline control |
IncrementingIdGenerator |
Deterministic sequential IDs (test-id-1, test-id-2, ...) |
PrintSyncLogger / NoOpSyncLogger
|
Stdout or silent logging |
SyncableObjectRebaseHandler.{serverId} and {version} placeholders resolved at sync time for safe offline chaining.ServiceRequestTag enums for per-operation response handling.| Platform | Status | Stack |
|---|---|---|
| Android | Supported (API 27+) | Ktor OkHttp, SQLDelight Android driver, WorkManager, ConnectivityManager, androidx.startup
|
| iOS | Supported (iOS 15+) | Ktor Darwin, SQLDelight Native driver, BGTaskScheduler, NWPathMonitor, SKIE for Swift APIs |
| JVM | Supported (tests & server-side) | Ktor CIO, SQLDelight JDBC driver |
buoyient is distributed to iOS projects as an XCFramework via Swift Package Manager:
# Build the framework
./gradlew :syncable-objects:assembleBuoyientReleaseXCFrameworkThe framework includes SKIE integration, which automatically provides Swift-native APIs: suspend -> async/await, sealed class -> Swift enum, Flow -> AsyncSequence.
See docs/setup-ios.md for the complete iOS setup guide.
./gradlew :syncable-objects:build
./gradlew :hilt:build
./gradlew :testing:buildBuild iOS XCFramework:
./gradlew :syncable-objects:assembleBuoyientReleaseXCFrameworkRun tests:
./gradlew :syncable-objects:jvmTest
./gradlew :mock-infra:jvmTest
./gradlew :mock-mode:jvmTest
./gradlew :testing:jvmTestPublish to local Maven:
./gradlew :syncable-objects:publishToMavenLocal
./gradlew :hilt:publishToMavenLocal
./gradlew :testing:publishToMavenLocal