A minimal, deterministic, offline-first synchronization library for Android applications using Room.
MiniSync solves a narrow but recurring problem: keeping local Room data in sync with a remote system without hiding conflicts, enforcing backend assumptions, or introducing opaque magic.
- Local-first correctness - Local writes always succeed, regardless of network state
- Explicit conflict handling - Conflicts are surfaced, persisted, and owned by the application
- Deterministic behavior - Given the same inputs, sync always produces the same results
- Minimal public surface - Only essential APIs are exposed
- Predictability over convenience - No hidden background behavior, retries, or auto-resolution
- Version-based change detection
- Ordered, resumable sync execution
- Explicit conflict detection and signaling
- Transactional application of sync results
- Networking implementations
- Authentication
- Retry or backoff strategies
- Background scheduling (optional adapter only)
- Automatic conflict resolution
Add the dependencies to your build.gradle.kts:
dependencies {
// Core sync engine (pure Kotlin)
implementation("com.minisync:minisync-core:0.1.0")
// Room integration
implementation("com.minisync:minisync-room:0.1.0")
// Optional: Test utilities
testImplementation("com.minisync:minisync-test-utils:0.1.0")
}@Entity(tableName = "notes")
data class Note(
@PrimaryKey override val id: String,
override val version: Long,
override val syncState: SyncState,
override val lastModifiedAt: Long? = null,
val title: String,
val content: String
) : SyncEntity<String>class NotesRemoteSource(
private val api: NotesApi
) : RemoteSource<String, Note> {
override suspend fun push(entities: List<Note>): PushResult<String, Note> {
val succeeded = mutableListOf<Note>()
val failed = mutableListOf<PushFailure<String>>()
for (note in entities) {
try {
val updated = api.upsertNote(note)
succeeded.add(updated)
} catch (e: Exception) {
failed.add(PushFailure(note.id, e.message ?: "Unknown error", e))
}
}
return PushResult(succeeded, failed)
}
override suspend fun pull(cursor: String?, limit: Int): PullResult<String, Note> {
val response = api.getNotes(since = cursor, limit = limit)
return PullResult(
entities = response.notes,
nextCursor = response.nextCursor,
hasMore = response.hasMore
)
}
}class NotesLocalSource(
private val notesDao: NotesDao
) : LocalSource<String, Note> {
override suspend fun getDirtyEntities(): List<Note> {
return notesDao.getDirtyNotes()
}
override suspend fun getById(id: String): Note? {
return notesDao.getById(id)
}
// ... implement other methods
}val syncEngine = SyncEngine(
entityType = "notes",
localSource = notesLocalSource,
remoteSource = notesRemoteSource,
cursorStorage = RoomCursorStorage(cursorDao),
transactionManager = RoomTransactionManager(database),
conflictDetector = ConflictDetector(
payloadComparator = { a, b ->
a.title == b.title && a.content == b.content
}
),
entityUpdater = { entity, state, version ->
entity.copy(
syncState = state,
version = version ?: entity.version
)
}
)// Full sync (push then pull)
val result = syncEngine.sync()
// Or separately
val pushResult = syncEngine.push()
val pullResult = syncEngine.pull()
// Handle results
if (result.hasConflicts) {
// Show conflicts to user
result.conflicts.forEach { conflict ->
showConflictDialog(conflict)
}
}
if (result.hasErrors) {
// Log errors
result.errors.forEach { error ->
Log.e("Sync", "Error: ${error.message}", error.cause)
}
}// User chose to keep local version
syncEngine.resolveConflict(
conflict,
ConflictResolution.AcceptLocal(conflict.entityId)
)
// Or accept remote
syncEngine.resolveConflict(
conflict,
ConflictResolution.AcceptRemote(conflict.entityId)
)
// Or merge
syncEngine.resolveConflict(
conflict,
ConflictResolution.Merge(
entityId = conflict.entityId,
mergedEntity = createMergedNote(conflict.localEntity, conflict.remoteEntity)
)
)[Creation] --> LOCAL_ONLY
LOCAL_ONLY --> SYNCED (after successful push)
LOCAL_ONLY --> DIRTY (on modification)
DIRTY --> SYNCED (after successful push)
DIRTY --> CONFLICT (when remote has conflicting changes)
SYNCED --> DIRTY (on local modification)
SYNCED --> CONFLICT (when pull detects conflict)
CONFLICT --> SYNCED (after explicit resolution to accept remote)
CONFLICT --> DIRTY (after resolution to accept local or merge)
| Module | Purpose |
|---|---|
minisync-core |
Pure Kotlin sync engine, versioning, and conflict logic |
minisync-room |
Room-specific integration helpers |
minisync-test-utils |
Deterministic fakes, clocks, and simulators |
The minisync-test-utils module provides fakes for deterministic testing:
val localSource = FakeLocalSource<String, Note> { entity, state ->
entity.copy(syncState = state)
}
val remoteSource = FakeRemoteSource<String, Note>()
val cursorStorage = FakeCursorStorage()
val transactionManager = FakeTransactionManager()
val clock = TestClock()
// Configure remote behavior
remoteSource.addRemoteEntity(remoteNote)
remoteSource.configurePushFailure { note ->
if (note.id == "fail-id") PushFailure(note.id, "Simulated error")
else null
}Copyright 2024 Tanmay Yadav
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0